feat: sync recent call and channel fixes
This commit is contained in:
parent
e5e0080216
commit
866a87583e
65 changed files with 6680 additions and 229 deletions
24
deploy/migrations/0037_conference_calls.down.sql
Normal file
24
deploy/migrations/0037_conference_calls.down.sql
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
DROP TABLE IF EXISTS public.group_call_chain_blocks;
|
||||
DROP TABLE IF EXISTS public.group_call_invites;
|
||||
|
||||
DROP INDEX IF EXISTS public.group_calls_conference_random_uniq;
|
||||
DROP INDEX IF EXISTS public.group_calls_invite_slug_uniq;
|
||||
DROP INDEX IF EXISTS public.group_calls_active_channel_uniq;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS group_calls_active_channel_uniq
|
||||
ON public.group_calls USING btree (channel_id)
|
||||
WHERE (state = 'active'::text);
|
||||
|
||||
ALTER TABLE public.group_call_participants
|
||||
DROP COLUMN IF EXISTS join_block,
|
||||
DROP COLUMN IF EXISTS public_key;
|
||||
|
||||
ALTER TABLE public.group_calls
|
||||
DROP CONSTRAINT IF EXISTS group_calls_kind_check;
|
||||
|
||||
ALTER TABLE public.group_calls
|
||||
DROP COLUMN IF EXISTS migrated_from_phone_call_id,
|
||||
DROP COLUMN IF EXISTS random_id,
|
||||
DROP COLUMN IF EXISTS invite_link,
|
||||
DROP COLUMN IF EXISTS invite_slug,
|
||||
DROP COLUMN IF EXISTS kind;
|
||||
58
deploy/migrations/0037_conference_calls.up.sql
Normal file
58
deploy/migrations/0037_conference_calls.up.sql
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
ALTER TABLE public.group_calls
|
||||
ADD COLUMN IF NOT EXISTS kind text DEFAULT 'channel'::text NOT NULL,
|
||||
ADD COLUMN IF NOT EXISTS invite_slug text DEFAULT ''::text NOT NULL,
|
||||
ADD COLUMN IF NOT EXISTS invite_link text DEFAULT ''::text NOT NULL,
|
||||
ADD COLUMN IF NOT EXISTS random_id bigint DEFAULT 0 NOT NULL,
|
||||
ADD COLUMN IF NOT EXISTS migrated_from_phone_call_id bigint DEFAULT 0 NOT NULL;
|
||||
|
||||
ALTER TABLE public.group_calls
|
||||
DROP CONSTRAINT IF EXISTS group_calls_kind_check;
|
||||
|
||||
ALTER TABLE public.group_calls
|
||||
ADD CONSTRAINT group_calls_kind_check CHECK (kind = ANY (ARRAY['channel'::text, 'conference'::text]));
|
||||
|
||||
ALTER TABLE public.group_call_participants
|
||||
ADD COLUMN IF NOT EXISTS public_key bytea,
|
||||
ADD COLUMN IF NOT EXISTS join_block bytea;
|
||||
|
||||
DROP INDEX IF EXISTS public.group_calls_active_channel_uniq;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS group_calls_active_channel_uniq
|
||||
ON public.group_calls USING btree (channel_id)
|
||||
WHERE ((state = 'active'::text) AND (channel_id <> 0));
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS group_calls_invite_slug_uniq
|
||||
ON public.group_calls USING btree (invite_slug)
|
||||
WHERE (invite_slug <> ''::text);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS group_calls_conference_random_uniq
|
||||
ON public.group_calls USING btree (creator_user_id, random_id)
|
||||
WHERE ((kind = 'conference'::text) AND (random_id <> 0));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.group_call_invites (
|
||||
call_id bigint NOT NULL REFERENCES public.group_calls(call_id) ON DELETE CASCADE,
|
||||
inviter_user_id bigint NOT NULL,
|
||||
invitee_user_id bigint NOT NULL,
|
||||
message_id integer NOT NULL,
|
||||
status text DEFAULT 'pending'::text NOT NULL,
|
||||
video boolean DEFAULT false NOT NULL,
|
||||
created_at integer NOT NULL,
|
||||
updated_at integer DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT group_call_invites_status_check CHECK (status = ANY (ARRAY['pending'::text, 'accepted'::text, 'declined'::text, 'missed'::text, 'revoked'::text])),
|
||||
CONSTRAINT group_call_invites_pkey PRIMARY KEY (call_id, invitee_user_id, message_id)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS group_call_invites_msg_uniq
|
||||
ON public.group_call_invites USING btree (invitee_user_id, message_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS group_call_invites_call_idx
|
||||
ON public.group_call_invites USING btree (call_id, invitee_user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.group_call_chain_blocks (
|
||||
call_id bigint NOT NULL REFERENCES public.group_calls(call_id) ON DELETE CASCADE,
|
||||
sub_chain_id integer DEFAULT 0 NOT NULL,
|
||||
block_offset integer NOT NULL,
|
||||
block bytea NOT NULL,
|
||||
created_at integer NOT NULL,
|
||||
CONSTRAINT group_call_chain_blocks_pkey PRIMARY KEY (call_id, sub_chain_id, block_offset)
|
||||
);
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- Data repair only. Do not remove ManageRanks on rollback because later user edits
|
||||
-- may have intentionally granted it.
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
UPDATE channel_members m
|
||||
SET admin_rights = m.admin_rights || '{"ManageRanks": true}'::jsonb
|
||||
FROM channels c
|
||||
WHERE c.id = m.channel_id
|
||||
AND NOT c.deleted
|
||||
AND c.megagroup
|
||||
AND NOT c.broadcast
|
||||
AND m.status = 'active'
|
||||
AND (
|
||||
m.role = 'creator'
|
||||
OR (
|
||||
m.role = 'admin'
|
||||
AND COALESCE((m.admin_rights ->> 'ChangeInfo')::boolean, false)
|
||||
AND COALESCE((m.admin_rights ->> 'DeleteMessages')::boolean, false)
|
||||
AND COALESCE((m.admin_rights ->> 'BanUsers')::boolean, false)
|
||||
AND COALESCE((m.admin_rights ->> 'InviteUsers')::boolean, false)
|
||||
AND COALESCE((m.admin_rights ->> 'PinMessages')::boolean, false)
|
||||
AND COALESCE((m.admin_rights ->> 'AddAdmins')::boolean, false)
|
||||
AND COALESCE((m.admin_rights ->> 'ManageCall')::boolean, false)
|
||||
)
|
||||
);
|
||||
4
deploy/migrations/0039_group_call_chain_author.down.sql
Normal file
4
deploy/migrations/0039_group_call_chain_author.down.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
DROP INDEX IF EXISTS public.group_call_chain_blocks_author_idx;
|
||||
|
||||
ALTER TABLE public.group_call_chain_blocks
|
||||
DROP COLUMN IF EXISTS author_user_id;
|
||||
5
deploy/migrations/0039_group_call_chain_author.up.sql
Normal file
5
deploy/migrations/0039_group_call_chain_author.up.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
ALTER TABLE public.group_call_chain_blocks
|
||||
ADD COLUMN IF NOT EXISTS author_user_id bigint DEFAULT 0 NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS group_call_chain_blocks_author_idx
|
||||
ON public.group_call_chain_blocks USING btree (call_id, sub_chain_id, author_user_id, block_offset);
|
||||
|
|
@ -0,0 +1 @@
|
|||
-- No-op: discarded empty conference calls are terminal cleanup records.
|
||||
31
deploy/migrations/0040_discard_empty_conferences.up.sql
Normal file
31
deploy/migrations/0040_discard_empty_conferences.up.sql
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
WITH stale AS (
|
||||
SELECT c.call_id
|
||||
FROM public.group_calls c
|
||||
WHERE c.kind = 'conference'::text
|
||||
AND c.state = 'active'::text
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM public.group_call_participants p
|
||||
WHERE p.call_id = c.call_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.group_call_participants p
|
||||
WHERE p.call_id = c.call_id
|
||||
AND NOT p.left_call
|
||||
)
|
||||
)
|
||||
UPDATE public.group_calls c
|
||||
SET state = 'discarded'::text,
|
||||
discarded_at = CASE
|
||||
WHEN c.discarded_at = 0 THEN EXTRACT(EPOCH FROM now())::integer
|
||||
ELSE c.discarded_at
|
||||
END,
|
||||
duration = CASE
|
||||
WHEN c.duration = 0 THEN GREATEST(0, EXTRACT(EPOCH FROM now())::integer - c.created_at)
|
||||
ELSE c.duration
|
||||
END,
|
||||
participants_count = 0,
|
||||
version = c.version + 1
|
||||
FROM stale
|
||||
WHERE c.call_id = stale.call_id;
|
||||
|
|
@ -53,6 +53,15 @@ func (c *participantsReadModelCache) getOrLoad(ctx context.Context, key particip
|
|||
return c.cache.GetOrLoadVersioned(ctx, key, hash, load)
|
||||
}
|
||||
|
||||
func (c *participantsReadModelCache) invalidateChannel(channelID int64) {
|
||||
if c == nil || channelID == 0 {
|
||||
return
|
||||
}
|
||||
c.cache.InvalidateWhere(func(key participantsCacheKey) bool {
|
||||
return key.channelID == channelID
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) cachedParticipants(ctx context.Context, userID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) {
|
||||
filter, offset, limit = normalizeParticipantsRequest(filter, offset, limit)
|
||||
if s.participantCache == nil || s.versions == nil {
|
||||
|
|
|
|||
|
|
@ -237,6 +237,7 @@ func (s *Service) InviteToChannel(ctx context.Context, userID, channelID int64,
|
|||
res, err := s.channels.InviteToChannel(ctx, channelID, userID, userIDs, date)
|
||||
if err == nil {
|
||||
s.invalidateActiveChannelIDs(activeMembershipUserIDsFromMembers(0, res.Members)...)
|
||||
s.participantCache.invalidateChannel(channelID)
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
|
@ -249,6 +250,7 @@ func (s *Service) JoinChannel(ctx context.Context, userID, channelID int64, date
|
|||
res, err := s.channels.JoinChannel(ctx, channelID, userID, date)
|
||||
if err == nil {
|
||||
s.invalidateActiveChannelIDs(userID)
|
||||
s.participantCache.invalidateChannel(channelID)
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
|
@ -261,6 +263,7 @@ func (s *Service) LeaveChannel(ctx context.Context, userID, channelID int64, dat
|
|||
res, err := s.channels.LeaveChannel(ctx, channelID, userID, date)
|
||||
if err == nil {
|
||||
s.invalidateActiveChannelIDs(userID)
|
||||
s.participantCache.invalidateChannel(channelID)
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
|
@ -318,7 +321,31 @@ func (s *Service) EditAdmin(ctx context.Context, userID int64, req domain.EditCh
|
|||
if req.UserID != userID || req.ChannelID == 0 || req.MemberID == 0 || len(req.Rank) > domain.MaxChannelAdminRankLength {
|
||||
return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.EditChannelAdmin(ctx, req)
|
||||
res, err := s.channels.EditChannelAdmin(ctx, req)
|
||||
if err == nil {
|
||||
s.invalidateActiveChannelIDs(req.MemberID)
|
||||
s.participantCache.invalidateChannel(req.ChannelID)
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
// TransferOwnership transfers a channel/supergroup to another active member.
|
||||
func (s *Service) TransferOwnership(ctx context.Context, userID int64, req domain.TransferChannelOwnershipRequest) (domain.TransferChannelOwnershipResult, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 {
|
||||
return domain.TransferChannelOwnershipResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if req.UserID == 0 {
|
||||
req.UserID = userID
|
||||
}
|
||||
if req.UserID != userID || req.ChannelID == 0 || req.NewOwnerID == 0 || req.NewOwnerID == userID {
|
||||
return domain.TransferChannelOwnershipResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
res, err := s.channels.TransferChannelOwnership(ctx, req)
|
||||
if err == nil {
|
||||
s.invalidateActiveChannelIDs(req.UserID, req.NewOwnerID)
|
||||
s.participantCache.invalidateChannel(req.ChannelID)
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
// EditMemberRank sets or clears a participant's member tag without touching
|
||||
|
|
@ -333,7 +360,11 @@ func (s *Service) EditMemberRank(ctx context.Context, userID int64, req domain.E
|
|||
if req.UserID != userID || req.ChannelID == 0 || req.MemberID == 0 || len(req.Rank) > domain.MaxChannelAdminRankLength {
|
||||
return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.EditChannelMemberRank(ctx, req)
|
||||
res, err := s.channels.EditChannelMemberRank(ctx, req)
|
||||
if err == nil {
|
||||
s.participantCache.invalidateChannel(req.ChannelID)
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
// EditBanned edits a participant's banned rights.
|
||||
|
|
@ -350,6 +381,7 @@ func (s *Service) EditBanned(ctx context.Context, userID int64, req domain.EditC
|
|||
res, err := s.channels.EditChannelBanned(ctx, req)
|
||||
if err == nil {
|
||||
s.invalidateActiveChannelIDs(req.Participant.ID)
|
||||
s.participantCache.invalidateChannel(req.ChannelID)
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -568,6 +568,106 @@ func TestGetParticipantsCachesPageByCompositeReadModelHash(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetParticipantsCacheInvalidatesAfterAdminMutation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
base := &countingChannelStore{ChannelStore: memory.NewChannelStore()}
|
||||
service := NewService(base)
|
||||
created, err := service.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{
|
||||
Title: "Admin Cache",
|
||||
Megagroup: true,
|
||||
MemberUserIDs: []int64{1002},
|
||||
Date: 1700004103,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel: %v", err)
|
||||
}
|
||||
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID}
|
||||
versions := &fakeReadModelVersions{hashes: map[store.ReadModelKey]int64{
|
||||
{Model: readmodel.ModelChannelBase, OwnerUserID: 0, PeerType: peer.Type, PeerID: peer.ID}: 201,
|
||||
{Model: readmodel.ModelChannelParticipants, OwnerUserID: 0, PeerType: peer.Type, PeerID: peer.ID}: 202,
|
||||
{Model: readmodel.ModelChannelMember, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}: 203,
|
||||
{Model: readmodel.ModelContactAccount, OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: ownerID}: 204,
|
||||
}}
|
||||
service = NewService(base, WithReadModelVersions(versions))
|
||||
|
||||
filter := domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsAdmins}
|
||||
before, err := service.GetParticipants(ctx, ownerID, created.Channel.ID, filter, 0, 20)
|
||||
if err != nil {
|
||||
t.Fatalf("first admins: %v", err)
|
||||
}
|
||||
if len(before.Participants) != 1 || before.Participants[0].UserID != ownerID {
|
||||
t.Fatalf("first admins = %+v, want only creator", before.Participants)
|
||||
}
|
||||
if _, err := service.GetParticipants(ctx, ownerID, created.Channel.ID, filter, 0, 20); err != nil {
|
||||
t.Fatalf("cached admins: %v", err)
|
||||
}
|
||||
if base.getParticipantCalls != 1 {
|
||||
t.Fatalf("GetParticipants calls before mutation = %d, want 1", base.getParticipantCalls)
|
||||
}
|
||||
|
||||
if _, err := service.EditAdmin(ctx, ownerID, domain.EditChannelAdminRequest{
|
||||
ChannelID: created.Channel.ID,
|
||||
MemberID: 1002,
|
||||
AdminRights: domain.ChannelAdminRights{InviteUsers: true},
|
||||
Date: 1700004104,
|
||||
}); err != nil {
|
||||
t.Fatalf("EditAdmin: %v", err)
|
||||
}
|
||||
after, err := service.GetParticipants(ctx, ownerID, created.Channel.ID, filter, 0, 20)
|
||||
if err != nil {
|
||||
t.Fatalf("admins after mutation: %v", err)
|
||||
}
|
||||
if base.getParticipantCalls != 2 {
|
||||
t.Fatalf("GetParticipants calls after mutation = %d, want 2", base.getParticipantCalls)
|
||||
}
|
||||
if len(after.Participants) != 2 || after.Participants[1].UserID != 1002 || after.Participants[1].Role != domain.ChannelRoleAdmin {
|
||||
t.Fatalf("admins after mutation = %+v, want fresh promoted admin", after.Participants)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullMegagroupAdminGrantFillsManageRanks(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := NewService(memory.NewChannelStore())
|
||||
created, err := service.CreateChannel(ctx, 1001, domain.CreateChannelRequest{
|
||||
Title: "Full Admin",
|
||||
Megagroup: true,
|
||||
MemberUserIDs: []int64{1002},
|
||||
Date: 1700004200,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel: %v", err)
|
||||
}
|
||||
rights := domain.ChannelAdminRights{
|
||||
ChangeInfo: true,
|
||||
DeleteMessages: true,
|
||||
BanUsers: true,
|
||||
InviteUsers: true,
|
||||
PinMessages: true,
|
||||
AddAdmins: true,
|
||||
ManageCall: true,
|
||||
}
|
||||
edited, err := service.EditAdmin(ctx, 1001, domain.EditChannelAdminRequest{
|
||||
ChannelID: created.Channel.ID,
|
||||
MemberID: 1002,
|
||||
AdminRights: rights,
|
||||
Date: 1700004201,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EditAdmin full rights: %v", err)
|
||||
}
|
||||
if !edited.Participant.AdminRights.ManageRanks {
|
||||
t.Fatalf("edited admin rights = %+v, want ManageRanks for full megagroup admin", edited.Participant.AdminRights)
|
||||
}
|
||||
member, err := service.GetParticipant(ctx, 1001, created.Channel.ID, 1002)
|
||||
if err != nil {
|
||||
t.Fatalf("GetParticipant: %v", err)
|
||||
}
|
||||
if !member.AdminRights.ManageRanks {
|
||||
t.Fatalf("stored admin rights = %+v, want ManageRanks", member.AdminRights)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateChatCreatesMegagroupWithChannelPts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := memory.NewChannelStore()
|
||||
|
|
@ -1647,6 +1747,60 @@ func TestDeleteParticipantHistoryDeletesOneBoundedSenderPage(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestTransferOwnershipDoesNotAdvanceChannelPts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := NewService(memory.NewChannelStore())
|
||||
created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{
|
||||
Title: "Transfer",
|
||||
MemberUserIDs: []int64{1002},
|
||||
Date: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMegagroupFromCreateChat: %v", err)
|
||||
}
|
||||
ptsBeforeTransfer := created.Channel.Pts
|
||||
transfer, err := service.TransferOwnership(ctx, 1001, domain.TransferChannelOwnershipRequest{
|
||||
ChannelID: created.Channel.ID,
|
||||
NewOwnerID: 1002,
|
||||
Date: 11,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("TransferOwnership: %v", err)
|
||||
}
|
||||
if transfer.Channel.CreatorUserID != 1002 || transfer.NewOwner.Role != domain.ChannelRoleCreator || transfer.OldOwner.Role != domain.ChannelRoleAdmin {
|
||||
t.Fatalf("transfer result = %+v, want owner moved to 1002 and old owner admin", transfer)
|
||||
}
|
||||
if transfer.Channel.Pts != ptsBeforeTransfer {
|
||||
t.Fatalf("transfer channel pts = %d, want unchanged %d", transfer.Channel.Pts, ptsBeforeTransfer)
|
||||
}
|
||||
if len(transfer.Events) != 2 {
|
||||
t.Fatalf("transfer events = %+v, want two participant transitions", transfer.Events)
|
||||
}
|
||||
for _, event := range transfer.Events {
|
||||
if event.Type != domain.ChannelUpdateParticipant || event.Pts != 0 || event.PtsCount != 0 {
|
||||
t.Fatalf("transfer event = %+v, want transient participant event", event)
|
||||
}
|
||||
}
|
||||
diffAfterTransfer, err := service.GetDifference(ctx, 1002, domain.ChannelDifferenceRequest{ChannelID: created.Channel.ID, Pts: ptsBeforeTransfer, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDifference after transfer: %v", err)
|
||||
}
|
||||
if len(diffAfterTransfer.OtherUpdates) != 0 || diffAfterTransfer.Pts != ptsBeforeTransfer {
|
||||
t.Fatalf("diff after transfer = %+v, want no durable participant update", diffAfterTransfer)
|
||||
}
|
||||
oldOwner, err := service.GetParticipant(ctx, 1002, created.Channel.ID, 1001)
|
||||
if err != nil {
|
||||
t.Fatalf("GetParticipant old owner: %v", err)
|
||||
}
|
||||
newOwner, err := service.GetParticipant(ctx, 1002, created.Channel.ID, 1002)
|
||||
if err != nil {
|
||||
t.Fatalf("GetParticipant new owner: %v", err)
|
||||
}
|
||||
if oldOwner.Role != domain.ChannelRoleAdmin || newOwner.Role != domain.ChannelRoleCreator {
|
||||
t.Fatalf("participants after transfer old=%+v new=%+v, want admin/creator", oldOwner, newOwner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelAdminTitlePinAndInvite(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := NewService(memory.NewChannelStore())
|
||||
|
|
@ -2292,8 +2446,8 @@ func TestPublicChannelSearchAndResolveUsername(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("SearchPublicChannels joined: %v", err)
|
||||
}
|
||||
if len(joined.MyResults) != 1 || joined.MyResults[0].ID != public.ID || len(joined.Results) != 0 {
|
||||
t.Fatalf("joined public search = %+v, want my public channel only", joined)
|
||||
if len(joined.MyResults) != 0 || len(joined.Results) != 0 {
|
||||
t.Fatalf("joined public search = %+v, want no discovery result for active member", joined)
|
||||
}
|
||||
global, err := service.SearchPublicChannels(ctx, 1003, "public", 10)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ package groupcalls
|
|||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
|
||||
|
|
@ -44,10 +45,55 @@ func (s *Service) Create(ctx context.Context, channelID, creatorUserID int64, ti
|
|||
})
|
||||
}
|
||||
|
||||
// CreateConference 分配 id/access_hash/slug 并创建 ad-hoc conference call。
|
||||
func (s *Service) CreateConference(ctx context.Context, creatorUserID, randomID, migratedFromPhoneCallID int64, now int) (domain.GroupCall, error) {
|
||||
for i := 0; i < 8; i++ {
|
||||
id, err := randomPositiveInt64()
|
||||
if err != nil {
|
||||
return domain.GroupCall{}, err
|
||||
}
|
||||
accessHash, err := randomPositiveInt64()
|
||||
if err != nil {
|
||||
return domain.GroupCall{}, err
|
||||
}
|
||||
slug, err := randomSlug()
|
||||
if err != nil {
|
||||
return domain.GroupCall{}, err
|
||||
}
|
||||
call, err := s.store.CreateConferenceCall(ctx, domain.GroupCall{
|
||||
ID: id,
|
||||
AccessHash: accessHash,
|
||||
CreatorUserID: creatorUserID,
|
||||
Kind: domain.GroupCallKindConference,
|
||||
Version: 1,
|
||||
CreatedAt: now,
|
||||
InviteSlug: slug,
|
||||
InviteLink: conferenceInviteLink(slug),
|
||||
RandomID: randomID,
|
||||
MigratedFromPhoneCallID: migratedFromPhoneCallID,
|
||||
})
|
||||
if err == nil {
|
||||
return call, nil
|
||||
}
|
||||
if err != domain.ErrGroupCallInvalid {
|
||||
return domain.GroupCall{}, err
|
||||
}
|
||||
}
|
||||
return domain.GroupCall{}, fmt.Errorf("groupcalls: exhausted conference slug attempts")
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, callID int64) (domain.GroupCall, bool, error) {
|
||||
return s.store.GetGroupCall(ctx, callID)
|
||||
}
|
||||
|
||||
func (s *Service) GetBySlug(ctx context.Context, slug string) (domain.GroupCall, bool, error) {
|
||||
return s.store.GetGroupCallBySlug(ctx, slug)
|
||||
}
|
||||
|
||||
func (s *Service) GetByInviteMessage(ctx context.Context, userID int64, msgID int) (domain.GroupCall, domain.GroupCallInvite, bool, error) {
|
||||
return s.store.GetGroupCallByInviteMessage(ctx, userID, msgID)
|
||||
}
|
||||
|
||||
func (s *Service) Join(ctx context.Context, req domain.JoinGroupCallRequest) (domain.GroupCallMutation, error) {
|
||||
return s.store.JoinGroupCall(ctx, req)
|
||||
}
|
||||
|
|
@ -56,6 +102,10 @@ func (s *Service) Leave(ctx context.Context, callID, userID int64, now int) (dom
|
|||
return s.store.LeaveGroupCall(ctx, callID, userID, now)
|
||||
}
|
||||
|
||||
func (s *Service) RemoveConferenceParticipants(ctx context.Context, req domain.RemoveConferenceCallParticipantsRequest) (domain.RemoveConferenceCallParticipantsResult, error) {
|
||||
return s.store.RemoveConferenceCallParticipants(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) Discard(ctx context.Context, callID int64, now int) (domain.GroupCall, []domain.GroupCallParticipant, error) {
|
||||
return s.store.DiscardGroupCall(ctx, callID, now)
|
||||
}
|
||||
|
|
@ -108,6 +158,26 @@ func (s *Service) ParticipantOverride(ctx context.Context, callID, setterUserID,
|
|||
return s.store.GetParticipantOverride(ctx, callID, setterUserID, targetUserID)
|
||||
}
|
||||
|
||||
func (s *Service) CreateConferenceInvite(ctx context.Context, invite domain.GroupCallInvite) (domain.GroupCallInvite, error) {
|
||||
return s.store.CreateConferenceInvite(ctx, invite)
|
||||
}
|
||||
|
||||
func (s *Service) SetConferenceInviteStatus(ctx context.Context, callID, inviteeUserID int64, msgID int, status domain.GroupCallInviteStatus, now int) (domain.GroupCallInvite, bool, error) {
|
||||
return s.store.SetConferenceInviteStatus(ctx, callID, inviteeUserID, msgID, status, now)
|
||||
}
|
||||
|
||||
func (s *Service) ConferenceRecipients(ctx context.Context, callID int64) ([]int64, error) {
|
||||
return s.store.ListConferenceRecipientUserIDs(ctx, callID)
|
||||
}
|
||||
|
||||
func (s *Service) AppendChainBlock(ctx context.Context, block domain.GroupCallChainBlock) (domain.GroupCallChainBlock, error) {
|
||||
return s.store.AppendGroupCallChainBlock(ctx, block)
|
||||
}
|
||||
|
||||
func (s *Service) ChainBlocks(ctx context.Context, callID int64, subChainID, offset, limit int) (domain.GroupCallChainBlockPage, error) {
|
||||
return s.store.ListGroupCallChainBlocks(ctx, callID, subChainID, offset, limit)
|
||||
}
|
||||
|
||||
func randomPositiveInt64() (int64, error) {
|
||||
var buf [8]byte
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
|
|
@ -119,3 +189,15 @@ func randomPositiveInt64() (int64, error) {
|
|||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func randomSlug() (string, error) {
|
||||
var buf [18]byte
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
return "", fmt.Errorf("groupcalls: random slug: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf[:]), nil
|
||||
}
|
||||
|
||||
func conferenceInviteLink(slug string) string {
|
||||
return "https://telesrv.net/call/" + slug + "?slug=" + slug
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,11 +83,16 @@ func (r *registry) decActiveLocked(userID int64) {
|
|||
|
||||
// markDiscardedLocked 把非终态 entry 迁入终态并更新并发计数。
|
||||
func (r *registry) markDiscardedLocked(e *entry, reason domain.PhoneCallDiscardReason, duration, nowUnix int) {
|
||||
r.markDiscardedWithSlugLocked(e, reason, "", duration, nowUnix)
|
||||
}
|
||||
|
||||
func (r *registry) markDiscardedWithSlugLocked(e *entry, reason domain.PhoneCallDiscardReason, reasonSlug string, duration, nowUnix int) {
|
||||
if e.call.Terminal() {
|
||||
return
|
||||
}
|
||||
e.call.State = domain.PhoneCallStateDiscarded
|
||||
e.call.DiscardReason = reason
|
||||
e.call.DiscardReasonSlug = reasonSlug
|
||||
e.call.Duration = duration
|
||||
e.call.DiscardedAt = nowUnix
|
||||
r.decActiveLocked(e.call.AdminID)
|
||||
|
|
|
|||
|
|
@ -252,6 +252,10 @@ func (s *Service) ConfirmCall(ctx context.Context, userID, callID, accessHash in
|
|||
// DiscardCall 挂断:任意非终态可达,幂等。already=true 表示通话此前已是终态
|
||||
// (双方同时挂断:先到者定 reason,后到者拿快照)。
|
||||
func (s *Service) DiscardCall(ctx context.Context, userID, callID, accessHash int64, reason domain.PhoneCallDiscardReason, duration int) (domain.PhoneCall, bool, error) {
|
||||
return s.DiscardCallWithSlug(ctx, userID, callID, accessHash, reason, "", duration)
|
||||
}
|
||||
|
||||
func (s *Service) DiscardCallWithSlug(ctx context.Context, userID, callID, accessHash int64, reason domain.PhoneCallDiscardReason, reasonSlug string, duration int) (domain.PhoneCall, bool, error) {
|
||||
s.reg.mu.Lock()
|
||||
defer s.reg.mu.Unlock()
|
||||
e, err := s.lookupLocked(callID, accessHash)
|
||||
|
|
@ -267,11 +271,14 @@ func (s *Service) DiscardCall(ctx context.Context, userID, callID, accessHash in
|
|||
if reason == "" {
|
||||
reason = domain.PhoneCallDiscardReasonHangup
|
||||
}
|
||||
if reason != domain.PhoneCallDiscardReasonMigrateConference {
|
||||
reasonSlug = ""
|
||||
}
|
||||
// duration 只在通话真正建立(Confirmed)后才认,防止客户端把振铃时长报成通话时长。
|
||||
if e.call.StartDate == 0 || duration < 0 {
|
||||
duration = 0
|
||||
}
|
||||
s.reg.markDiscardedLocked(e, reason, duration, int(s.clk.Now().Unix()))
|
||||
s.reg.markDiscardedWithSlugLocked(e, reason, reasonSlug, duration, int(s.clk.Now().Unix()))
|
||||
return e.call, false, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,9 +10,15 @@ import (
|
|||
|
||||
// Canonical ids used to synthesize converted/defaulted values.
|
||||
const (
|
||||
inputUserID = 0xf21158c6 // inputUser user_id:long access_hash:long
|
||||
inputMessageID = 0xa676a322 // inputMessageID id:int
|
||||
boolFalseID = 0xbc799737 // boolFalse
|
||||
inputUserID = 0xf21158c6 // inputUser user_id:long access_hash:long
|
||||
inputMessageID = 0xa676a322 // inputMessageID id:int
|
||||
inputChannelEmptyID = 0xee8c1e86 // inputChannelEmpty
|
||||
inputChannelID = 0xf35aec28 // inputChannel channel_id:long access_hash:long
|
||||
inputChannelFromMessageID = 0x5b934f9d // inputChannelFromMessage peer:InputPeer msg_id:int channel_id:long
|
||||
inputPeerEmptyID = 0x7f3b18ea // inputPeerEmpty
|
||||
inputPeerChannelID = 0x27bcbbfc // inputPeerChannel channel_id:long access_hash:long
|
||||
inputPeerChannelFromMessageID = 0xbd2a0840 // inputPeerChannelFromMessage peer:InputPeer msg_id:int channel_id:long
|
||||
boolFalseID = 0xbc799737 // boolFalse
|
||||
)
|
||||
|
||||
//go:embed schema/client-drift.tl
|
||||
|
|
@ -34,7 +40,8 @@ func mustLoadDrift() *schemaModel {
|
|||
// Pure schema diff cannot recover a rename, so it is declared here (data, not a
|
||||
// transform). It is the only thing a structural rename needs.
|
||||
var driftFieldRenames = map[string]string{
|
||||
"bots.exportBotToken\x00bot": "bot_id",
|
||||
"bots.exportBotToken\x00bot": "bot_id",
|
||||
"messages.editChatCreator\x00peer": "channel",
|
||||
}
|
||||
|
||||
// fieldConverter rewrites one field whose wire type changed between the old and
|
||||
|
|
@ -73,6 +80,28 @@ var fieldConverters = map[string]fieldConverter{
|
|||
out.PutLong(0)
|
||||
return nil
|
||||
},
|
||||
// channel:InputChannel -> peer:InputPeer for the old channels.editCreator
|
||||
// Android constructor. Concrete layouts are otherwise byte-compatible.
|
||||
"InputChannel->InputPeer": func(raw []byte, out *bin.Buffer) error {
|
||||
in := &bin.Buffer{Buf: raw}
|
||||
id, err := in.ID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch id {
|
||||
case inputChannelEmptyID:
|
||||
out.PutID(inputPeerEmptyID)
|
||||
case inputChannelID:
|
||||
out.PutID(inputPeerChannelID)
|
||||
out.Put(in.Buf)
|
||||
case inputChannelFromMessageID:
|
||||
out.PutID(inputPeerChannelFromMessageID)
|
||||
out.Put(in.Buf)
|
||||
default:
|
||||
return bin.NewUnexpectedID(id)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// UpgradeInbound converts an old client's inbound request to canonical (227)
|
||||
|
|
|
|||
|
|
@ -147,6 +147,34 @@ func TestInboundBodyTransforms(t *testing.T) {
|
|||
}
|
||||
validateMethodRequest(t, out, 0x42c6978f, "langpackGetLanguages")
|
||||
})
|
||||
|
||||
t.Run("channelsEditCreatorToMessagesEditChatCreator", func(t *testing.T) {
|
||||
var in bin.Buffer
|
||||
in.PutID(0x8f38cd1f)
|
||||
_ = (&tg.InputChannel{ChannelID: 132, AccessHash: 8956724956393200600}).Encode(&in)
|
||||
_ = (&tg.InputUser{UserID: 1780243211, AccessHash: 42}).Encode(&in)
|
||||
_ = (&tg.InputCheckPasswordEmpty{}).Encode(&in)
|
||||
out, ok, err := UpgradeInbound(0x8f38cd1f, &in)
|
||||
if !ok || err != nil {
|
||||
t.Fatalf("upgrade: ok=%v err=%v", ok, err)
|
||||
}
|
||||
validateMethodRequest(t, out, 0xf743b857, "messagesEditChatCreator")
|
||||
var req tg.MessagesEditChatCreatorRequest
|
||||
if err := req.Decode(&bin.Buffer{Buf: append([]byte(nil), out.Buf...)}); err != nil {
|
||||
t.Fatalf("decode upgraded editChatCreator: %v", err)
|
||||
}
|
||||
peer, ok := req.Peer.(*tg.InputPeerChannel)
|
||||
if !ok || peer.ChannelID != 132 || peer.AccessHash != 8956724956393200600 {
|
||||
t.Fatalf("upgraded peer = %T %+v, want inputPeerChannel", req.Peer, req.Peer)
|
||||
}
|
||||
user, ok := req.UserID.(*tg.InputUser)
|
||||
if !ok || user.UserID != 1780243211 || user.AccessHash != 42 {
|
||||
t.Fatalf("upgraded user = %T %+v, want inputUser", req.UserID, req.UserID)
|
||||
}
|
||||
if _, ok := req.Password.(*tg.InputCheckPasswordEmpty); !ok {
|
||||
t.Fatalf("upgraded password = %T, want inputCheckPasswordEmpty", req.Password)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestInboundCRCSwaps covers the body-compatible client-drift methods that only
|
||||
|
|
|
|||
|
|
@ -26,3 +26,7 @@ contacts.search#11f812d8 q:string limit:int = contacts.Found;
|
|||
langpack.getLangPack#9ab5c58e lang_code:string = LangPackDifference;
|
||||
langpack.getStrings#2e1ee318 lang_code:string keys:Vector<string> = Vector<LangPackString>;
|
||||
langpack.getLanguages#800fd57d = Vector<LangPackLanguage>;
|
||||
// DrKLO still emits old channels.editCreator#8f38cd1f; canonical 227 replaced
|
||||
// that flow with messages.editChatCreator(peer:InputPeer,...). Keep the old
|
||||
// constructor id here but target the canonical method name for generic upgrade.
|
||||
messages.editChatCreator#8f38cd1f channel:InputChannel user_id:InputUser password:InputCheckPasswordSRP = Updates;
|
||||
|
|
|
|||
|
|
@ -204,6 +204,40 @@ type ChannelAdminRights struct {
|
|||
ManageDirectMessages bool
|
||||
}
|
||||
|
||||
// CreatorChannelAdminRights returns the full rights set clients expect on creator projections.
|
||||
func CreatorChannelAdminRights() ChannelAdminRights {
|
||||
return ChannelAdminRights{
|
||||
ChangeInfo: true,
|
||||
PostMessages: true,
|
||||
EditMessages: true,
|
||||
DeleteMessages: true,
|
||||
PostStories: true,
|
||||
EditStories: true,
|
||||
DeleteStories: true,
|
||||
BanUsers: true,
|
||||
InviteUsers: true,
|
||||
PinMessages: true,
|
||||
AddAdmins: true,
|
||||
ManageCall: true,
|
||||
ManageRanks: true,
|
||||
}
|
||||
}
|
||||
|
||||
// NormalizeFullMegagroupAdminRights fills implicit full-admin bits for megagroups.
|
||||
func NormalizeFullMegagroupAdminRights(ch Channel, rights ChannelAdminRights) ChannelAdminRights {
|
||||
if ch.Megagroup && !ch.Broadcast &&
|
||||
rights.ChangeInfo &&
|
||||
rights.DeleteMessages &&
|
||||
rights.BanUsers &&
|
||||
rights.InviteUsers &&
|
||||
rights.PinMessages &&
|
||||
rights.AddAdmins &&
|
||||
rights.ManageCall {
|
||||
rights.ManageRanks = true
|
||||
}
|
||||
return rights
|
||||
}
|
||||
|
||||
// ChannelBannedRights is a domain-only representation of Telegram banned rights.
|
||||
type ChannelBannedRights struct {
|
||||
ViewMessages bool
|
||||
|
|
@ -1019,6 +1053,9 @@ type ChannelRecommendationsResult struct {
|
|||
}
|
||||
|
||||
// PublicChannelSearchResult contains contacts.search public channel/supergroup matches.
|
||||
// Results are public peers the viewer is not an active member of; joined peers
|
||||
// are intentionally left to dialogs/resolve paths so clients do not render
|
||||
// invisible discovery rows for already joined channels.
|
||||
type PublicChannelSearchResult struct {
|
||||
MyResults []Channel
|
||||
Results []Channel
|
||||
|
|
@ -1203,6 +1240,26 @@ type EditChannelAdminResult struct {
|
|||
Date int
|
||||
}
|
||||
|
||||
// TransferChannelOwnershipRequest transfers a channel/supergroup to another active member.
|
||||
type TransferChannelOwnershipRequest struct {
|
||||
UserID int64
|
||||
ChannelID int64
|
||||
NewOwnerID int64
|
||||
Date int
|
||||
}
|
||||
|
||||
// TransferChannelOwnershipResult describes both participant transitions produced by an owner transfer.
|
||||
type TransferChannelOwnershipResult struct {
|
||||
Channel Channel
|
||||
PreviousOwner ChannelMember
|
||||
OldOwner ChannelMember
|
||||
PreviousNewOwner ChannelMember
|
||||
NewOwner ChannelMember
|
||||
Events []ChannelUpdateEvent
|
||||
Recipients []int64
|
||||
Date int
|
||||
}
|
||||
|
||||
// EditChannelMemberRankRequest sets or clears a participant's member tag (rank)
|
||||
// without touching their role or admin rights.
|
||||
type EditChannelMemberRankRequest struct {
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ type ImportContactsResult struct {
|
|||
|
||||
// UserSearchResult 是 contacts.search 的业务结果。
|
||||
// MyResults 放当前账号通讯录内命中的用户;Results 放其他全局命中用户。
|
||||
// MyChannelResults 放当前账号已加入的公开 channel/supergroup;ChannelResults 放其他公开命中。
|
||||
// ChannelResults 放未加入的公开 channel/supergroup 命中;已加入频道由 dialogs/resolve 路径呈现。
|
||||
type UserSearchResult struct {
|
||||
MyResults []User
|
||||
Results []User
|
||||
|
|
|
|||
|
|
@ -14,6 +14,27 @@ const (
|
|||
GroupCallStateDiscarded GroupCallState = "discarded"
|
||||
)
|
||||
|
||||
// GroupCallKind distinguishes regular channel group calls from ad-hoc
|
||||
// conference calls. Conference calls have no channel membership scope; access is
|
||||
// granted by creator/participant/invite/slug.
|
||||
type GroupCallKind string
|
||||
|
||||
const (
|
||||
GroupCallKindChannel GroupCallKind = "channel"
|
||||
GroupCallKindConference GroupCallKind = "conference"
|
||||
)
|
||||
|
||||
// GroupCallInviteStatus is the durable state of a private conference invite.
|
||||
type GroupCallInviteStatus string
|
||||
|
||||
const (
|
||||
GroupCallInvitePending GroupCallInviteStatus = "pending"
|
||||
GroupCallInviteAccepted GroupCallInviteStatus = "accepted"
|
||||
GroupCallInviteDeclined GroupCallInviteStatus = "declined"
|
||||
GroupCallInviteMissed GroupCallInviteStatus = "missed"
|
||||
GroupCallInviteRevoked GroupCallInviteStatus = "revoked"
|
||||
)
|
||||
|
||||
// 群通话业务错误;rpc 层映射为 GROUPCALL_* RPC_ERROR。
|
||||
var (
|
||||
ErrGroupCallInvalid = errors.New("group call invalid")
|
||||
|
|
@ -21,6 +42,7 @@ var (
|
|||
ErrGroupCallAlreadyStarted = errors.New("group call already started")
|
||||
ErrGroupCallSSRCDuplicate = errors.New("group call ssrc duplicate")
|
||||
ErrGroupCallNotJoined = errors.New("group call participant missing")
|
||||
ErrConferenceChainInvalid = errors.New("conference call chain invalid")
|
||||
)
|
||||
|
||||
// GroupCall 是一场群通话的权威态。
|
||||
|
|
@ -29,6 +51,7 @@ type GroupCall struct {
|
|||
AccessHash int64
|
||||
ChannelID int64
|
||||
CreatorUserID int64
|
||||
Kind GroupCallKind
|
||||
State GroupCallState
|
||||
Title string
|
||||
JoinMuted bool
|
||||
|
|
@ -41,6 +64,14 @@ type GroupCall struct {
|
|||
// StartedMsgID 是 messageActionGroupCall(started) 的频道消息 id(discard 时
|
||||
// 客户端用它定位起始服务消息,当前仅记录)。
|
||||
StartedMsgID int
|
||||
// InviteSlug/InviteLink 仅 conference 使用。slug 必须在 migrate reason 与
|
||||
// InputGroupCallSlug 中稳定可解析;link 是客户端 UI 复制/分享用完整 URL。
|
||||
InviteSlug string
|
||||
InviteLink string
|
||||
// RandomID 是 conference create 的幂等键(creator_user_id + random_id)。
|
||||
RandomID int64
|
||||
// MigratedFromPhoneCallID 记录由哪通 P2P call 升级而来;link-only create 为 0。
|
||||
MigratedFromPhoneCallID int64
|
||||
}
|
||||
|
||||
// Active 报告通话是否仍在进行。
|
||||
|
|
@ -48,6 +79,10 @@ func (c GroupCall) Active() bool {
|
|||
return c.State == GroupCallStateActive
|
||||
}
|
||||
|
||||
func (c GroupCall) Conference() bool {
|
||||
return c.Kind == GroupCallKindConference
|
||||
}
|
||||
|
||||
// GroupCallParticipant 是房间内一名参与者。
|
||||
type GroupCallParticipant struct {
|
||||
CallID int64
|
||||
|
|
@ -67,7 +102,11 @@ type GroupCallParticipant struct {
|
|||
// 快照(M3/M4 启用;M0/M1 仅透明保存 self-edit,不转发)。
|
||||
VideoJSON []byte
|
||||
PresentationJSON []byte
|
||||
Left bool
|
||||
// PublicKey/JoinBlock 仅 conference 使用。服务端不解析 E2E 内容,只持久化
|
||||
// opaque bytes 并供 chain block 补拉/转发。
|
||||
PublicKey []byte
|
||||
JoinBlock []byte
|
||||
Left bool
|
||||
// LastCheckDate 是 checkGroupCall 保活水位。注意:客户端只在 Connecting 态
|
||||
// 发 checkGroupCall(媒体连通后心跳停止),掉线判定必须与 SFU 媒体面活性
|
||||
// 取双过期(见 sweeper),绝不能单凭此字段。
|
||||
|
|
@ -85,11 +124,13 @@ type CreateGroupCallRequest struct {
|
|||
|
||||
// JoinGroupCallRequest 加入/重进群通话(rejoin 同主键换新 ssrc)。
|
||||
type JoinGroupCallRequest struct {
|
||||
CallID int64
|
||||
UserID int64
|
||||
SSRC int64
|
||||
Muted bool
|
||||
IsAdmin bool
|
||||
CallID int64
|
||||
UserID int64
|
||||
SSRC int64
|
||||
Muted bool
|
||||
IsAdmin bool
|
||||
PublicKey []byte
|
||||
JoinBlock []byte
|
||||
// VideoJSON 是本次 join 铸造的视频内部状态(endpoint+源组+active);rejoin
|
||||
// 整体替换并**清空旧 PresentationJSON**(客户端主连接 rejoin 后会重发
|
||||
// joinGroupCallPresentation,旧屏幕登记必须作废)。
|
||||
|
|
@ -104,6 +145,27 @@ type GroupCallMutation struct {
|
|||
Participant GroupCallParticipant
|
||||
}
|
||||
|
||||
// RemoveConferenceCallParticipantsRequest describes a conference participant
|
||||
// removal from both the media participant set and the E2E member chain.
|
||||
type RemoveConferenceCallParticipantsRequest struct {
|
||||
CallID int64
|
||||
AuthorUserID int64
|
||||
TargetUserIDs []int64
|
||||
OnlyLeft bool
|
||||
Kick bool
|
||||
Block []byte
|
||||
Now int
|
||||
}
|
||||
|
||||
// RemoveConferenceCallParticipantsResult is the transactional result of
|
||||
// accepting a conference E2E removal block and/or kicking active participants.
|
||||
type RemoveConferenceCallParticipantsResult struct {
|
||||
Call GroupCall
|
||||
ParticipantsChanged []GroupCallParticipant
|
||||
ChainBlock GroupCallChainBlock
|
||||
ChainBlockAppended bool
|
||||
}
|
||||
|
||||
// GroupCallParticipantUpdate 是 editGroupCallParticipant 的字段级更新(nil=不动)。
|
||||
type GroupCallParticipantUpdate struct {
|
||||
Muted *bool
|
||||
|
|
@ -129,3 +191,33 @@ type GroupCallParticipantPage struct {
|
|||
NextOffset string
|
||||
Version int
|
||||
}
|
||||
|
||||
// GroupCallInvite 是 conference call 在私聊中发出的邀请服务消息索引。
|
||||
type GroupCallInvite struct {
|
||||
CallID int64
|
||||
InviterUserID int64
|
||||
InviteeUserID int64
|
||||
MessageID int
|
||||
Status GroupCallInviteStatus
|
||||
Video bool
|
||||
CreatedAt int
|
||||
UpdatedAt int
|
||||
}
|
||||
|
||||
// GroupCallChainBlockLatestOffset 是客户端用来请求当前 sub-chain 最新 block 的哨兵值。
|
||||
const GroupCallChainBlockLatestOffset = -1
|
||||
|
||||
// GroupCallChainBlock 是 conference E2E chain 的 opaque block。
|
||||
type GroupCallChainBlock struct {
|
||||
CallID int64
|
||||
SubChainID int
|
||||
Offset int
|
||||
AuthorUserID int64
|
||||
Block []byte
|
||||
CreatedAt int
|
||||
}
|
||||
|
||||
type GroupCallChainBlockPage struct {
|
||||
Blocks []GroupCallChainBlock
|
||||
NextOffset int
|
||||
}
|
||||
|
|
|
|||
|
|
@ -398,6 +398,9 @@ const (
|
|||
// MessageServiceActionPhoneCall 映射 messageActionPhoneCall:私聊通话
|
||||
// 结束(含 missed 超时)后落历史的通话条目,sender 恒为主叫。
|
||||
MessageServiceActionPhoneCall MessageServiceActionKind = "phone_call"
|
||||
// MessageServiceActionConferenceCall 映射 messageActionConferenceCall:
|
||||
// ad-hoc conference call 的私聊邀请/状态服务消息。
|
||||
MessageServiceActionConferenceCall MessageServiceActionKind = "conference_call"
|
||||
// MessageServiceActionBotAllowed 映射 messageActionBotAllowed:用户授权
|
||||
// bot 后在 bot 私聊中留下的服务消息。
|
||||
MessageServiceActionBotAllowed MessageServiceActionKind = "bot_allowed"
|
||||
|
|
@ -424,6 +427,16 @@ type MessagePhoneCallAction struct {
|
|||
Video bool `json:"video,omitempty"`
|
||||
}
|
||||
|
||||
// MessageConferenceCallAction 是 messageActionConferenceCall 的协议中立载荷。
|
||||
type MessageConferenceCallAction struct {
|
||||
CallID int64 `json:"call_id"`
|
||||
Missed bool `json:"missed,omitempty"`
|
||||
Active bool `json:"active,omitempty"`
|
||||
Video bool `json:"video,omitempty"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
OtherParticipants []Peer `json:"other_participants,omitempty"`
|
||||
}
|
||||
|
||||
// MessageBotAllowedAction 是 messageActionBotAllowed 的协议中立载荷。
|
||||
type MessageBotAllowedAction struct {
|
||||
AttachMenu bool `json:"attach_menu,omitempty"`
|
||||
|
|
@ -450,14 +463,15 @@ type MessageRequestedPeerAction struct {
|
|||
|
||||
// MessageServiceAction 是私聊服务消息动作的协议中立表示。
|
||||
type MessageServiceAction struct {
|
||||
Kind MessageServiceActionKind `json:"kind"`
|
||||
Photo *Photo `json:"photo,omitempty"`
|
||||
Call *MessagePhoneCallAction `json:"call,omitempty"`
|
||||
BotAllowed *MessageBotAllowedAction `json:"bot_allowed,omitempty"`
|
||||
WebViewData *MessageWebViewDataAction `json:"web_view_data,omitempty"`
|
||||
RequestedPeer *MessageRequestedPeerAction `json:"requested_peer,omitempty"`
|
||||
ChatThemeEmoticon string `json:"chat_theme_emoticon,omitempty"`
|
||||
StarGift *MessageStarGiftAction `json:"star_gift,omitempty"`
|
||||
Kind MessageServiceActionKind `json:"kind"`
|
||||
Photo *Photo `json:"photo,omitempty"`
|
||||
Call *MessagePhoneCallAction `json:"call,omitempty"`
|
||||
ConferenceCall *MessageConferenceCallAction `json:"conference_call,omitempty"`
|
||||
BotAllowed *MessageBotAllowedAction `json:"bot_allowed,omitempty"`
|
||||
WebViewData *MessageWebViewDataAction `json:"web_view_data,omitempty"`
|
||||
RequestedPeer *MessageRequestedPeerAction `json:"requested_peer,omitempty"`
|
||||
ChatThemeEmoticon string `json:"chat_theme_emoticon,omitempty"`
|
||||
StarGift *MessageStarGiftAction `json:"star_gift,omitempty"`
|
||||
}
|
||||
|
||||
// MessageStarGiftAction 是 messageActionStarGift 的协议中立载荷:内嵌礼物快照(贴纸/星价)
|
||||
|
|
|
|||
|
|
@ -105,7 +105,10 @@ type PhoneCall struct {
|
|||
|
||||
P2PAllowed bool
|
||||
DiscardReason PhoneCallDiscardReason
|
||||
Duration int
|
||||
// DiscardReasonSlug is set for migrate_conference and must resolve through
|
||||
// phone.getGroupCall(inputGroupCallSlug) before the discarded update is sent.
|
||||
DiscardReasonSlug string
|
||||
Duration int
|
||||
|
||||
// PrivacyP2P 与 Connections 自 PhoneCallRequest 原样保留(见其注释)。
|
||||
PrivacyP2P bool
|
||||
|
|
|
|||
|
|
@ -15,6 +15,17 @@ import (
|
|||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type acceptPasswordAccountService struct {
|
||||
AccountService
|
||||
}
|
||||
|
||||
func (acceptPasswordAccountService) CheckPassword(_ context.Context, _ int64, check domain.PasswordCheck) error {
|
||||
if check.Empty {
|
||||
return domain.ErrPasswordHashInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestMessagesGetFutureChatCreatorAfterLeaveAndCreatorLeaveTransfers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
|
|
@ -98,6 +109,85 @@ func TestMessagesGetFutureChatCreatorAfterLeaveAndCreatorLeaveTransfers(t *testi
|
|||
}
|
||||
}
|
||||
|
||||
func TestMessagesEditChatCreatorTransfersWithoutChannelPts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
owner, err := userStore.Create(ctx, domain.User{AccessHash: 9121, Phone: "15550009121", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
member, err := userStore.Create(ctx, domain.User{AccessHash: 9122, Phone: "15550009122", FirstName: "Member"})
|
||||
if err != nil {
|
||||
t.Fatalf("create member: %v", err)
|
||||
}
|
||||
channelStore := memory.NewChannelStore()
|
||||
channelService := appchannels.NewService(channelStore)
|
||||
r := New(Config{}, Deps{
|
||||
Account: acceptPasswordAccountService{},
|
||||
Users: appusers.NewService(userStore),
|
||||
Channels: channelService,
|
||||
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700009130, 0)})
|
||||
created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
|
||||
CreatorUserID: owner.ID,
|
||||
Title: "explicit transfer",
|
||||
Megagroup: true,
|
||||
MemberUserIDs: []int64{member.ID},
|
||||
Date: 1700009130,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
ownerCtx := WithUserID(ctx, owner.ID)
|
||||
peer := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}
|
||||
if _, err := r.onMessagesEditChatCreator(ownerCtx, &tg.MessagesEditChatCreatorRequest{
|
||||
Peer: peer,
|
||||
UserID: &tg.InputUserEmpty{},
|
||||
Password: &tg.InputCheckPasswordEmpty{},
|
||||
}); err == nil || !tgerr.Is(err, "PASSWORD_HASH_INVALID") {
|
||||
t.Fatalf("editChatCreator probe err = %v, want PASSWORD_HASH_INVALID", err)
|
||||
}
|
||||
|
||||
updatesClass, err := r.onMessagesEditChatCreator(ownerCtx, &tg.MessagesEditChatCreatorRequest{
|
||||
Peer: peer,
|
||||
UserID: &tg.InputUser{UserID: member.ID, AccessHash: member.AccessHash},
|
||||
Password: &tg.InputCheckPasswordSRP{SRPID: 1, A: []byte{1}, M1: []byte{2}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("editChatCreator transfer: %v", err)
|
||||
}
|
||||
updates := updatesClass.(*tg.Updates)
|
||||
participantUpdates := 0
|
||||
hasChannel := false
|
||||
for _, update := range updates.Updates {
|
||||
switch update.(type) {
|
||||
case *tg.UpdateChannelParticipant:
|
||||
participantUpdates++
|
||||
case *tg.UpdateChannel:
|
||||
hasChannel = true
|
||||
}
|
||||
}
|
||||
if participantUpdates != 2 || !hasChannel {
|
||||
t.Fatalf("transfer updates = %+v, want two participant updates and updateChannel", updates.Updates)
|
||||
}
|
||||
if chat, ok := updates.Chats[0].(*tg.Channel); !ok || chat.Creator || !chat.AdminRights.AddAdmins {
|
||||
t.Fatalf("owner response chat = %T %+v, want old owner as non-creator admin", updates.Chats[0], updates.Chats[0])
|
||||
}
|
||||
view, err := channelService.GetChannel(ctx, member.ID, created.Channel.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("member get channel after transfer: %v", err)
|
||||
}
|
||||
if view.Channel.CreatorUserID != member.ID || view.Self.Role != domain.ChannelRoleCreator || view.Channel.Pts != created.Channel.Pts {
|
||||
t.Fatalf("channel after transfer = %+v self=%+v, want member creator and pts unchanged %d", view.Channel, view.Self, created.Channel.Pts)
|
||||
}
|
||||
oldOwner, err := channelService.GetParticipant(ctx, member.ID, created.Channel.ID, owner.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("old owner participant after transfer: %v", err)
|
||||
}
|
||||
if oldOwner.Role != domain.ChannelRoleAdmin {
|
||||
t.Fatalf("old owner after transfer = %+v, want admin", oldOwner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesGetFutureChatCreatorAfterLeaveNoCandidate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/gotd/td/tgerr"
|
||||
"go.uber.org/zap"
|
||||
"telesrv/internal/compat/tdesktop"
|
||||
"telesrv/internal/domain"
|
||||
|
|
@ -283,6 +282,9 @@ func (r *Router) onMessagesEditChatDefaultBannedRights(ctx context.Context, req
|
|||
}
|
||||
|
||||
func (r *Router) onMessagesEditChatCreator(ctx context.Context, req *tg.MessagesEditChatCreatorRequest) (tg.UpdatesClass, error) {
|
||||
if r.deps.Channels == nil {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
if req.UserID == nil {
|
||||
return nil, peerIDInvalidErr()
|
||||
}
|
||||
|
|
@ -290,15 +292,52 @@ func (r *Router) onMessagesEditChatCreator(ctx context.Context, req *tg.Messages
|
|||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if _, err := r.channelIDFromLegacyInputPeerChecked(ctx, userID, req.Peer); err != nil {
|
||||
channelID, err := r.channelIDFromLegacyInputPeerChecked(ctx, userID, req.Peer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, found, err := r.userFromInput(ctx, userID, req.UserID); err != nil {
|
||||
if req.Password == nil {
|
||||
return nil, passwordHashInvalidErr()
|
||||
}
|
||||
if _, ok := req.UserID.(*tg.InputUserEmpty); ok {
|
||||
return nil, passwordHashInvalidErr()
|
||||
}
|
||||
if _, ok := req.Password.(*tg.InputCheckPasswordEmpty); ok {
|
||||
return nil, passwordHashInvalidErr()
|
||||
}
|
||||
target, found, err := r.userFromInput(ctx, userID, req.UserID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
} else if !found {
|
||||
}
|
||||
if !found || target.ID == 0 {
|
||||
return nil, peerIDInvalidErr()
|
||||
}
|
||||
return nil, tgerr.New(400, "PASSWORD_HASH_INVALID")
|
||||
if target.Bot {
|
||||
return nil, userIDInvalidErr()
|
||||
}
|
||||
if r.deps.Account == nil {
|
||||
return nil, passwordHashInvalidErr()
|
||||
}
|
||||
if err := r.deps.Account.CheckPassword(ctx, userID, domainPasswordCheck(req.Password)); err != nil {
|
||||
return nil, passwordErr(err)
|
||||
}
|
||||
res, err := r.deps.Channels.TransferOwnership(ctx, userID, domain.TransferChannelOwnershipRequest{
|
||||
UserID: userID,
|
||||
ChannelID: channelID,
|
||||
NewOwnerID: target.ID,
|
||||
Date: int(r.clock.Now().Unix()),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, channelTransferErr(err)
|
||||
}
|
||||
r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID)
|
||||
r.addOnlineChannelMemberships(res.Channel.ID, res.OldOwner.UserID, res.NewOwner.UserID)
|
||||
cache := newViewerPeerCache(r)
|
||||
updates := r.channelOwnershipTransferUpdatesWithPeerCache(ctx, userID, userID, res, cache)
|
||||
r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates {
|
||||
return r.channelOwnershipTransferUpdatesWithPeerCache(ctx, viewerUserID, userID, res, cache)
|
||||
})
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func (r *Router) onMessagesGetFutureChatCreatorAfterLeave(ctx context.Context, peer tg.InputPeerClass) (tg.UserClass, error) {
|
||||
|
|
|
|||
|
|
@ -716,6 +716,47 @@ func (r *Router) channelParticipantUpdatesWithPeerCache(ctx context.Context, vie
|
|||
}
|
||||
}
|
||||
|
||||
func (r *Router) channelOwnershipTransferUpdatesWithPeerCache(ctx context.Context, viewerUserID, actorUserID int64, res domain.TransferChannelOwnershipResult, cache *viewerPeerCache) *tg.Updates {
|
||||
if cache == nil {
|
||||
cache = newViewerPeerCache(r)
|
||||
}
|
||||
date := res.Date
|
||||
if date == 0 {
|
||||
date = int(r.clock.Now().Unix())
|
||||
}
|
||||
updates := make([]tg.UpdateClass, 0, len(res.Events)+1)
|
||||
userIDs := []int64{actorUserID, res.PreviousOwner.UserID, res.OldOwner.UserID, res.OldOwner.InviterUserID, res.PreviousNewOwner.UserID, res.NewOwner.UserID, res.NewOwner.InviterUserID}
|
||||
for _, event := range res.Events {
|
||||
update := tgChannelUpdate(viewerUserID, event)
|
||||
if update == nil {
|
||||
continue
|
||||
}
|
||||
updates = append(updates, update)
|
||||
userIDs = append(userIDs, event.SenderUserID, event.Previous.UserID, event.Previous.InviterUserID, event.Participant.UserID, event.Participant.InviterUserID)
|
||||
}
|
||||
updates = append(updates, &tg.UpdateChannel{ChannelID: res.Channel.ID})
|
||||
var self *domain.ChannelMember
|
||||
switch viewerUserID {
|
||||
case res.OldOwner.UserID:
|
||||
member := res.OldOwner
|
||||
self = &member
|
||||
case res.NewOwner.UserID:
|
||||
member := res.NewOwner
|
||||
self = &member
|
||||
}
|
||||
chat := tgChannelChatMin(viewerUserID, res.Channel)
|
||||
if self != nil {
|
||||
chat = tgChannelChat(viewerUserID, res.Channel, self)
|
||||
}
|
||||
return &tg.Updates{
|
||||
Updates: updates,
|
||||
Users: tgUsersForViewer(viewerUserID, cache.usersForIDs(ctx, viewerUserID, uniqueRecipientIDs(userIDs))),
|
||||
Chats: []tg.ChatClass{chat},
|
||||
Date: date,
|
||||
Seq: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func domainChannelAdminLogFilter(req *tg.ChannelsGetAdminLogRequest) domain.ChannelAdminLogFilter {
|
||||
filter, ok := req.GetEventsFilter()
|
||||
if !ok {
|
||||
|
|
@ -828,3 +869,14 @@ func channelAdminErr(err error) error {
|
|||
return channelInvalidErr(err)
|
||||
}
|
||||
}
|
||||
|
||||
func channelTransferErr(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrChannelAdminRequired):
|
||||
return tgerr400("CHAT_CREATOR_REQUIRED")
|
||||
case errors.Is(err, domain.ErrUserNotParticipant):
|
||||
return tgerr400("PARTICIPANT_MISSING")
|
||||
default:
|
||||
return channelAdminErr(err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -196,4 +196,27 @@ func TestPublicChannelPreviewRPCsAllowNonMember(t *testing.T) {
|
|||
if !ok || !peerDialogChat.Left || peerDialogChat.ID != public.Channel.ID {
|
||||
t.Fatalf("peer dialog chat = %T %+v, want left public channel", peerDialogs.Chats[0], peerDialogs.Chats[0])
|
||||
}
|
||||
|
||||
if _, err := channelService.JoinChannel(ctx, viewer.ID, public.Channel.ID, 1700010120); err != nil {
|
||||
t.Fatalf("join public channel after preview: %v", err)
|
||||
}
|
||||
var joinedPeerDialogsIn bin.Buffer
|
||||
if err := peerDialogsReq.Encode(&joinedPeerDialogsIn); err != nil {
|
||||
t.Fatalf("encode joined getPeerDialogs: %v", err)
|
||||
}
|
||||
joinedPeerDialogsEnc, err := r.Dispatch(WithUserID(ctx, viewer.ID), [8]byte{}, 0, &joinedPeerDialogsIn)
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch getPeerDialogs after join: %v", err)
|
||||
}
|
||||
joinedPeerDialogs, ok := joinedPeerDialogsEnc.(*tg.MessagesPeerDialogs)
|
||||
if !ok {
|
||||
t.Fatalf("joined getPeerDialogs response = %T, want peer dialogs", joinedPeerDialogsEnc)
|
||||
}
|
||||
if len(joinedPeerDialogs.Chats) != 1 {
|
||||
t.Fatalf("joined peer dialog chats = %d, want one channel", len(joinedPeerDialogs.Chats))
|
||||
}
|
||||
joinedChat, ok := joinedPeerDialogs.Chats[0].(*tg.Channel)
|
||||
if !ok || joinedChat.Left || joinedChat.ID != public.Channel.ID {
|
||||
t.Fatalf("joined peer dialog chat = %T %+v, want active channel with left=false", joinedPeerDialogs.Chats[0], joinedPeerDialogs.Chats[0])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -848,7 +848,6 @@ func (r *Router) onContactsSearch(ctx context.Context, req *tg.ContactsSearchReq
|
|||
if err != nil {
|
||||
return nil, channelInvalidErr(err)
|
||||
}
|
||||
res.MyChannelResults = channelRes.MyResults
|
||||
res.ChannelResults = channelRes.Results
|
||||
}
|
||||
return r.tgContactsFound(ctx, userID, r.withUserSearchPresence(res)), nil
|
||||
|
|
|
|||
|
|
@ -591,19 +591,8 @@ func TestContactsSearchFindsPublicChannels(t *testing.T) {
|
|||
if !ok {
|
||||
t.Fatalf("result type = %T, want *tg.ContactsFound", enc)
|
||||
}
|
||||
if len(box.MyResults) != 1 || len(box.Chats) != 1 {
|
||||
t.Fatalf("search result sizes = my %d chats %d, want 1/1", len(box.MyResults), len(box.Chats))
|
||||
}
|
||||
peer, ok := box.MyResults[0].(*tg.PeerChannel)
|
||||
if !ok || peer.ChannelID != public.ID {
|
||||
t.Fatalf("peer = %T %+v, want public channel", box.MyResults[0], box.MyResults[0])
|
||||
}
|
||||
chat, ok := box.Chats[0].(*tg.Channel)
|
||||
if !ok || chat.ID != public.ID || chat.Username != "cu_public_rpc" {
|
||||
t.Fatalf("chat = %T %+v, want public channel chat", box.Chats[0], box.Chats[0])
|
||||
}
|
||||
if chat.Left {
|
||||
t.Fatalf("member search chat left = true, want active member channel")
|
||||
if len(box.MyResults) != 0 || len(box.Results) != 0 || len(box.Chats) != 0 {
|
||||
t.Fatalf("member public search = my %d results %d chats %d, want no discovery channel for active member", len(box.MyResults), len(box.Results), len(box.Chats))
|
||||
}
|
||||
|
||||
var strangerIn bin.Buffer
|
||||
|
|
|
|||
|
|
@ -436,7 +436,7 @@ func tgChannel(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember
|
|||
switch self.Role {
|
||||
case domain.ChannelRoleCreator:
|
||||
out.Creator = true
|
||||
out.SetAdminRights(tgChatAdminRights(self.AdminRights))
|
||||
out.SetAdminRights(tgChatAdminRights(creatorProjectionAdminRights(self.AdminRights)))
|
||||
case domain.ChannelRoleAdmin:
|
||||
out.SetAdminRights(tgChatAdminRights(self.AdminRights))
|
||||
}
|
||||
|
|
@ -611,7 +611,7 @@ func tgChannelParticipant(selfUserID int64, member domain.ChannelMember) tg.Chan
|
|||
case domain.ChannelRoleCreator:
|
||||
out := &tg.ChannelParticipantCreator{
|
||||
UserID: member.UserID,
|
||||
AdminRights: tgChatAdminRights(member.AdminRights),
|
||||
AdminRights: tgChatAdminRights(creatorProjectionAdminRights(member.AdminRights)),
|
||||
}
|
||||
if member.Rank != "" {
|
||||
out.SetRank(member.Rank)
|
||||
|
|
@ -779,6 +779,13 @@ func tgChatAdminRights(rights domain.ChannelAdminRights) tg.ChatAdminRights {
|
|||
}
|
||||
}
|
||||
|
||||
func creatorProjectionAdminRights(rights domain.ChannelAdminRights) domain.ChannelAdminRights {
|
||||
creatorRights := domain.CreatorChannelAdminRights()
|
||||
creatorRights.Anonymous = rights.Anonymous
|
||||
creatorRights.ManageDirectMessages = rights.ManageDirectMessages
|
||||
return creatorRights
|
||||
}
|
||||
|
||||
func domainChannelAdminRights(rights tg.ChatAdminRights) domain.ChannelAdminRights {
|
||||
return domain.ChannelAdminRights{
|
||||
ChangeInfo: rights.ChangeInfo,
|
||||
|
|
|
|||
|
|
@ -322,6 +322,12 @@ func tgChannelsForDialogs(viewerUserID int64, channels []domain.Channel, dialogs
|
|||
UserID: viewerUserID,
|
||||
Status: domain.ChannelMemberLeft,
|
||||
}
|
||||
} else {
|
||||
self = &domain.ChannelMember{
|
||||
ChannelID: ch.ID,
|
||||
UserID: viewerUserID,
|
||||
Status: domain.ChannelMemberActive,
|
||||
}
|
||||
}
|
||||
out = append(out, tgChannelChat(viewerUserID, ch, self))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,6 +159,27 @@ func tgMessageServiceAction(msg domain.Message) tg.MessageActionClass {
|
|||
action.SetDuration(m.ServiceAction.Call.Duration)
|
||||
}
|
||||
return action
|
||||
case domain.MessageServiceActionConferenceCall:
|
||||
c := m.ServiceAction.ConferenceCall
|
||||
if c == nil {
|
||||
return &tg.MessageActionEmpty{}
|
||||
}
|
||||
action := &tg.MessageActionConferenceCall{
|
||||
Missed: c.Missed,
|
||||
Active: c.Active,
|
||||
Video: c.Video,
|
||||
CallID: c.CallID,
|
||||
}
|
||||
if c.Duration > 0 {
|
||||
action.SetDuration(c.Duration)
|
||||
}
|
||||
if len(c.OtherParticipants) > 0 {
|
||||
peers := tgPeerList(c.OtherParticipants)
|
||||
if len(peers) > 0 {
|
||||
action.SetOtherParticipants(peers)
|
||||
}
|
||||
}
|
||||
return action
|
||||
case domain.MessageServiceActionBotAllowed:
|
||||
allowed := m.ServiceAction.BotAllowed
|
||||
if allowed == nil {
|
||||
|
|
|
|||
|
|
@ -66,18 +66,19 @@ func tgPhoneCallForViewer(call domain.PhoneCall, viewerID int64) tg.PhoneCallCla
|
|||
gaOrB = call.GB // 主叫视角:拿被叫的 g_b
|
||||
}
|
||||
out := &tg.PhoneCall{
|
||||
P2PAllowed: call.P2PAllowed,
|
||||
Video: call.Video,
|
||||
ID: call.ID,
|
||||
AccessHash: call.AccessHash,
|
||||
Date: call.Date,
|
||||
AdminID: call.AdminID,
|
||||
ParticipantID: call.ParticipantID,
|
||||
GAOrB: gaOrB,
|
||||
KeyFingerprint: call.KeyFingerprint,
|
||||
Protocol: tgPhoneCallProtocol(call.Protocol),
|
||||
Connections: tgPhoneConnections(call.Connections),
|
||||
StartDate: call.StartDate,
|
||||
P2PAllowed: call.P2PAllowed,
|
||||
Video: call.Video,
|
||||
ConferenceSupported: true,
|
||||
ID: call.ID,
|
||||
AccessHash: call.AccessHash,
|
||||
Date: call.Date,
|
||||
AdminID: call.AdminID,
|
||||
ParticipantID: call.ParticipantID,
|
||||
GAOrB: gaOrB,
|
||||
KeyFingerprint: call.KeyFingerprint,
|
||||
Protocol: tgPhoneCallProtocol(call.Protocol),
|
||||
Connections: tgPhoneConnections(call.Connections),
|
||||
StartDate: call.StartDate,
|
||||
}
|
||||
return out
|
||||
case domain.PhoneCallStateDiscarded:
|
||||
|
|
@ -132,7 +133,7 @@ func tgPhoneCallDiscarded(call domain.PhoneCall) *tg.PhoneCallDiscarded {
|
|||
Video: call.Video,
|
||||
ID: call.ID,
|
||||
}
|
||||
if reason := tgPhoneCallDiscardReason(call.DiscardReason); reason != nil {
|
||||
if reason := tgPhoneCallDiscardReasonWithSlug(call.DiscardReason, call.DiscardReasonSlug); reason != nil {
|
||||
out.SetReason(reason)
|
||||
}
|
||||
if call.Duration > 0 {
|
||||
|
|
@ -155,6 +156,10 @@ func tgPhoneCallStopRinging(call domain.PhoneCall) *tg.PhoneCallDiscarded {
|
|||
}
|
||||
|
||||
func tgPhoneCallDiscardReason(r domain.PhoneCallDiscardReason) tg.PhoneCallDiscardReasonClass {
|
||||
return tgPhoneCallDiscardReasonWithSlug(r, "")
|
||||
}
|
||||
|
||||
func tgPhoneCallDiscardReasonWithSlug(r domain.PhoneCallDiscardReason, slug string) tg.PhoneCallDiscardReasonClass {
|
||||
switch r {
|
||||
case domain.PhoneCallDiscardReasonMissed:
|
||||
return &tg.PhoneCallDiscardReasonMissed{}
|
||||
|
|
@ -165,7 +170,7 @@ func tgPhoneCallDiscardReason(r domain.PhoneCallDiscardReason) tg.PhoneCallDisca
|
|||
case domain.PhoneCallDiscardReasonBusy:
|
||||
return &tg.PhoneCallDiscardReasonBusy{}
|
||||
case domain.PhoneCallDiscardReasonMigrateConference:
|
||||
return &tg.PhoneCallDiscardReasonMigrateConferenceCall{}
|
||||
return &tg.PhoneCallDiscardReasonMigrateConferenceCall{Slug: slug}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
|
@ -188,3 +193,10 @@ func phoneCallDiscardReasonFromTL(r tg.PhoneCallDiscardReasonClass) domain.Phone
|
|||
return domain.PhoneCallDiscardReasonHangup
|
||||
}
|
||||
}
|
||||
|
||||
func phoneCallDiscardReasonSlugFromTL(r tg.PhoneCallDiscardReasonClass) string {
|
||||
if migrate, ok := r.(*tg.PhoneCallDiscardReasonMigrateConferenceCall); ok {
|
||||
return migrate.Slug
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ func tgGroupCall(call domain.GroupCall, viewerUserID int64, canManage bool) tg.G
|
|||
JoinMuted: call.JoinMuted,
|
||||
CanChangeJoinMuted: canManage,
|
||||
Creator: call.CreatorUserID == viewerUserID && viewerUserID != 0,
|
||||
Conference: call.Conference(),
|
||||
// can_start_video:TDesktop 不读;DrKLO 用它喂入会前 dummy self 行的
|
||||
// video_joined。RTC 通话一律放行。
|
||||
CanStartVideo: true,
|
||||
|
|
@ -41,6 +42,13 @@ func tgGroupCall(call domain.GroupCall, viewerUserID int64, canManage bool) tg.G
|
|||
if call.Title != "" {
|
||||
out.SetTitle(call.Title)
|
||||
}
|
||||
if call.Conference() {
|
||||
if link := conferenceCanonicalInviteLink(call.InviteSlug); link != "" {
|
||||
out.SetInviteLink(link)
|
||||
} else if call.InviteLink != "" {
|
||||
out.SetInviteLink(call.InviteLink)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -229,7 +229,7 @@ func tgContactsFound(viewerUserID int64, res domain.UserSearchResult) *tg.Contac
|
|||
}
|
||||
for _, ch := range res.MyChannelResults {
|
||||
out.MyResults = append(out.MyResults, &tg.PeerChannel{ChannelID: ch.ID})
|
||||
appendChannel(ch, nil)
|
||||
appendChannel(ch, &domain.ChannelMember{ChannelID: ch.ID, UserID: viewerUserID, Status: domain.ChannelMemberActive})
|
||||
}
|
||||
for _, u := range res.Results {
|
||||
out.Results = append(out.Results, &tg.PeerUser{UserID: u.ID})
|
||||
|
|
|
|||
|
|
@ -470,6 +470,7 @@ type ChannelsService interface {
|
|||
SetWallpaper(ctx context.Context, userID int64, req domain.SetChannelWallpaperRequest) (domain.SetChannelWallpaperResult, error)
|
||||
EditAbout(ctx context.Context, userID int64, req domain.EditChannelAboutRequest) (domain.Channel, error)
|
||||
EditAdmin(ctx context.Context, userID int64, req domain.EditChannelAdminRequest) (domain.EditChannelAdminResult, error)
|
||||
TransferOwnership(ctx context.Context, userID int64, req domain.TransferChannelOwnershipRequest) (domain.TransferChannelOwnershipResult, error)
|
||||
EditMemberRank(ctx context.Context, userID int64, req domain.EditChannelMemberRankRequest) (domain.EditChannelAdminResult, error)
|
||||
EditBanned(ctx context.Context, userID int64, req domain.EditChannelBannedRequest) (domain.EditChannelBannedResult, error)
|
||||
EditDefaultBannedRights(ctx context.Context, userID int64, req domain.EditChannelDefaultBannedRightsRequest) (domain.Channel, error)
|
||||
|
|
@ -760,6 +761,7 @@ type PhoneService interface {
|
|||
AcceptCall(ctx context.Context, userID, callID, accessHash int64, gb []byte, proto domain.PhoneCallProtocol, device domain.SessionRef) (domain.PhoneCall, error)
|
||||
ConfirmCall(ctx context.Context, userID, callID, accessHash int64, ga []byte, keyFingerprint int64, proto domain.PhoneCallProtocol) (call domain.PhoneCall, forcedDiscard bool, err error)
|
||||
DiscardCall(ctx context.Context, userID, callID, accessHash int64, reason domain.PhoneCallDiscardReason, duration int) (call domain.PhoneCall, already bool, err error)
|
||||
DiscardCallWithSlug(ctx context.Context, userID, callID, accessHash int64, reason domain.PhoneCallDiscardReason, reasonSlug string, duration int) (call domain.PhoneCall, already bool, err error)
|
||||
// Signal 在该通话的信令顺序锁内执行 forward;drop=true 表示按契约静默吞掉。
|
||||
// peerDevice 是对端受理设备锚点(可零值/失效),定向推送失败须回退 user 扇出。
|
||||
Signal(ctx context.Context, userID, callID, accessHash int64, forward func(peerUserID int64, peerDevice domain.SessionRef)) (drop bool, err error)
|
||||
|
|
@ -772,9 +774,13 @@ type PhoneService interface {
|
|||
// 错误集合见 domain.ErrGroupCall*(rpc 层映射为 GROUPCALL_* RPC_ERROR)。
|
||||
type GroupCallsService interface {
|
||||
Create(ctx context.Context, channelID, creatorUserID int64, title string, now int) (domain.GroupCall, error)
|
||||
CreateConference(ctx context.Context, creatorUserID, randomID, migratedFromPhoneCallID int64, now int) (domain.GroupCall, error)
|
||||
Get(ctx context.Context, callID int64) (domain.GroupCall, bool, error)
|
||||
GetBySlug(ctx context.Context, slug string) (domain.GroupCall, bool, error)
|
||||
GetByInviteMessage(ctx context.Context, userID int64, msgID int) (domain.GroupCall, domain.GroupCallInvite, bool, error)
|
||||
Join(ctx context.Context, req domain.JoinGroupCallRequest) (domain.GroupCallMutation, error)
|
||||
Leave(ctx context.Context, callID, userID int64, now int) (domain.GroupCallMutation, error)
|
||||
RemoveConferenceParticipants(ctx context.Context, req domain.RemoveConferenceCallParticipantsRequest) (domain.RemoveConferenceCallParticipantsResult, error)
|
||||
Discard(ctx context.Context, callID int64, now int) (domain.GroupCall, []domain.GroupCallParticipant, error)
|
||||
Touch(ctx context.Context, callID, userID int64, now int) (activeSSRCs []int64, joined bool, err error)
|
||||
Participant(ctx context.Context, callID, userID int64) (domain.GroupCallParticipant, bool, error)
|
||||
|
|
@ -788,6 +794,11 @@ type GroupCallsService interface {
|
|||
NextRaiseHandRating(ctx context.Context, callID int64) (int64, error)
|
||||
SetParticipantOverride(ctx context.Context, callID, setterUserID, targetUserID int64, override domain.GroupCallParticipantOverride, clear bool) error
|
||||
ParticipantOverride(ctx context.Context, callID, setterUserID, targetUserID int64) (domain.GroupCallParticipantOverride, bool, error)
|
||||
CreateConferenceInvite(ctx context.Context, invite domain.GroupCallInvite) (domain.GroupCallInvite, error)
|
||||
SetConferenceInviteStatus(ctx context.Context, callID, inviteeUserID int64, msgID int, status domain.GroupCallInviteStatus, now int) (domain.GroupCallInvite, bool, error)
|
||||
ConferenceRecipients(ctx context.Context, callID int64) ([]int64, error)
|
||||
AppendChainBlock(ctx context.Context, block domain.GroupCallChainBlock) (domain.GroupCallChainBlock, error)
|
||||
ChainBlocks(ctx context.Context, callID int64, subChainID, offset, limit int) (domain.GroupCallChainBlockPage, error)
|
||||
}
|
||||
|
||||
// PollsService 抽象 poll 权威态的发送时创建与投票人列表(messages.getPollVotes)。
|
||||
|
|
|
|||
|
|
@ -307,11 +307,15 @@ func groupCallInvalidErr() error { return tgerr.New(400, "GROUPCALL_INV
|
|||
func groupCallAlreadyDiscardedErr() error { return tgerr.New(400, "GROUPCALL_ALREADY_DISCARDED") }
|
||||
func groupCallAlreadyStartedErr() error { return tgerr.New(400, "GROUPCALL_ALREADY_STARTED") }
|
||||
func groupCallForbiddenErr() error { return tgerr.New(403, "GROUPCALL_FORBIDDEN") }
|
||||
func publicChannelMissingErr() error { return tgerr.New(403, "PUBLIC_CHANNEL_MISSING") }
|
||||
func groupCallSSRCDuplicateErr() error {
|
||||
return tgerr.New(400, "GROUPCALL_SSRC_DUPLICATE_MUCH")
|
||||
}
|
||||
func groupCallJoinMissingErr() error { return tgerr.New(400, "GROUPCALL_JOIN_MISSING") }
|
||||
func groupCallNotModifiedErr() error { return tgerr.New(400, "GROUPCALL_NOT_MODIFIED") }
|
||||
func confWriteChainInvalidErr() error {
|
||||
return tgerr.New(400, "CONF_WRITE_CHAIN_INVALID")
|
||||
}
|
||||
|
||||
// 私聊端对端加密(Secret Chat / encrypted chat)错误;触发点见
|
||||
// internal/rpc/encrypted_chats.go 与 app/secretchat、domain 错误映射。
|
||||
|
|
|
|||
|
|
@ -69,6 +69,14 @@ func (d *GroupCallSweepDispatcher) DispatchOnce(ctx context.Context) {
|
|||
if d.router.deps.SFU != nil {
|
||||
_ = d.router.deps.SFU.Leave(ctx, mut.Call.ID, mut.Participant.UserID, sfu.EndpointMain)
|
||||
}
|
||||
if mut.Call.Conference() {
|
||||
d.router.groupCallMutationFanout(ctx, domain.Channel{}, mut)
|
||||
d.log.Info("conference call participant swept",
|
||||
zap.Int64("call_id", mut.Call.ID),
|
||||
zap.Int64("user_id", mut.Participant.UserID),
|
||||
zap.String("state", string(mut.Call.State)))
|
||||
continue
|
||||
}
|
||||
channel, err := d.router.channelForGroupCall(ctx, mut.Call)
|
||||
if err != nil {
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -11,9 +11,12 @@ import (
|
|||
)
|
||||
|
||||
func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.MessagesForwardMessagesRequest) (tg.UpdatesClass, error) {
|
||||
if len(req.ID) == 0 || len(req.ID) != len(req.RandomID) {
|
||||
ids, randomIDs, ok := normalizeForwardMessageVectors(req.ID, req.RandomID)
|
||||
if !ok {
|
||||
return nil, inputRequestInvalidErr()
|
||||
}
|
||||
req.ID = ids
|
||||
req.RandomID = randomIDs
|
||||
if len(req.ID) > domain.MaxForwardMessageIDs {
|
||||
return nil, limitInvalidErr()
|
||||
}
|
||||
|
|
@ -37,7 +40,7 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages
|
|||
if userID == 0 {
|
||||
return nil, peerIDInvalidErr()
|
||||
}
|
||||
fromPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.FromPeer)
|
||||
fromPeer, preloadedSources, err := r.forwardFromPeerAndSources(ctx, userID, req.FromPeer, req.ID, req.RandomID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -69,22 +72,20 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages
|
|||
}
|
||||
}
|
||||
}
|
||||
for i, id := range req.ID {
|
||||
if id <= 0 || id > domain.MaxMessageBoxID || req.RandomID[i] == 0 {
|
||||
return nil, messageIDInvalidErr()
|
||||
}
|
||||
if !forwardMessageIDsValid(req.ID, req.RandomID) {
|
||||
return nil, messageIDInvalidErr()
|
||||
}
|
||||
if err := r.checkSendRateLimit(ctx, userID, len(req.ID)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.ScheduleDate != 0 && !scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix())) {
|
||||
return r.scheduleForwardMessages(ctx, userID, fromPeer, toPeer, req, replyTo, sendAs)
|
||||
return r.scheduleForwardMessages(ctx, userID, fromPeer, toPeer, req, replyTo, sendAs, preloadedSources)
|
||||
}
|
||||
if toPeer.Type == domain.PeerTypeChannel {
|
||||
if r.deps.Channels == nil {
|
||||
return nil, peerIDInvalidErr()
|
||||
}
|
||||
sources, err := r.forwardSources(ctx, userID, fromPeer, req.ID)
|
||||
sources, err := r.forwardSourcesForRequest(ctx, userID, fromPeer, req.ID, preloadedSources)
|
||||
if err != nil {
|
||||
return nil, messageForwardErr(err)
|
||||
}
|
||||
|
|
@ -152,7 +153,7 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages
|
|||
// 私聊源与频道源统一经 forwardSources 取源:首次生成的 forward header 在
|
||||
// forwardSources 内已按原作者 PrivacyKeyForwards 降级(不允许链接回账号时仅保
|
||||
// 留 from_name),避免私聊→私聊路径泄漏原作者可点击账号;media 也随 source 透传。
|
||||
sources, err := r.forwardSources(ctx, userID, fromPeer, req.ID)
|
||||
sources, err := r.forwardSourcesForRequest(ctx, userID, fromPeer, req.ID, preloadedSources)
|
||||
if err != nil {
|
||||
return nil, messageForwardErr(err)
|
||||
}
|
||||
|
|
@ -200,6 +201,110 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages
|
|||
return nil, peerIDInvalidErr()
|
||||
}
|
||||
|
||||
func normalizeForwardMessageVectors(ids []int, randomIDs []int64) ([]int, []int64, bool) {
|
||||
if len(ids) == 0 || len(randomIDs) == 0 {
|
||||
return nil, nil, false
|
||||
}
|
||||
if len(ids) == len(randomIDs) {
|
||||
return ids, randomIDs, true
|
||||
}
|
||||
if len(ids) < len(randomIDs) {
|
||||
return nil, nil, false
|
||||
}
|
||||
compact := make([]int, 0, len(randomIDs))
|
||||
runLength := 0
|
||||
for i, id := range ids {
|
||||
if i == 0 || id != ids[i-1] {
|
||||
compact = append(compact, id)
|
||||
runLength = 1
|
||||
continue
|
||||
}
|
||||
runLength++
|
||||
if runLength > 2 {
|
||||
return nil, nil, false
|
||||
}
|
||||
}
|
||||
if len(compact) != len(randomIDs) {
|
||||
return nil, nil, false
|
||||
}
|
||||
return compact, randomIDs, true
|
||||
}
|
||||
|
||||
func (r *Router) forwardFromPeerAndSources(ctx context.Context, userID int64, input tg.InputPeerClass, ids []int, randomIDs []int64) (domain.Peer, []forwardSource, error) {
|
||||
if forwardFromPeerIsEmpty(input) {
|
||||
if !forwardMessageIDsValid(ids, randomIDs) {
|
||||
return domain.Peer{}, nil, messageIDInvalidErr()
|
||||
}
|
||||
fromPeer, sources, err := r.forwardSourcesFromEmptyPeer(ctx, userID, ids)
|
||||
if err != nil {
|
||||
return domain.Peer{}, nil, messageForwardErr(err)
|
||||
}
|
||||
return fromPeer, sources, nil
|
||||
}
|
||||
fromPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input)
|
||||
return fromPeer, nil, err
|
||||
}
|
||||
|
||||
func forwardMessageIDsValid(ids []int, randomIDs []int64) bool {
|
||||
if len(ids) == 0 || len(ids) != len(randomIDs) {
|
||||
return false
|
||||
}
|
||||
for i, id := range ids {
|
||||
if id <= 0 || id > domain.MaxMessageBoxID || randomIDs[i] == 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func forwardFromPeerIsEmpty(peer tg.InputPeerClass) bool {
|
||||
if inputPeerClassNil(peer) {
|
||||
return false
|
||||
}
|
||||
_, ok := peer.(*tg.InputPeerEmpty)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (r *Router) forwardSourcesFromEmptyPeer(ctx context.Context, userID int64, ids []int) (domain.Peer, []forwardSource, error) {
|
||||
if r.deps.Messages == nil {
|
||||
return domain.Peer{}, nil, domain.ErrMessageIDInvalid
|
||||
}
|
||||
list, err := r.deps.Messages.GetMessages(ctx, userID, ids)
|
||||
if err != nil {
|
||||
return domain.Peer{}, nil, domain.ErrMessageIDInvalid
|
||||
}
|
||||
var fromPeer domain.Peer
|
||||
byID := make(map[int]domain.Message, len(list.Messages))
|
||||
for _, msg := range list.Messages {
|
||||
byID[msg.ID] = msg
|
||||
}
|
||||
for _, id := range ids {
|
||||
msg, ok := byID[id]
|
||||
if !ok || msg.Peer.Type != domain.PeerTypeUser || msg.Peer.ID == 0 {
|
||||
return domain.Peer{}, nil, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if fromPeer.ID == 0 {
|
||||
fromPeer = msg.Peer
|
||||
continue
|
||||
}
|
||||
if msg.Peer != fromPeer {
|
||||
return domain.Peer{}, nil, domain.ErrMessageIDInvalid
|
||||
}
|
||||
}
|
||||
sources, err := r.forwardSourcesFromPrivateMessages(ctx, userID, fromPeer, ids, list.Messages)
|
||||
if err != nil {
|
||||
return domain.Peer{}, nil, err
|
||||
}
|
||||
return fromPeer, sources, nil
|
||||
}
|
||||
|
||||
func (r *Router) forwardSourcesForRequest(ctx context.Context, userID int64, fromPeer domain.Peer, ids []int, preloaded []forwardSource) ([]forwardSource, error) {
|
||||
if preloaded != nil {
|
||||
return preloaded, nil
|
||||
}
|
||||
return r.forwardSources(ctx, userID, fromPeer, ids)
|
||||
}
|
||||
|
||||
func mergeForwardTopMsgID(toPeer domain.Peer, replyTo *domain.MessageReply, topMsgID int, topMsgIDSet bool) (*domain.MessageReply, error) {
|
||||
if !topMsgIDSet || topMsgID == 0 {
|
||||
return replyTo, nil
|
||||
|
|
@ -241,36 +346,11 @@ func (r *Router) forwardSources(ctx context.Context, userID int64, fromPeer doma
|
|||
if err != nil {
|
||||
return nil, domain.ErrMessageIDInvalid
|
||||
}
|
||||
byID := make(map[int]domain.Message, len(list.Messages))
|
||||
for _, msg := range list.Messages {
|
||||
byID[msg.ID] = msg
|
||||
}
|
||||
for _, id := range ids {
|
||||
msg, ok := byID[id]
|
||||
if !ok {
|
||||
return nil, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if msg.Peer != fromPeer {
|
||||
return nil, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if msg.NoForwards {
|
||||
return nil, domain.ErrChatForwardsRestricted
|
||||
}
|
||||
forward := cloneDomainMessageForward(msg.Forward)
|
||||
if forward == nil {
|
||||
forward = &domain.MessageForward{From: msg.From, Date: msg.Date}
|
||||
r.applyForwardAuthorPrivacy(ctx, userID, forward)
|
||||
}
|
||||
out = append(out, forwardSource{
|
||||
body: msg.Body,
|
||||
entities: append([]domain.MessageEntity(nil),
|
||||
msg.Entities...),
|
||||
media: msg.Media,
|
||||
forward: forward,
|
||||
from: msg.From,
|
||||
date: msg.Date,
|
||||
})
|
||||
sources, err := r.forwardSourcesFromPrivateMessages(ctx, userID, fromPeer, ids, list.Messages)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, sources...)
|
||||
case domain.PeerTypeChannel:
|
||||
if r.deps.Channels == nil {
|
||||
return nil, domain.ErrMessageIDInvalid
|
||||
|
|
@ -325,6 +405,44 @@ func (r *Router) forwardSources(ctx context.Context, userID int64, fromPeer doma
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Router) forwardSourcesFromPrivateMessages(ctx context.Context, userID int64, fromPeer domain.Peer, ids []int, messages []domain.Message) ([]forwardSource, error) {
|
||||
if fromPeer.Type != domain.PeerTypeUser || fromPeer.ID == 0 {
|
||||
return nil, domain.ErrMessageIDInvalid
|
||||
}
|
||||
byID := make(map[int]domain.Message, len(messages))
|
||||
for _, msg := range messages {
|
||||
byID[msg.ID] = msg
|
||||
}
|
||||
out := make([]forwardSource, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
msg, ok := byID[id]
|
||||
if !ok {
|
||||
return nil, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if msg.Peer != fromPeer {
|
||||
return nil, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if msg.NoForwards {
|
||||
return nil, domain.ErrChatForwardsRestricted
|
||||
}
|
||||
forward := cloneDomainMessageForward(msg.Forward)
|
||||
if forward == nil {
|
||||
forward = &domain.MessageForward{From: msg.From, Date: msg.Date}
|
||||
r.applyForwardAuthorPrivacy(ctx, userID, forward)
|
||||
}
|
||||
out = append(out, forwardSource{
|
||||
body: msg.Body,
|
||||
entities: append([]domain.MessageEntity(nil),
|
||||
msg.Entities...),
|
||||
media: msg.Media,
|
||||
forward: forward,
|
||||
from: msg.From,
|
||||
date: msg.Date,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func cloneDomainMessageForward(in *domain.MessageForward) *domain.MessageForward {
|
||||
if in == nil {
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -195,6 +195,200 @@ func TestMessagesForwardMessagesLoadsPrivateSourcesInSingleBatch(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestMessagesForwardMessagesInfersPrivateSourceFromInputPeerEmpty(t *testing.T) {
|
||||
const (
|
||||
ownerID = int64(1780243210)
|
||||
fromID = int64(1780243211)
|
||||
toID = int64(1780243212)
|
||||
)
|
||||
ctx := context.Background()
|
||||
messages := &captureMessages{
|
||||
getMessagesListed: true,
|
||||
list: domain.MessageList{Messages: []domain.Message{
|
||||
{
|
||||
ID: 189,
|
||||
OwnerUserID: ownerID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: fromID},
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: fromID},
|
||||
Date: 1700002189,
|
||||
Body: "android source",
|
||||
},
|
||||
}},
|
||||
}
|
||||
r := New(Config{}, Deps{
|
||||
Messages: messages,
|
||||
Users: mapUsersService{users: map[int64]domain.User{
|
||||
ownerID: {ID: ownerID, FirstName: "Owner"},
|
||||
fromID: {ID: fromID, FirstName: "From"},
|
||||
toID: {ID: toID, FirstName: "To"},
|
||||
}},
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
updatesClass, err := r.onMessagesForwardMessages(WithUserID(ctx, ownerID), &tg.MessagesForwardMessagesRequest{
|
||||
FromPeer: &tg.InputPeerEmpty{},
|
||||
ToPeer: &tg.InputPeerUser{UserID: toID},
|
||||
ID: []int{189},
|
||||
RandomID: []int64{5069400637215652584},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("forward with empty source peer: %v", err)
|
||||
}
|
||||
if messages.getMessagesCalls != 1 || len(messages.getMessagesIDs) != 1 || len(messages.getMessagesIDs[0]) != 1 || messages.getMessagesIDs[0][0] != 189 {
|
||||
t.Fatalf("GetMessages calls=%d ids=%+v, want one source lookup for [189]", messages.getMessagesCalls, messages.getMessagesIDs)
|
||||
}
|
||||
if messages.sendReq.RecipientUserID != toID || messages.sendReq.Message != "android source" {
|
||||
t.Fatalf("send request = %+v, want inferred source body to target", messages.sendReq)
|
||||
}
|
||||
if messages.sendReq.Forward == nil || messages.sendReq.Forward.From != (domain.Peer{Type: domain.PeerTypeUser, ID: fromID}) {
|
||||
t.Fatalf("forward header = %+v, want original author %d", messages.sendReq.Forward, fromID)
|
||||
}
|
||||
updates, ok := updatesClass.(*tg.Updates)
|
||||
if !ok || len(updates.Updates) != 2 {
|
||||
t.Fatalf("updates = %T %+v, want updateMessageID + updateNewMessage", updatesClass, updatesClass)
|
||||
}
|
||||
if id, ok := updates.Updates[0].(*tg.UpdateMessageID); !ok || id.RandomID != 5069400637215652584 {
|
||||
t.Fatalf("first update = %#v, want request random id", updates.Updates[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesForwardMessagesInputPeerEmptyRejectsMixedPrivateSources(t *testing.T) {
|
||||
const (
|
||||
ownerID = int64(1780243210)
|
||||
fromA = int64(1780243211)
|
||||
fromB = int64(1780243212)
|
||||
toID = int64(1780243213)
|
||||
)
|
||||
ctx := context.Background()
|
||||
messages := &captureMessages{
|
||||
getMessagesListed: true,
|
||||
list: domain.MessageList{Messages: []domain.Message{
|
||||
{ID: 10, OwnerUserID: ownerID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: fromA}, From: domain.Peer{Type: domain.PeerTypeUser, ID: fromA}, Date: 1700002210, Body: "first"},
|
||||
{ID: 11, OwnerUserID: ownerID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: fromB}, From: domain.Peer{Type: domain.PeerTypeUser, ID: fromB}, Date: 1700002211, Body: "second"},
|
||||
}},
|
||||
}
|
||||
r := New(Config{}, Deps{
|
||||
Messages: messages,
|
||||
Users: mapUsersService{users: map[int64]domain.User{
|
||||
ownerID: {ID: ownerID, FirstName: "Owner"},
|
||||
fromA: {ID: fromA, FirstName: "FromA"},
|
||||
fromB: {ID: fromB, FirstName: "FromB"},
|
||||
toID: {ID: toID, FirstName: "To"},
|
||||
}},
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
_, err := r.onMessagesForwardMessages(WithUserID(ctx, ownerID), &tg.MessagesForwardMessagesRequest{
|
||||
FromPeer: &tg.InputPeerEmpty{},
|
||||
ToPeer: &tg.InputPeerUser{UserID: toID},
|
||||
ID: []int{10, 11},
|
||||
RandomID: []int64{10010, 10011},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") {
|
||||
t.Fatalf("forward mixed empty-source ids err = %v, want MESSAGE_ID_INVALID", err)
|
||||
}
|
||||
if messages.sendReq.RecipientUserID != 0 {
|
||||
t.Fatalf("send request = %+v, want no send after mixed source rejection", messages.sendReq)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesForwardMessagesInputPeerEmptyRejectsBadIDsBeforeLookup(t *testing.T) {
|
||||
const ownerID = int64(1780243210)
|
||||
ctx := context.Background()
|
||||
messages := &captureMessages{}
|
||||
r := New(Config{}, Deps{
|
||||
Messages: messages,
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
_, err := r.onMessagesForwardMessages(WithUserID(ctx, ownerID), &tg.MessagesForwardMessagesRequest{
|
||||
FromPeer: &tg.InputPeerEmpty{},
|
||||
ToPeer: &tg.InputPeerUser{UserID: 1780243211},
|
||||
ID: []int{0},
|
||||
RandomID: []int64{10001},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") {
|
||||
t.Fatalf("forward bad empty-source id err = %v, want MESSAGE_ID_INVALID", err)
|
||||
}
|
||||
if messages.getMessagesCalls != 0 {
|
||||
t.Fatalf("GetMessages calls = %d, want no source lookup for invalid id", messages.getMessagesCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesForwardMessagesNormalizesAndroidDuplicateIDRetry(t *testing.T) {
|
||||
const (
|
||||
ownerID = int64(1780243210)
|
||||
fromID = int64(1780243211)
|
||||
)
|
||||
ctx := context.Background()
|
||||
messages := &captureMessages{
|
||||
getMessagesListed: true,
|
||||
list: domain.MessageList{Messages: []domain.Message{
|
||||
{
|
||||
ID: 187,
|
||||
OwnerUserID: ownerID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: fromID},
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: fromID},
|
||||
Date: 1700002187,
|
||||
Body: "retry source",
|
||||
},
|
||||
}},
|
||||
}
|
||||
r := New(Config{}, Deps{
|
||||
Messages: messages,
|
||||
Users: mapUsersService{users: map[int64]domain.User{
|
||||
ownerID: {ID: ownerID, FirstName: "Owner"},
|
||||
fromID: {ID: fromID, FirstName: "From"},
|
||||
}},
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
updatesClass, err := r.onMessagesForwardMessages(WithUserID(ctx, ownerID), &tg.MessagesForwardMessagesRequest{
|
||||
FromPeer: &tg.InputPeerEmpty{},
|
||||
ToPeer: &tg.InputPeerUser{UserID: fromID},
|
||||
ID: []int{187, 187},
|
||||
RandomID: []int64{1993272996073519809},
|
||||
DropAuthor: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("forward android duplicate-id retry: %v", err)
|
||||
}
|
||||
if messages.getMessagesCalls != 1 || len(messages.getMessagesIDs) != 1 || len(messages.getMessagesIDs[0]) != 1 || messages.getMessagesIDs[0][0] != 187 {
|
||||
t.Fatalf("GetMessages calls=%d ids=%+v, want one normalized source lookup for [187]", messages.getMessagesCalls, messages.getMessagesIDs)
|
||||
}
|
||||
if messages.sendReq.RecipientUserID != fromID || messages.sendReq.Message != "retry source" {
|
||||
t.Fatalf("send request = %+v, want one forwarded message to current peer", messages.sendReq)
|
||||
}
|
||||
if messages.sendReq.Forward != nil {
|
||||
t.Fatalf("forward header = %+v, want dropped author", messages.sendReq.Forward)
|
||||
}
|
||||
updates, ok := updatesClass.(*tg.Updates)
|
||||
if !ok || len(updates.Updates) != 2 {
|
||||
t.Fatalf("updates = %T %+v, want one updateMessageID + one updateNewMessage", updatesClass, updatesClass)
|
||||
}
|
||||
if id, ok := updates.Updates[0].(*tg.UpdateMessageID); !ok || id.RandomID != 1993272996073519809 {
|
||||
t.Fatalf("first update = %#v, want normalized random id", updates.Updates[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesForwardMessagesRejectsUnpairedIDRandomVectors(t *testing.T) {
|
||||
const ownerID = int64(1780243210)
|
||||
ctx := context.Background()
|
||||
messages := &captureMessages{}
|
||||
r := New(Config{}, Deps{
|
||||
Messages: messages,
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
_, err := r.onMessagesForwardMessages(WithUserID(ctx, ownerID), &tg.MessagesForwardMessagesRequest{
|
||||
FromPeer: &tg.InputPeerEmpty{},
|
||||
ToPeer: &tg.InputPeerUser{UserID: 1780243211},
|
||||
ID: []int{187, 188},
|
||||
RandomID: []int64{1993272996073519809},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "INPUT_REQUEST_INVALID") {
|
||||
t.Fatalf("forward unpaired vectors err = %v, want INPUT_REQUEST_INVALID", err)
|
||||
}
|
||||
if messages.getMessagesCalls != 0 {
|
||||
t.Fatalf("GetMessages calls = %d, want no source lookup for unpaired vectors", messages.getMessagesCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatsForMessageUpdatesUsesBatchChannelProjection(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
|
|
|
|||
|
|
@ -365,12 +365,12 @@ func sentMessageIDFromUpdates(updates tg.UpdatesClass) int {
|
|||
return 0
|
||||
}
|
||||
|
||||
func (r *Router) scheduleForwardMessages(ctx context.Context, userID int64, fromPeer, toPeer domain.Peer, req *tg.MessagesForwardMessagesRequest, replyTo *domain.MessageReply, sendAs *domain.Peer) (tg.UpdatesClass, error) {
|
||||
func (r *Router) scheduleForwardMessages(ctx context.Context, userID int64, fromPeer, toPeer domain.Peer, req *tg.MessagesForwardMessagesRequest, replyTo *domain.MessageReply, sendAs *domain.Peer, preloadedSources []forwardSource) (tg.UpdatesClass, error) {
|
||||
scheduledSvc, ok := r.deps.Messages.(scheduledMessagesService)
|
||||
if r.deps.Messages == nil || !ok {
|
||||
return nil, peerIDInvalidErr()
|
||||
}
|
||||
sources, err := r.forwardSources(ctx, userID, fromPeer, req.ID)
|
||||
sources, err := r.forwardSourcesForRequest(ctx, userID, fromPeer, req.ID, preloadedSources)
|
||||
if err != nil {
|
||||
return nil, messageForwardErr(err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -210,15 +210,28 @@ func (r *Router) onPhoneDiscardCall(ctx context.Context, req *tg.PhoneDiscardCal
|
|||
return nil, err
|
||||
}
|
||||
reason := phoneCallDiscardReasonFromTL(req.Reason)
|
||||
call, already, err := r.deps.Phone.DiscardCall(ctx, userID, req.Peer.ID, req.Peer.AccessHash, reason, req.Duration)
|
||||
reasonSlug := phoneCallDiscardReasonSlugFromTL(req.Reason)
|
||||
if reason == domain.PhoneCallDiscardReasonMigrateConference {
|
||||
if reasonSlug == "" || r.deps.GroupCalls == nil {
|
||||
return nil, groupCallInvalidErr()
|
||||
}
|
||||
if call, found, err := r.deps.GroupCalls.GetBySlug(ctx, reasonSlug); err != nil {
|
||||
return nil, internalErr()
|
||||
} else if !found || !call.Conference() || !call.Active() {
|
||||
return nil, groupCallInvalidErr()
|
||||
}
|
||||
}
|
||||
call, already, err := r.deps.Phone.DiscardCallWithSlug(ctx, userID, req.Peer.ID, req.Peer.AccessHash, reason, reasonSlug, req.Duration)
|
||||
if err != nil {
|
||||
return nil, phoneCallErr(err)
|
||||
}
|
||||
if !already {
|
||||
// 对端全部设备 + 发起者其它设备(ctx except 排除发起设备,其结果在 RPC 响应里)。
|
||||
r.pushPhoneCallDiscardedBoth(ctx, call)
|
||||
// 落 messageActionPhoneCall 历史(带 pts 走 outbox,双方全部设备可靠收到)。
|
||||
r.sendPhoneCallServiceMessage(ctx, call)
|
||||
if reason != domain.PhoneCallDiscardReasonMigrateConference {
|
||||
// 落 messageActionPhoneCall 历史(带 pts 走 outbox,双方全部设备可靠收到)。
|
||||
r.sendPhoneCallServiceMessage(ctx, call)
|
||||
}
|
||||
}
|
||||
// 双方同时挂断的竞态:先到者定 reason,后到者拿终态快照(幂等成功)。
|
||||
return r.phoneCallUpdates(ctx, call, userID), nil
|
||||
|
|
|
|||
468
internal/rpc/phone_conference.go
Normal file
468
internal/rpc/phone_conference.go
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
|
||||
"github.com/gotd/td/tg"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/sfu"
|
||||
)
|
||||
|
||||
const (
|
||||
maxConferenceChainBlockBytes = 64 * 1024
|
||||
maxConferenceChainBlocks = 100
|
||||
|
||||
conferenceChainBlockConstructor = 0x639a3db6
|
||||
conferenceChainBlockServerConstructor = 0x639a3db7
|
||||
conferenceBroadcastCommitConstructor = 0xd1512ae7
|
||||
conferenceBroadcastCommitServerConstructor = 0xd1512ae8
|
||||
conferenceBroadcastRevealConstructor = 0x83f4f9d8
|
||||
conferenceBroadcastRevealServerConstructor = 0x83f4f9d9
|
||||
)
|
||||
|
||||
func (r *Router) onPhoneCreateConferenceCall(ctx context.Context, req *tg.PhoneCreateConferenceCallRequest) (tg.UpdatesClass, error) {
|
||||
if req == nil {
|
||||
return nil, inputRequestInvalidErr()
|
||||
}
|
||||
if r.deps.GroupCalls == nil {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
userID, err := r.phoneRequireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := int(r.clock.Now().Unix())
|
||||
call, err := r.deps.GroupCalls.CreateConference(ctx, userID, int64(req.RandomID), 0, now)
|
||||
if err != nil {
|
||||
return nil, groupCallErr(err)
|
||||
}
|
||||
out := r.groupCallUpdateContainer(ctx, userID, domain.Channel{},
|
||||
&tg.UpdateGroupCall{Call: tgGroupCall(call, userID, true)}, []int64{userID})
|
||||
if !req.Join {
|
||||
return out, nil
|
||||
}
|
||||
params, ok := req.GetParams()
|
||||
if !ok {
|
||||
return nil, groupCallInvalidErr()
|
||||
}
|
||||
joinReq := &tg.PhoneJoinGroupCallRequest{
|
||||
Muted: req.Muted,
|
||||
VideoStopped: req.VideoStopped,
|
||||
Call: &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash},
|
||||
JoinAs: &tg.InputPeerSelf{},
|
||||
Params: params,
|
||||
}
|
||||
if pk, ok := req.GetPublicKey(); ok {
|
||||
joinReq.SetPublicKey(pk)
|
||||
}
|
||||
if block, ok := req.GetBlock(); ok {
|
||||
joinReq.SetBlock(block)
|
||||
}
|
||||
joinUpdates, err := r.onPhoneJoinGroupCall(ctx, joinReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appendUpdates(out, joinUpdates)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Router) onPhoneExportGroupCallInvite(ctx context.Context, req *tg.PhoneExportGroupCallInviteRequest) (*tg.PhoneExportedGroupCallInvite, error) {
|
||||
if req == nil {
|
||||
return nil, inputRequestInvalidErr()
|
||||
}
|
||||
scope, err := r.groupCallScopeFrom(ctx, req.Call)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !scope.call.Active() {
|
||||
return nil, groupCallInvalidErr()
|
||||
}
|
||||
if scope.call.Conference() {
|
||||
link := conferenceExportInviteLink(scope.call)
|
||||
if link == "" {
|
||||
return nil, groupCallInvalidErr()
|
||||
}
|
||||
return &tg.PhoneExportedGroupCallInvite{Link: link}, nil
|
||||
}
|
||||
if scope.call.InviteLink != "" {
|
||||
return &tg.PhoneExportedGroupCallInvite{Link: scope.call.InviteLink}, nil
|
||||
}
|
||||
if scope.channel.Username == "" {
|
||||
return nil, publicChannelMissingErr()
|
||||
}
|
||||
return &tg.PhoneExportedGroupCallInvite{Link: "https://telesrv.net/" + scope.channel.Username}, nil
|
||||
}
|
||||
|
||||
func conferenceExportInviteLink(call domain.GroupCall) string {
|
||||
if link := conferenceCanonicalInviteLink(call.InviteSlug); link != "" {
|
||||
return link
|
||||
}
|
||||
return call.InviteLink
|
||||
}
|
||||
|
||||
func conferenceCanonicalInviteLink(slug string) string {
|
||||
if slug == "" {
|
||||
return ""
|
||||
}
|
||||
return "https://telesrv.net/call/" + slug + "?slug=" + slug
|
||||
}
|
||||
|
||||
func (r *Router) onPhoneInviteConferenceCallParticipant(ctx context.Context, req *tg.PhoneInviteConferenceCallParticipantRequest) (tg.UpdatesClass, error) {
|
||||
if req == nil {
|
||||
return nil, inputRequestInvalidErr()
|
||||
}
|
||||
if r.deps.GroupCalls == nil || r.deps.Messages == nil {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
scope, err := r.groupCallScopeFrom(ctx, req.Call)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !scope.call.Conference() || !scope.call.Active() {
|
||||
return nil, groupCallInvalidErr()
|
||||
}
|
||||
target, found, err := r.userFromInput(ctx, scope.userID, req.UserID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !found || target.ID == 0 || target.Bot || target.ID == scope.userID {
|
||||
return nil, userIDInvalidErr()
|
||||
}
|
||||
if p, found, err := r.deps.GroupCalls.Participant(ctx, scope.call.ID, target.ID); err != nil {
|
||||
return nil, internalErr()
|
||||
} else if found && !p.Left {
|
||||
return nil, tgerr400("USER_ALREADY_PARTICIPANT")
|
||||
}
|
||||
recipientBlocked, err := r.peerBlocksUser(ctx, scope.userID, target.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := int(r.clock.Now().Unix())
|
||||
res, err := r.deps.Messages.SendPrivateText(ctx, scope.userID, domain.SendPrivateTextRequest{
|
||||
SenderUserID: scope.userID,
|
||||
RecipientUserID: target.ID,
|
||||
RandomID: conferenceInviteRandomID(scope.call.ID, target.ID, now),
|
||||
Media: &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindService,
|
||||
ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionConferenceCall,
|
||||
ConferenceCall: &domain.MessageConferenceCallAction{
|
||||
CallID: scope.call.ID,
|
||||
Video: req.Video,
|
||||
OtherParticipants: []domain.Peer{
|
||||
{Type: domain.PeerTypeUser, ID: scope.userID},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Date: now,
|
||||
OriginAuthKeyID: authKeyIDFromCtx(ctx),
|
||||
OriginSessionID: sessionIDFromCtx(ctx),
|
||||
RecipientBlocked: recipientBlocked,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, messageSendErr(err)
|
||||
}
|
||||
invite, err := r.deps.GroupCalls.CreateConferenceInvite(ctx, domain.GroupCallInvite{
|
||||
CallID: scope.call.ID,
|
||||
InviterUserID: scope.userID,
|
||||
InviteeUserID: target.ID,
|
||||
MessageID: res.RecipientMessage.ID,
|
||||
Status: domain.GroupCallInvitePending,
|
||||
Video: req.Video,
|
||||
CreatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, groupCallErr(err)
|
||||
}
|
||||
_ = invite
|
||||
users := r.tgUsersForIDs(ctx, scope.userID, []int64{scope.userID, target.ID})
|
||||
out := tgPrivateMessageUpdates(res.SenderEvent, res.SenderMessage, 0, false, users, nil)
|
||||
recipientUsers := r.tgUsersForIDs(ctx, target.ID, []int64{scope.userID, target.ID})
|
||||
r.pushUserMessage(ctx, target.ID, "conference invite",
|
||||
tgPrivateMessageUpdates(res.RecipientEvent, res.RecipientMessage, 0, false, recipientUsers, nil))
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Router) onPhoneDeclineConferenceCallInvite(ctx context.Context, msgID int) (tg.UpdatesClass, error) {
|
||||
if r.deps.GroupCalls == nil {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
userID, err := r.phoneRequireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
call, inv, found, err := r.deps.GroupCalls.GetByInviteMessage(ctx, userID, msgID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !found || !call.Conference() {
|
||||
return nil, msgIDInvalidErr()
|
||||
}
|
||||
now := int(r.clock.Now().Unix())
|
||||
if _, _, err := r.deps.GroupCalls.SetConferenceInviteStatus(ctx, call.ID, userID, msgID, domain.GroupCallInviteDeclined, now); err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
r.pushConferenceGroupCallUpdate(ctx, call)
|
||||
return r.groupCallUpdateContainer(ctx, userID, domain.Channel{},
|
||||
&tg.UpdateGroupCall{Call: tgGroupCall(call, userID, userID == call.CreatorUserID)}, []int64{inv.InviterUserID, inv.InviteeUserID}), nil
|
||||
}
|
||||
|
||||
func (r *Router) onPhoneDeleteConferenceCallParticipants(ctx context.Context, req *tg.PhoneDeleteConferenceCallParticipantsRequest) (tg.UpdatesClass, error) {
|
||||
if req == nil {
|
||||
return nil, inputRequestInvalidErr()
|
||||
}
|
||||
if len(req.IDs) == 0 || len(req.IDs) > 100 {
|
||||
return nil, limitInvalidErr()
|
||||
}
|
||||
if len(req.Block) > maxConferenceChainBlockBytes {
|
||||
return nil, limitInvalidErr()
|
||||
}
|
||||
scope, err := r.groupCallScopeFrom(ctx, req.Call)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !scope.call.Conference() {
|
||||
return nil, groupCallInvalidErr()
|
||||
}
|
||||
if req.OnlyLeft == req.Kick {
|
||||
return nil, inputRequestInvalidErr()
|
||||
}
|
||||
now := int(r.clock.Now().Unix())
|
||||
if req.OnlyLeft && !scope.canManage() {
|
||||
self, found, err := r.deps.GroupCalls.Participant(ctx, scope.call.ID, scope.userID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !found || self.Left {
|
||||
return nil, groupCallForbiddenErr()
|
||||
}
|
||||
}
|
||||
for _, targetID := range req.IDs {
|
||||
if targetID <= 0 {
|
||||
return nil, userIDInvalidErr()
|
||||
}
|
||||
if req.Kick && targetID != scope.userID && !scope.canManage() {
|
||||
return nil, groupCallForbiddenErr()
|
||||
}
|
||||
}
|
||||
result, err := r.deps.GroupCalls.RemoveConferenceParticipants(ctx, domain.RemoveConferenceCallParticipantsRequest{
|
||||
CallID: scope.call.ID,
|
||||
AuthorUserID: scope.userID,
|
||||
TargetUserIDs: req.IDs,
|
||||
OnlyLeft: req.OnlyLeft,
|
||||
Kick: req.Kick,
|
||||
Block: req.Block,
|
||||
Now: now,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, groupCallErr(err)
|
||||
}
|
||||
if result.ChainBlockAppended {
|
||||
block := result.ChainBlock
|
||||
r.pushConferenceChainBlocks(ctx, result.Call, block.SubChainID, [][]byte{block.Block}, block.Offset+1)
|
||||
}
|
||||
if len(result.ParticipantsChanged) > 0 {
|
||||
r.pushConferenceGroupCallParticipantsUpdate(ctx, result.Call, result.ParticipantsChanged)
|
||||
r.pushConferenceGroupCallUpdate(ctx, result.Call)
|
||||
}
|
||||
for _, p := range result.ParticipantsChanged {
|
||||
if r.deps.SFU != nil {
|
||||
_ = r.deps.SFU.Leave(ctx, scope.call.ID, p.UserID, sfu.EndpointMain)
|
||||
}
|
||||
}
|
||||
out := r.groupCallUpdateContainer(ctx, scope.userID, domain.Channel{},
|
||||
&tg.UpdateGroupCallParticipants{
|
||||
Call: &tg.InputGroupCall{ID: result.Call.ID, AccessHash: result.Call.AccessHash},
|
||||
Participants: tgGroupCallParticipants(result.ParticipantsChanged, scope.userID),
|
||||
Version: result.Call.Version,
|
||||
}, req.IDs)
|
||||
if result.ChainBlockAppended {
|
||||
block := result.ChainBlock
|
||||
out.Updates = append(out.Updates, conferenceChainBlocksUpdate(result.Call, block.SubChainID, [][]byte{block.Block}, block.Offset+1))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Router) onPhoneSendConferenceCallBroadcast(ctx context.Context, req *tg.PhoneSendConferenceCallBroadcastRequest) (tg.UpdatesClass, error) {
|
||||
if req == nil {
|
||||
return nil, inputRequestInvalidErr()
|
||||
}
|
||||
if len(req.Block) == 0 || len(req.Block) > maxConferenceChainBlockBytes {
|
||||
return nil, limitInvalidErr()
|
||||
}
|
||||
scope, err := r.groupCallScopeFrom(ctx, req.Call)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !scope.call.Conference() {
|
||||
return nil, groupCallInvalidErr()
|
||||
}
|
||||
if err := r.requireActiveConferenceParticipant(ctx, scope.call.ID, scope.userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := int(r.clock.Now().Unix())
|
||||
block, err := r.deps.GroupCalls.AppendChainBlock(ctx, domain.GroupCallChainBlock{
|
||||
CallID: scope.call.ID,
|
||||
SubChainID: 1,
|
||||
Offset: -1,
|
||||
AuthorUserID: scope.userID,
|
||||
Block: req.Block,
|
||||
CreatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, groupCallErr(err)
|
||||
}
|
||||
nextOffset := block.Offset + 1
|
||||
r.pushConferenceChainBlocks(ctx, scope.call, block.SubChainID, [][]byte{block.Block}, nextOffset)
|
||||
return r.conferenceChainBlocksUpdates(ctx, scope.userID, scope.call, block.SubChainID, [][]byte{block.Block}, nextOffset), nil
|
||||
}
|
||||
|
||||
func (r *Router) requireActiveConferenceParticipant(ctx context.Context, callID, userID int64) error {
|
||||
if r.deps.GroupCalls == nil {
|
||||
return notImplementedErr()
|
||||
}
|
||||
p, found, err := r.deps.GroupCalls.Participant(ctx, callID, userID)
|
||||
if err != nil {
|
||||
return internalErr()
|
||||
}
|
||||
if !found || p.Left {
|
||||
return groupCallJoinMissingErr()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Router) onPhoneGetGroupCallChainBlocks(ctx context.Context, req *tg.PhoneGetGroupCallChainBlocksRequest) (tg.UpdatesClass, error) {
|
||||
if req == nil {
|
||||
return nil, inputRequestInvalidErr()
|
||||
}
|
||||
if req.Offset < domain.GroupCallChainBlockLatestOffset {
|
||||
return nil, inputRequestInvalidErr()
|
||||
}
|
||||
limit := req.Limit
|
||||
if limit <= 0 || limit > maxConferenceChainBlocks {
|
||||
limit = maxConferenceChainBlocks
|
||||
}
|
||||
scope, err := r.groupCallScopeFrom(ctx, req.Call)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !scope.call.Conference() {
|
||||
return nil, groupCallInvalidErr()
|
||||
}
|
||||
page, err := r.deps.GroupCalls.ChainBlocks(ctx, scope.call.ID, req.SubChainID, req.Offset, limit)
|
||||
if err != nil {
|
||||
return nil, groupCallErr(err)
|
||||
}
|
||||
blocks := make([][]byte, 0, len(page.Blocks))
|
||||
for _, row := range page.Blocks {
|
||||
blocks = append(blocks, row.Block)
|
||||
}
|
||||
return r.conferenceChainBlocksUpdates(ctx, scope.userID, scope.call, req.SubChainID, blocks, page.NextOffset), nil
|
||||
}
|
||||
|
||||
func (r *Router) pushConferenceChainBlocks(ctx context.Context, call domain.GroupCall, subChainID int, blocks [][]byte, nextOffset int) {
|
||||
recipients := r.conferenceCallRecipients(ctx, call.ID)
|
||||
for _, viewerID := range recipients {
|
||||
r.pushUserMessage(ctx, viewerID, "conference chain blocks",
|
||||
r.conferenceChainBlocksUpdates(ctx, viewerID, call, subChainID, blocks, nextOffset))
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) conferenceChainBlocksUpdates(ctx context.Context, viewerID int64, call domain.GroupCall, subChainID int, blocks [][]byte, nextOffset int) *tg.Updates {
|
||||
return &tg.Updates{
|
||||
Updates: []tg.UpdateClass{conferenceChainBlocksUpdate(call, subChainID, blocks, nextOffset)},
|
||||
Users: r.tgUsersForIDs(ctx, viewerID, []int64{viewerID}),
|
||||
Date: int(r.clock.Now().Unix()),
|
||||
Seq: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func conferenceChainBlocksUpdate(call domain.GroupCall, subChainID int, blocks [][]byte, nextOffset int) *tg.UpdateGroupCallChainBlocks {
|
||||
return &tg.UpdateGroupCallChainBlocks{
|
||||
Call: &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash},
|
||||
SubChainID: subChainID,
|
||||
Blocks: conferenceServerBlocks(subChainID, blocks),
|
||||
NextOffset: nextOffset,
|
||||
}
|
||||
}
|
||||
|
||||
func conferenceServerBlocks(subChainID int, blocks [][]byte) [][]byte {
|
||||
out := make([][]byte, 0, len(blocks))
|
||||
for _, block := range blocks {
|
||||
out = append(out, conferenceServerBlock(subChainID, block))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func conferenceServerBlock(subChainID int, block []byte) []byte {
|
||||
out := append([]byte(nil), block...)
|
||||
if len(out) < 4 {
|
||||
return out
|
||||
}
|
||||
constructor := binary.LittleEndian.Uint32(out[:4])
|
||||
switch subChainID {
|
||||
case 0:
|
||||
if constructor == conferenceChainBlockConstructor {
|
||||
binary.LittleEndian.PutUint32(out[:4], conferenceChainBlockServerConstructor)
|
||||
}
|
||||
case 1:
|
||||
switch constructor {
|
||||
case conferenceBroadcastCommitConstructor:
|
||||
binary.LittleEndian.PutUint32(out[:4], conferenceBroadcastCommitServerConstructor)
|
||||
case conferenceBroadcastRevealConstructor:
|
||||
binary.LittleEndian.PutUint32(out[:4], conferenceBroadcastRevealServerConstructor)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func appendUpdates(dst *tg.Updates, src tg.UpdatesClass) {
|
||||
if dst == nil || src == nil {
|
||||
return
|
||||
}
|
||||
switch v := src.(type) {
|
||||
case *tg.Updates:
|
||||
dst.Updates = append(dst.Updates, v.Updates...)
|
||||
dst.Users = append(dst.Users, v.Users...)
|
||||
dst.Chats = append(dst.Chats, v.Chats...)
|
||||
case *tg.UpdatesCombined:
|
||||
dst.Updates = append(dst.Updates, v.Updates...)
|
||||
dst.Users = append(dst.Users, v.Users...)
|
||||
dst.Chats = append(dst.Chats, v.Chats...)
|
||||
}
|
||||
}
|
||||
|
||||
func conferenceInviteRandomID(callID, targetID int64, date int) int64 {
|
||||
id := int64(0x636f6e6663616c) // "confcal"
|
||||
id ^= callID << 11
|
||||
id ^= targetID << 3
|
||||
id ^= int64(date) << 29
|
||||
if id == 0 {
|
||||
return 0x636f6e66
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func authKeyIDFromCtx(ctx context.Context) [8]byte {
|
||||
if authKeyID, ok := RawAuthKeyIDFrom(ctx); ok {
|
||||
return authKeyID
|
||||
}
|
||||
return [8]byte{}
|
||||
}
|
||||
|
||||
func sessionIDFromCtx(ctx context.Context) int64 {
|
||||
if sessionID, ok := SessionIDFrom(ctx); ok {
|
||||
return sessionID
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (r *Router) logConferenceMessageFailure(callID int64, err error) {
|
||||
if err != nil {
|
||||
r.log.Warn("conference message", zap.Int64("call_id", callID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
890
internal/rpc/phone_conference_rpc_test.go
Normal file
890
internal/rpc/phone_conference_rpc_test.go
Normal file
|
|
@ -0,0 +1,890 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gotd/td/clock"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/gotd/td/tgerr"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
appgroupcalls "telesrv/internal/app/groupcalls"
|
||||
appmessages "telesrv/internal/app/messages"
|
||||
appphone "telesrv/internal/app/phone"
|
||||
appusers "telesrv/internal/app/users"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type conferenceFixture struct {
|
||||
ctx context.Context
|
||||
router *Router
|
||||
group *appgroupcalls.Service
|
||||
messages *appmessages.Service
|
||||
sessions *groupCallSessions
|
||||
alice domain.User
|
||||
bob domain.User
|
||||
carol domain.User
|
||||
clock *phoneTestClock
|
||||
}
|
||||
|
||||
func newConferenceFixture(t *testing.T) *conferenceFixture {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
dialogs := memory.NewDialogStore()
|
||||
messageStore := memory.NewMessageStore(dialogs)
|
||||
groupStore := memory.NewGroupCallStore()
|
||||
sessions := &groupCallSessions{}
|
||||
clk := &phoneTestClock{now: time.Unix(1_700_000_000, 0)}
|
||||
groupSvc := appgroupcalls.NewService(groupStore)
|
||||
messageSvc := appmessages.NewService(messageStore, dialogs)
|
||||
router := New(Config{GroupCallMaxParticipants: 8}, Deps{
|
||||
Users: appusers.NewService(users),
|
||||
Messages: messageSvc,
|
||||
GroupCalls: groupSvc,
|
||||
Phone: appphone.NewService(appphone.Config{}, appphone.WithClock(clk)),
|
||||
Sessions: sessions,
|
||||
}, zaptest.NewLogger(t), clk)
|
||||
mk := func(hash int64, phone, name string) domain.User {
|
||||
u, err := users.Create(ctx, domain.User{AccessHash: hash, Phone: phone, FirstName: name})
|
||||
if err != nil {
|
||||
t.Fatalf("create user %s: %v", name, err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
f := &conferenceFixture{ctx: ctx, router: router, group: groupSvc, messages: messageSvc, sessions: sessions, clock: clk}
|
||||
f.alice = mk(3001, "13700000001", "Alice")
|
||||
f.bob = mk(3002, "13700000002", "Bob")
|
||||
f.carol = mk(3003, "13700000003", "Carol")
|
||||
f.sessions.online = []int64{f.alice.ID, f.bob.ID, f.carol.ID}
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *conferenceFixture) userCtx(u domain.User, session int64) context.Context {
|
||||
return WithSessionID(WithUserID(f.ctx, u.ID), session)
|
||||
}
|
||||
|
||||
func joinConferenceForTest(t *testing.T, f *conferenceFixture, ctx context.Context, call tg.InputGroupCallClass, blockSuffix string, ssrc int32) {
|
||||
t.Helper()
|
||||
block := conferenceTestBlock(conferenceChainBlockConstructor, blockSuffix)
|
||||
req := &tg.PhoneJoinGroupCallRequest{
|
||||
Call: call,
|
||||
JoinAs: &tg.InputPeerSelf{},
|
||||
Params: groupCallJoinParams(t, ssrc),
|
||||
}
|
||||
req.SetBlock(block)
|
||||
if _, err := f.router.onPhoneJoinGroupCall(ctx, req); err != nil {
|
||||
t.Fatalf("join conference %s: %v", blockSuffix, err)
|
||||
}
|
||||
}
|
||||
|
||||
func optionalUpdate[T tg.UpdateClass](updates tg.UpdatesClass) (T, bool) {
|
||||
box, ok := updates.(*tg.Updates)
|
||||
if !ok {
|
||||
var zero T
|
||||
return zero, false
|
||||
}
|
||||
for _, u := range box.Updates {
|
||||
if v, ok := u.(T); ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
var zero T
|
||||
return zero, false
|
||||
}
|
||||
|
||||
func pushedDiscardedGroupCallUsers(records []phonePushRecord, callID int64) map[int64]bool {
|
||||
seen := map[int64]bool{}
|
||||
for _, rec := range records {
|
||||
box, ok := rec.msg.(*tg.Updates)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, raw := range box.Updates {
|
||||
update, ok := raw.(*tg.UpdateGroupCall)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
discarded, ok := update.Call.(*tg.GroupCallDiscarded)
|
||||
if ok && discarded.ID == callID {
|
||||
seen[rec.userID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return seen
|
||||
}
|
||||
|
||||
func TestConferenceCreateLinkAndGetBySlug(t *testing.T) {
|
||||
f := newConferenceFixture(t)
|
||||
ctx := f.userCtx(f.alice, 11)
|
||||
res, err := f.router.onPhoneCreateConferenceCall(ctx, &tg.PhoneCreateConferenceCallRequest{RandomID: 42})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference: %v", err)
|
||||
}
|
||||
update := findUpdate[*tg.UpdateGroupCall](t, res)
|
||||
call, ok := update.Call.(*tg.GroupCall)
|
||||
if !ok || !call.Conference || call.InviteLink == "" || !strings.Contains(call.InviteLink, "slug=") || !strings.HasPrefix(call.InviteLink, "https://telesrv.net/call/") {
|
||||
t.Fatalf("created call = %#v", update.Call)
|
||||
}
|
||||
slug := conferenceSlugFromLink(t, call.InviteLink)
|
||||
got, err := f.router.onPhoneGetGroupCall(ctx, &tg.PhoneGetGroupCallRequest{
|
||||
Call: &tg.InputGroupCallSlug{Slug: slug},
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get by slug: %v", err)
|
||||
}
|
||||
if got.Call.(*tg.GroupCall).ID != call.ID {
|
||||
t.Fatalf("get by slug id = %d, want %d", got.Call.(*tg.GroupCall).ID, call.ID)
|
||||
}
|
||||
again, err := f.router.onPhoneCreateConferenceCall(ctx, &tg.PhoneCreateConferenceCallRequest{RandomID: 42})
|
||||
if err != nil {
|
||||
t.Fatalf("repeat create conference: %v", err)
|
||||
}
|
||||
if findUpdate[*tg.UpdateGroupCall](t, again).Call.(*tg.GroupCall).ID != call.ID {
|
||||
t.Fatalf("random_id must be idempotent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConferenceExportGroupCallInviteReturnsConferenceLink(t *testing.T) {
|
||||
f := newConferenceFixture(t)
|
||||
ctx := f.userCtx(f.alice, 11)
|
||||
create, err := f.router.onPhoneCreateConferenceCall(ctx, &tg.PhoneCreateConferenceCallRequest{RandomID: 43})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference: %v", err)
|
||||
}
|
||||
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||
exported, err := f.router.onPhoneExportGroupCallInvite(ctx, &tg.PhoneExportGroupCallInviteRequest{
|
||||
Call: &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("export invite: %v", err)
|
||||
}
|
||||
if exported.Link != call.InviteLink || !strings.Contains(exported.Link, "slug=") {
|
||||
t.Fatalf("exported link = %q, want conference invite link %q", exported.Link, call.InviteLink)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConferenceExportGroupCallInviteReturnsPathSlug(t *testing.T) {
|
||||
f := newConferenceFixture(t)
|
||||
ctx := WithClientInfo(f.userCtx(f.alice, 11), ClientInfo{Type: ClientTypeAndroid, AppVersion: "12.8.1"})
|
||||
create, err := f.router.onPhoneCreateConferenceCall(ctx, &tg.PhoneCreateConferenceCallRequest{RandomID: 44})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference: %v", err)
|
||||
}
|
||||
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||
slug := conferenceSlugFromLink(t, call.InviteLink)
|
||||
exported, err := f.router.onPhoneExportGroupCallInvite(ctx, &tg.PhoneExportGroupCallInviteRequest{
|
||||
Call: &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("export invite: %v", err)
|
||||
}
|
||||
if exported.Link != call.InviteLink {
|
||||
t.Fatalf("exported link = %q, want stored canonical invite link %q", exported.Link, call.InviteLink)
|
||||
}
|
||||
if got := lastPathSegmentFromLink(t, exported.Link); got != slug {
|
||||
t.Fatalf("export path slug = %q, want %q (link %q)", got, slug, exported.Link)
|
||||
}
|
||||
if got := conferenceSlugFromLink(t, exported.Link); got != slug {
|
||||
t.Fatalf("export query slug = %q, want %q (link %q)", got, slug, exported.Link)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConferenceLinksNormalizeLegacyTMeLink(t *testing.T) {
|
||||
const slug = "legacy_slug-1"
|
||||
legacy := domain.GroupCall{
|
||||
ID: 1,
|
||||
AccessHash: 2,
|
||||
Kind: domain.GroupCallKindConference,
|
||||
State: domain.GroupCallStateActive,
|
||||
Version: 1,
|
||||
InviteSlug: slug,
|
||||
InviteLink: "https://t.me/call?slug=" + slug,
|
||||
}
|
||||
want := "https://telesrv.net/call/" + slug + "?slug=" + slug
|
||||
if got := conferenceExportInviteLink(legacy); got != want {
|
||||
t.Fatalf("export link = %q, want %q", got, want)
|
||||
}
|
||||
call := tgGroupCall(legacy, 0, false).(*tg.GroupCall)
|
||||
if call.InviteLink != want {
|
||||
t.Fatalf("tg group call invite link = %q, want %q", call.InviteLink, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConferenceJoinBroadcastAndGetChainBlocks(t *testing.T) {
|
||||
f := newConferenceFixture(t)
|
||||
ctx := f.userCtx(f.alice, 11)
|
||||
create, err := f.router.onPhoneCreateConferenceCall(ctx, &tg.PhoneCreateConferenceCallRequest{RandomID: 77})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference: %v", err)
|
||||
}
|
||||
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||
join, err := f.router.onPhoneJoinGroupCall(ctx, &tg.PhoneJoinGroupCallRequest{
|
||||
Call: input,
|
||||
JoinAs: &tg.InputPeerSelf{},
|
||||
Params: groupCallJoinParams(t, 9001),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("join conference: %v", err)
|
||||
}
|
||||
findUpdate[*tg.UpdateGroupCallConnection](t, join)
|
||||
participants := findUpdate[*tg.UpdateGroupCallParticipants](t, join)
|
||||
if len(participants.Participants) != 1 || !participants.Participants[0].Self {
|
||||
t.Fatalf("participants update = %+v", participants.Participants)
|
||||
}
|
||||
block := conferenceTestBlock(conferenceBroadcastCommitConstructor, "opaque-chain-block")
|
||||
broadcast, err := f.router.onPhoneSendConferenceCallBroadcast(ctx, &tg.PhoneSendConferenceCallBroadcastRequest{
|
||||
Call: input,
|
||||
Block: block,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("broadcast: %v", err)
|
||||
}
|
||||
chain := findUpdate[*tg.UpdateGroupCallChainBlocks](t, broadcast)
|
||||
if chain.SubChainID != 1 || chain.NextOffset != 1 || len(chain.Blocks) != 1 || conferenceTestConstructor(chain.Blocks[0]) != conferenceBroadcastCommitServerConstructor {
|
||||
t.Fatalf("chain update = %+v", chain)
|
||||
}
|
||||
list, err := f.router.onPhoneGetGroupCallChainBlocks(ctx, &tg.PhoneGetGroupCallChainBlocksRequest{
|
||||
Call: input, SubChainID: 1, Offset: 0, Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get chain blocks: %v", err)
|
||||
}
|
||||
got := findUpdate[*tg.UpdateGroupCallChainBlocks](t, list)
|
||||
if got.SubChainID != 1 || len(got.Blocks) != 1 || conferenceTestConstructor(got.Blocks[0]) != conferenceBroadcastCommitServerConstructor {
|
||||
t.Fatalf("get chain blocks = %+v", got)
|
||||
}
|
||||
nextBlock := conferenceTestBlock(conferenceBroadcastRevealConstructor, "opaque-chain-block-2")
|
||||
nextBroadcast, err := f.router.onPhoneSendConferenceCallBroadcast(ctx, &tg.PhoneSendConferenceCallBroadcastRequest{
|
||||
Call: input,
|
||||
Block: nextBlock,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second broadcast: %v", err)
|
||||
}
|
||||
nextChain := findUpdate[*tg.UpdateGroupCallChainBlocks](t, nextBroadcast)
|
||||
if nextChain.SubChainID != 1 || nextChain.NextOffset != 2 || len(nextChain.Blocks) != 1 || conferenceTestConstructor(nextChain.Blocks[0]) != conferenceBroadcastRevealServerConstructor {
|
||||
t.Fatalf("second chain update = %+v", nextChain)
|
||||
}
|
||||
latest, err := f.router.onPhoneGetGroupCallChainBlocks(ctx, &tg.PhoneGetGroupCallChainBlocksRequest{
|
||||
Call: input, SubChainID: 1, Offset: domain.GroupCallChainBlockLatestOffset, Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get latest chain block: %v", err)
|
||||
}
|
||||
gotLatest := findUpdate[*tg.UpdateGroupCallChainBlocks](t, latest)
|
||||
if gotLatest.SubChainID != 1 || gotLatest.NextOffset != 2 || len(gotLatest.Blocks) != 1 || conferenceTestConstructor(gotLatest.Blocks[0]) != conferenceBroadcastRevealServerConstructor {
|
||||
t.Fatalf("latest chain block = %+v", gotLatest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConferenceBroadcastRequiresActiveParticipant(t *testing.T) {
|
||||
f := newConferenceFixture(t)
|
||||
aliceCtx := f.userCtx(f.alice, 11)
|
||||
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 770})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference: %v", err)
|
||||
}
|
||||
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||
|
||||
f.sessions.reset()
|
||||
block := conferenceTestBlock(conferenceBroadcastCommitConstructor, "creator-not-joined-broadcast")
|
||||
res, err := f.router.onPhoneSendConferenceCallBroadcast(aliceCtx, &tg.PhoneSendConferenceCallBroadcastRequest{
|
||||
Call: input,
|
||||
Block: block,
|
||||
})
|
||||
if res != nil || !tgerr.Is(err, "GROUPCALL_JOIN_MISSING") {
|
||||
t.Fatalf("broadcast before join = %+v err=%v, want GROUPCALL_JOIN_MISSING", res, err)
|
||||
}
|
||||
if got := f.sessions.records(); len(got) != 0 {
|
||||
t.Fatalf("broadcast before join must not push updates, got %+v", got)
|
||||
}
|
||||
list, err := f.router.onPhoneGetGroupCallChainBlocks(aliceCtx, &tg.PhoneGetGroupCallChainBlocksRequest{
|
||||
Call: input, SubChainID: 1, Offset: 0, Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get chain blocks: %v", err)
|
||||
}
|
||||
chain := findUpdate[*tg.UpdateGroupCallChainBlocks](t, list)
|
||||
if chain.NextOffset != 0 || len(chain.Blocks) != 0 {
|
||||
t.Fatalf("chain after forbidden broadcast = %+v, want empty", chain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConferenceJoinBlockSeedsChainAndDuplicateJoinIsRejected(t *testing.T) {
|
||||
f := newConferenceFixture(t)
|
||||
aliceCtx := f.userCtx(f.alice, 11)
|
||||
bobCtx := f.userCtx(f.bob, 22)
|
||||
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 78})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference: %v", err)
|
||||
}
|
||||
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||
joinBlock := conferenceTestBlock(conferenceChainBlockConstructor, "join-chain-block")
|
||||
joinReq := &tg.PhoneJoinGroupCallRequest{
|
||||
Call: input,
|
||||
JoinAs: &tg.InputPeerSelf{},
|
||||
Params: groupCallJoinParams(t, 9002),
|
||||
}
|
||||
joinReq.SetBlock(joinBlock)
|
||||
join, err := f.router.onPhoneJoinGroupCall(aliceCtx, joinReq)
|
||||
if err != nil {
|
||||
t.Fatalf("join conference: %v", err)
|
||||
}
|
||||
findUpdate[*tg.UpdateGroupCallConnection](t, join)
|
||||
joinChain := findUpdate[*tg.UpdateGroupCallChainBlocks](t, join)
|
||||
if joinChain.SubChainID != 0 || joinChain.NextOffset != 1 || len(joinChain.Blocks) != 1 || conferenceTestConstructor(joinChain.Blocks[0]) != conferenceChainBlockServerConstructor {
|
||||
t.Fatalf("join chain update = %+v", joinChain)
|
||||
}
|
||||
|
||||
dupJoinReq := &tg.PhoneJoinGroupCallRequest{
|
||||
Call: &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)},
|
||||
JoinAs: &tg.InputPeerSelf{},
|
||||
Params: groupCallJoinParams(t, 9003),
|
||||
}
|
||||
dupJoinReq.SetBlock(append([]byte(nil), joinBlock...))
|
||||
dupJoin, err := f.router.onPhoneJoinGroupCall(bobCtx, dupJoinReq)
|
||||
if dupJoin != nil || !tgerr.Is(err, "CONF_WRITE_CHAIN_INVALID") {
|
||||
t.Fatalf("duplicate join = %+v err=%v, want CONF_WRITE_CHAIN_INVALID", dupJoin, err)
|
||||
}
|
||||
list, err := f.router.onPhoneGetGroupCallChainBlocks(aliceCtx, &tg.PhoneGetGroupCallChainBlocksRequest{
|
||||
Call: input, Offset: 0, Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get chain blocks: %v", err)
|
||||
}
|
||||
got := findUpdate[*tg.UpdateGroupCallChainBlocks](t, list)
|
||||
if got.NextOffset != 1 || len(got.Blocks) != 1 || conferenceTestConstructor(got.Blocks[0]) != conferenceChainBlockServerConstructor {
|
||||
t.Fatalf("get chain blocks after duplicate = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConferenceJoinReturnsSubmittedBlockOnly(t *testing.T) {
|
||||
f := newConferenceFixture(t)
|
||||
aliceCtx := f.userCtx(f.alice, 11)
|
||||
bobCtx := f.userCtx(f.bob, 22)
|
||||
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 790})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference: %v", err)
|
||||
}
|
||||
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||
aliceBlock := conferenceTestBlock(conferenceChainBlockConstructor, "alice-join-chain-block")
|
||||
aliceJoin := &tg.PhoneJoinGroupCallRequest{
|
||||
Call: input,
|
||||
JoinAs: &tg.InputPeerSelf{},
|
||||
Params: groupCallJoinParams(t, 9031),
|
||||
}
|
||||
aliceJoin.SetBlock(aliceBlock)
|
||||
if _, err := f.router.onPhoneJoinGroupCall(aliceCtx, aliceJoin); err != nil {
|
||||
t.Fatalf("alice join: %v", err)
|
||||
}
|
||||
|
||||
bobBlock := conferenceTestBlock(conferenceChainBlockConstructor, "bob-join-chain-block")
|
||||
bobJoin := &tg.PhoneJoinGroupCallRequest{
|
||||
Call: &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)},
|
||||
JoinAs: &tg.InputPeerSelf{},
|
||||
Params: groupCallJoinParams(t, 9032),
|
||||
}
|
||||
bobJoin.SetBlock(bobBlock)
|
||||
join, err := f.router.onPhoneJoinGroupCall(bobCtx, bobJoin)
|
||||
if err != nil {
|
||||
t.Fatalf("bob join: %v", err)
|
||||
}
|
||||
joinChain := findUpdate[*tg.UpdateGroupCallChainBlocks](t, join)
|
||||
if joinChain.SubChainID != 0 || joinChain.NextOffset != 2 || len(joinChain.Blocks) != 1 {
|
||||
t.Fatalf("bob join chain update = %+v, want only submitted block at next_offset=2", joinChain)
|
||||
}
|
||||
if conferenceTestConstructor(joinChain.Blocks[0]) != conferenceChainBlockServerConstructor || string(joinChain.Blocks[0][4:]) != string(bobBlock[4:]) {
|
||||
t.Fatalf("bob join block = %x, want server-form submitted block %x", joinChain.Blocks[0], bobBlock)
|
||||
}
|
||||
list, err := f.router.onPhoneGetGroupCallChainBlocks(aliceCtx, &tg.PhoneGetGroupCallChainBlocksRequest{
|
||||
Call: input, Offset: 0, Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get chain blocks: %v", err)
|
||||
}
|
||||
got := findUpdate[*tg.UpdateGroupCallChainBlocks](t, list)
|
||||
if got.NextOffset != 2 || len(got.Blocks) != 2 {
|
||||
t.Fatalf("persisted chain blocks = %+v, want full history through getGroupCallChainBlocks", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConferenceDuplicateJoinBlockDoesNotLeaveParticipantActive(t *testing.T) {
|
||||
f := newConferenceFixture(t)
|
||||
aliceCtx := f.userCtx(f.alice, 11)
|
||||
bobCtx := f.userCtx(f.bob, 22)
|
||||
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 79})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference: %v", err)
|
||||
}
|
||||
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||
joinBlock := conferenceTestBlock(conferenceChainBlockConstructor, "alice-join-chain-block")
|
||||
aliceJoin := &tg.PhoneJoinGroupCallRequest{
|
||||
Call: input,
|
||||
JoinAs: &tg.InputPeerSelf{},
|
||||
Params: groupCallJoinParams(t, 9011),
|
||||
}
|
||||
aliceJoin.SetBlock(joinBlock)
|
||||
if _, err := f.router.onPhoneJoinGroupCall(aliceCtx, aliceJoin); err != nil {
|
||||
t.Fatalf("alice join: %v", err)
|
||||
}
|
||||
|
||||
bobJoin := &tg.PhoneJoinGroupCallRequest{
|
||||
Call: &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)},
|
||||
JoinAs: &tg.InputPeerSelf{},
|
||||
Params: groupCallJoinParams(t, 9022),
|
||||
}
|
||||
bobJoin.SetBlock(append([]byte(nil), joinBlock...))
|
||||
if res, err := f.router.onPhoneJoinGroupCall(bobCtx, bobJoin); res != nil || !tgerr.Is(err, "CONF_WRITE_CHAIN_INVALID") {
|
||||
t.Fatalf("bob duplicate join = %+v err=%v, want CONF_WRITE_CHAIN_INVALID", res, err)
|
||||
}
|
||||
if p, found, err := f.group.Participant(f.ctx, call.ID, f.bob.ID); err != nil || (found && !p.Left) {
|
||||
t.Fatalf("bob participant after duplicate join = %+v found=%v err=%v, want absent or left", p, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConferenceDeleteParticipantsReturnsSubmittedBlock(t *testing.T) {
|
||||
f := newConferenceFixture(t)
|
||||
aliceCtx := f.userCtx(f.alice, 11)
|
||||
bobCtx := f.userCtx(f.bob, 22)
|
||||
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 791})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference: %v", err)
|
||||
}
|
||||
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||
aliceBlock := conferenceTestBlock(conferenceChainBlockConstructor, "alice-delete-seed-block")
|
||||
aliceJoin := &tg.PhoneJoinGroupCallRequest{Call: input, JoinAs: &tg.InputPeerSelf{}, Params: groupCallJoinParams(t, 9041)}
|
||||
aliceJoin.SetBlock(aliceBlock)
|
||||
if _, err := f.router.onPhoneJoinGroupCall(aliceCtx, aliceJoin); err != nil {
|
||||
t.Fatalf("alice join: %v", err)
|
||||
}
|
||||
bobBlock := conferenceTestBlock(conferenceChainBlockConstructor, "bob-delete-seed-block")
|
||||
bobJoin := &tg.PhoneJoinGroupCallRequest{Call: &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, JoinAs: &tg.InputPeerSelf{}, Params: groupCallJoinParams(t, 9042)}
|
||||
bobJoin.SetBlock(bobBlock)
|
||||
if _, err := f.router.onPhoneJoinGroupCall(bobCtx, bobJoin); err != nil {
|
||||
t.Fatalf("bob join: %v", err)
|
||||
}
|
||||
|
||||
removeBlock := conferenceTestBlock(conferenceChainBlockConstructor, "alice-remove-bob-block")
|
||||
res, err := f.router.onPhoneDeleteConferenceCallParticipants(aliceCtx, &tg.PhoneDeleteConferenceCallParticipantsRequest{
|
||||
Call: input,
|
||||
IDs: []int64{f.bob.ID},
|
||||
Kick: true,
|
||||
Block: removeBlock,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("delete conference participants: %v", err)
|
||||
}
|
||||
chain := findUpdate[*tg.UpdateGroupCallChainBlocks](t, res)
|
||||
if chain.SubChainID != 0 || chain.NextOffset != 3 || len(chain.Blocks) != 1 || conferenceTestConstructor(chain.Blocks[0]) != conferenceChainBlockServerConstructor {
|
||||
t.Fatalf("delete participants chain update = %+v", chain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConferenceOnlyLeftRemoveIsIdempotentAndAllowedForParticipant(t *testing.T) {
|
||||
f := newConferenceFixture(t)
|
||||
aliceCtx := f.userCtx(f.alice, 11)
|
||||
bobCtx := f.userCtx(f.bob, 22)
|
||||
carolCtx := f.userCtx(f.carol, 33)
|
||||
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 792})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference: %v", err)
|
||||
}
|
||||
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||
joinConferenceForTest(t, f, aliceCtx, input, "alice-only-left-join", 9051)
|
||||
joinConferenceForTest(t, f, bobCtx, &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, "bob-only-left-join", 9052)
|
||||
joinConferenceForTest(t, f, carolCtx, &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, "carol-only-left-join", 9053)
|
||||
if _, err := f.router.onPhoneLeaveGroupCall(carolCtx, &tg.PhoneLeaveGroupCallRequest{Call: input}); err != nil {
|
||||
t.Fatalf("carol leave: %v", err)
|
||||
}
|
||||
|
||||
aliceRemove := conferenceTestBlock(conferenceChainBlockConstructor, "alice-remove-left-carol")
|
||||
first, err := f.router.onPhoneDeleteConferenceCallParticipants(aliceCtx, &tg.PhoneDeleteConferenceCallParticipantsRequest{
|
||||
Call: input,
|
||||
IDs: []int64{f.carol.ID},
|
||||
OnlyLeft: true,
|
||||
Block: aliceRemove,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("alice only_left remove: %v", err)
|
||||
}
|
||||
firstChain := findUpdate[*tg.UpdateGroupCallChainBlocks](t, first)
|
||||
if firstChain.SubChainID != 0 || firstChain.NextOffset != 4 || len(firstChain.Blocks) != 1 {
|
||||
t.Fatalf("first only_left chain update = %+v", firstChain)
|
||||
}
|
||||
|
||||
bobRemove := conferenceTestBlock(conferenceChainBlockConstructor, "bob-stale-remove-left-carol")
|
||||
second, err := f.router.onPhoneDeleteConferenceCallParticipants(bobCtx, &tg.PhoneDeleteConferenceCallParticipantsRequest{
|
||||
Call: input,
|
||||
IDs: []int64{f.carol.ID},
|
||||
OnlyLeft: true,
|
||||
Block: bobRemove,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("bob duplicate only_left remove: %v", err)
|
||||
}
|
||||
if chain, ok := optionalUpdate[*tg.UpdateGroupCallChainBlocks](second); ok {
|
||||
t.Fatalf("duplicate only_left must not append stale chain block, got %+v", chain)
|
||||
}
|
||||
list, err := f.router.onPhoneGetGroupCallChainBlocks(aliceCtx, &tg.PhoneGetGroupCallChainBlocksRequest{
|
||||
Call: input, Offset: 0, Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get chain blocks: %v", err)
|
||||
}
|
||||
got := findUpdate[*tg.UpdateGroupCallChainBlocks](t, list)
|
||||
if got.NextOffset != 4 || len(got.Blocks) != 4 {
|
||||
t.Fatalf("chain after duplicate only_left = %+v, want exactly 3 joins + 1 remove", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConferenceForbiddenKickDoesNotAppendChainBlock(t *testing.T) {
|
||||
f := newConferenceFixture(t)
|
||||
aliceCtx := f.userCtx(f.alice, 11)
|
||||
bobCtx := f.userCtx(f.bob, 22)
|
||||
carolCtx := f.userCtx(f.carol, 33)
|
||||
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 793})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference: %v", err)
|
||||
}
|
||||
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||
joinConferenceForTest(t, f, aliceCtx, input, "alice-forbidden-kick-join", 9061)
|
||||
joinConferenceForTest(t, f, bobCtx, &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, "bob-forbidden-kick-join", 9062)
|
||||
joinConferenceForTest(t, f, carolCtx, &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, "carol-forbidden-kick-join", 9063)
|
||||
|
||||
staleBlock := conferenceTestBlock(conferenceChainBlockConstructor, "bob-forbidden-kick-carol")
|
||||
res, err := f.router.onPhoneDeleteConferenceCallParticipants(bobCtx, &tg.PhoneDeleteConferenceCallParticipantsRequest{
|
||||
Call: input,
|
||||
IDs: []int64{f.carol.ID},
|
||||
Kick: true,
|
||||
Block: staleBlock,
|
||||
})
|
||||
if res != nil || !tgerr.Is(err, "GROUPCALL_FORBIDDEN") {
|
||||
t.Fatalf("bob forbidden kick = %+v err=%v, want GROUPCALL_FORBIDDEN", res, err)
|
||||
}
|
||||
list, err := f.router.onPhoneGetGroupCallChainBlocks(aliceCtx, &tg.PhoneGetGroupCallChainBlocksRequest{
|
||||
Call: input, Offset: 0, Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get chain blocks: %v", err)
|
||||
}
|
||||
got := findUpdate[*tg.UpdateGroupCallChainBlocks](t, list)
|
||||
if got.NextOffset != 3 || len(got.Blocks) != 3 {
|
||||
t.Fatalf("chain after forbidden kick = %+v, want only join blocks", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConferenceLastLeaveDiscardsCall(t *testing.T) {
|
||||
f := newConferenceFixture(t)
|
||||
aliceCtx := f.userCtx(f.alice, 11)
|
||||
bobCtx := f.userCtx(f.bob, 22)
|
||||
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 794})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference: %v", err)
|
||||
}
|
||||
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||
joinConferenceForTest(t, f, aliceCtx, input, "alice-last-leave-join", 9071)
|
||||
joinConferenceForTest(t, f, bobCtx, &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, "bob-last-leave-join", 9072)
|
||||
|
||||
bobLeave, err := f.router.onPhoneLeaveGroupCall(bobCtx, &tg.PhoneLeaveGroupCallRequest{Call: input})
|
||||
if err != nil {
|
||||
t.Fatalf("bob leave: %v", err)
|
||||
}
|
||||
if update, ok := optionalUpdate[*tg.UpdateGroupCall](bobLeave); ok {
|
||||
if _, discarded := update.Call.(*tg.GroupCallDiscarded); discarded {
|
||||
t.Fatalf("first leave must keep conference active, got %+v", update.Call)
|
||||
}
|
||||
}
|
||||
aliceLeave, err := f.router.onPhoneLeaveGroupCall(aliceCtx, &tg.PhoneLeaveGroupCallRequest{Call: input})
|
||||
if err != nil {
|
||||
t.Fatalf("alice last leave: %v", err)
|
||||
}
|
||||
discardUpdate := findUpdate[*tg.UpdateGroupCall](t, aliceLeave)
|
||||
if discarded, ok := discardUpdate.Call.(*tg.GroupCallDiscarded); !ok || discarded.ID != call.ID {
|
||||
t.Fatalf("last leave update = %+v, want groupCallDiscarded %d", discardUpdate.Call, call.ID)
|
||||
}
|
||||
_, err = f.router.onPhoneJoinGroupCall(bobCtx, &tg.PhoneJoinGroupCallRequest{
|
||||
Call: input,
|
||||
JoinAs: &tg.InputPeerSelf{},
|
||||
Params: groupCallJoinParams(t, 9073),
|
||||
})
|
||||
if !tgerr.Is(err, "GROUPCALL_ALREADY_DISCARDED") {
|
||||
t.Fatalf("join after last leave err = %v, want GROUPCALL_ALREADY_DISCARDED", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConferenceDiscardRequiresCreator(t *testing.T) {
|
||||
f := newConferenceFixture(t)
|
||||
aliceCtx := f.userCtx(f.alice, 11)
|
||||
bobCtx := f.userCtx(f.bob, 22)
|
||||
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 794})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference: %v", err)
|
||||
}
|
||||
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||
joinConferenceForTest(t, f, aliceCtx, input, "alice-discard-permission-join", 9071)
|
||||
joinConferenceForTest(t, f, bobCtx, &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, "bob-discard-permission-join", 9072)
|
||||
|
||||
f.sessions.reset()
|
||||
res, err := f.router.onPhoneDiscardGroupCall(bobCtx, input)
|
||||
if res != nil || !tgerr.Is(err, "CHAT_ADMIN_REQUIRED") {
|
||||
t.Fatalf("bob discard = %+v err=%v, want CHAT_ADMIN_REQUIRED", res, err)
|
||||
}
|
||||
if got := f.sessions.records(); len(got) != 0 {
|
||||
t.Fatalf("forbidden discard must not push updates, got %+v", got)
|
||||
}
|
||||
got, err := f.router.onPhoneGetGroupCall(aliceCtx, &tg.PhoneGetGroupCallRequest{Call: input, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("get group call after forbidden discard: %v", err)
|
||||
}
|
||||
if _, ok := got.Call.(*tg.GroupCall); !ok {
|
||||
t.Fatalf("call after forbidden discard = %T, want active GroupCall", got.Call)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConferenceDiscardFanoutIncludesSlugJoinedParticipants(t *testing.T) {
|
||||
f := newConferenceFixture(t)
|
||||
aliceCtx := f.userCtx(f.alice, 11)
|
||||
bobCtx := f.userCtx(f.bob, 22)
|
||||
carolCtx := f.userCtx(f.carol, 33)
|
||||
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 795})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference: %v", err)
|
||||
}
|
||||
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||
joinConferenceForTest(t, f, aliceCtx, input, "alice-discard-fanout-join", 9081)
|
||||
joinConferenceForTest(t, f, bobCtx, &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, "bob-discard-fanout-join", 9082)
|
||||
joinConferenceForTest(t, f, carolCtx, &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, "carol-discard-fanout-join", 9083)
|
||||
|
||||
f.sessions.reset()
|
||||
discard, err := f.router.onPhoneDiscardGroupCall(aliceCtx, input)
|
||||
if err != nil {
|
||||
t.Fatalf("discard conference: %v", err)
|
||||
}
|
||||
if _, ok := findUpdate[*tg.UpdateGroupCall](t, discard).Call.(*tg.GroupCallDiscarded); !ok {
|
||||
t.Fatalf("discard response missing groupCallDiscarded")
|
||||
}
|
||||
seen := pushedDiscardedGroupCallUsers(f.sessions.records(), call.ID)
|
||||
for _, user := range []domain.User{f.alice, f.bob, f.carol} {
|
||||
if !seen[user.ID] {
|
||||
t.Fatalf("discard fanout missing user %d, seen=%v records=%+v", user.ID, seen, f.sessions.records())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConferenceDiscardAllowsHistoricalParticipantCleanupRPCs(t *testing.T) {
|
||||
f := newConferenceFixture(t)
|
||||
aliceCtx := f.userCtx(f.alice, 11)
|
||||
bobCtx := f.userCtx(f.bob, 22)
|
||||
carolCtx := f.userCtx(f.carol, 33)
|
||||
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 796})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference: %v", err)
|
||||
}
|
||||
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||
slugCall := &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}
|
||||
joinConferenceForTest(t, f, aliceCtx, input, "alice-discard-cleanup-join", 9091)
|
||||
joinConferenceForTest(t, f, bobCtx, slugCall, "bob-discard-cleanup-join", 9092)
|
||||
joinConferenceForTest(t, f, carolCtx, slugCall, "carol-discard-cleanup-join", 9093)
|
||||
|
||||
if _, err := f.router.onPhoneDiscardGroupCall(aliceCtx, input); err != nil {
|
||||
t.Fatalf("discard conference: %v", err)
|
||||
}
|
||||
got, err := f.router.onPhoneGetGroupCall(bobCtx, &tg.PhoneGetGroupCallRequest{Call: input, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("bob get discarded group call: %v", err)
|
||||
}
|
||||
if _, ok := got.Call.(*tg.GroupCallDiscarded); !ok {
|
||||
t.Fatalf("bob call after discard = %T, want GroupCallDiscarded", got.Call)
|
||||
}
|
||||
ssrcs, err := f.router.onPhoneCheckGroupCall(bobCtx, &tg.PhoneCheckGroupCallRequest{
|
||||
Call: input,
|
||||
Sources: []int{9092},
|
||||
})
|
||||
if err != nil || len(ssrcs) != 0 {
|
||||
t.Fatalf("bob check discarded group call = %v err=%v, want empty no error", ssrcs, err)
|
||||
}
|
||||
chain, err := f.router.onPhoneGetGroupCallChainBlocks(bobCtx, &tg.PhoneGetGroupCallChainBlocksRequest{
|
||||
Call: input,
|
||||
SubChainID: 0,
|
||||
Offset: 0,
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("bob get chain blocks after discard: %v", err)
|
||||
}
|
||||
if blocks := findUpdate[*tg.UpdateGroupCallChainBlocks](t, chain).Blocks; len(blocks) != 3 {
|
||||
t.Fatalf("bob cleanup chain blocks len=%d, want 3", len(blocks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConferenceInviteMessageResolvesInputGroupCallInviteMessage(t *testing.T) {
|
||||
f := newConferenceFixture(t)
|
||||
aliceCtx := f.userCtx(f.alice, 11)
|
||||
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 88})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference: %v", err)
|
||||
}
|
||||
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||
invite, err := f.router.onPhoneInviteConferenceCallParticipant(aliceCtx, &tg.PhoneInviteConferenceCallParticipantRequest{
|
||||
Call: input,
|
||||
UserID: &tg.InputUser{UserID: f.bob.ID, AccessHash: f.bob.AccessHash},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("invite conference: %v", err)
|
||||
}
|
||||
msg := findUpdate[*tg.UpdateNewMessage](t, invite).Message.(*tg.MessageService)
|
||||
if _, ok := msg.Action.(*tg.MessageActionConferenceCall); !ok {
|
||||
t.Fatalf("invite action = %T", msg.Action)
|
||||
}
|
||||
history, err := f.messages.GetHistory(f.ctx, f.bob.ID, domain.MessageFilter{
|
||||
HasPeer: true,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: f.alice.ID},
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil || len(history.Messages) != 1 {
|
||||
t.Fatalf("bob history len=%d err=%v", len(history.Messages), err)
|
||||
}
|
||||
bobMsgID := history.Messages[0].ID
|
||||
joinConferenceForTest(t, f, aliceCtx, input, "alice-invite-message-broadcast-join", 9091)
|
||||
got, err := f.router.onPhoneGetGroupCall(f.userCtx(f.bob, 22), &tg.PhoneGetGroupCallRequest{
|
||||
Call: &tg.InputGroupCallInviteMessage{MsgID: bobMsgID},
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get by invite message: %v", err)
|
||||
}
|
||||
if got.Call.(*tg.GroupCall).ID != call.ID {
|
||||
t.Fatalf("invite message call id = %d, want %d", got.Call.(*tg.GroupCall).ID, call.ID)
|
||||
}
|
||||
block := conferenceTestBlock(conferenceBroadcastCommitConstructor, "invite-message-chain-block")
|
||||
if _, err := f.router.onPhoneSendConferenceCallBroadcast(aliceCtx, &tg.PhoneSendConferenceCallBroadcastRequest{
|
||||
Call: input,
|
||||
Block: block,
|
||||
}); err != nil {
|
||||
t.Fatalf("broadcast invite message chain block: %v", err)
|
||||
}
|
||||
chain, err := f.router.onPhoneGetGroupCallChainBlocks(f.userCtx(f.bob, 22), &tg.PhoneGetGroupCallChainBlocksRequest{
|
||||
Call: &tg.InputGroupCallInviteMessage{MsgID: bobMsgID},
|
||||
SubChainID: 1,
|
||||
Offset: domain.GroupCallChainBlockLatestOffset,
|
||||
Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get latest by invite message: %v", err)
|
||||
}
|
||||
latest := findUpdate[*tg.UpdateGroupCallChainBlocks](t, chain)
|
||||
if latest.SubChainID != 1 || latest.NextOffset != 1 || len(latest.Blocks) != 1 || conferenceTestConstructor(latest.Blocks[0]) != conferenceBroadcastCommitServerConstructor {
|
||||
t.Fatalf("invite message latest block = %+v", latest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneDiscardMigrateConferenceCarriesSlug(t *testing.T) {
|
||||
f := newConferenceFixture(t)
|
||||
aliceCtx := f.userCtx(f.alice, 11)
|
||||
bobCtx := f.userCtx(f.bob, 22)
|
||||
ga, gaHash, gb := phoneTestKeys()
|
||||
requested, err := f.router.onPhoneRequestCall(aliceCtx, &tg.PhoneRequestCallRequest{
|
||||
UserID: &tg.InputUser{UserID: f.bob.ID, AccessHash: f.bob.AccessHash},
|
||||
RandomID: 9,
|
||||
GAHash: gaHash,
|
||||
Protocol: phoneTestProtocol(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("request call: %v", err)
|
||||
}
|
||||
peer := tg.InputPhoneCall{ID: requested.PhoneCall.(*tg.PhoneCallWaiting).ID, AccessHash: requested.PhoneCall.(*tg.PhoneCallWaiting).AccessHash}
|
||||
if _, err := f.router.onPhoneAcceptCall(bobCtx, &tg.PhoneAcceptCallRequest{Peer: peer, GB: gb, Protocol: phoneTestProtocol()}); err != nil {
|
||||
t.Fatalf("accept call: %v", err)
|
||||
}
|
||||
if _, err := f.router.onPhoneConfirmCall(aliceCtx, &tg.PhoneConfirmCallRequest{Peer: peer, GA: ga, KeyFingerprint: 123, Protocol: phoneTestProtocol()}); err != nil {
|
||||
t.Fatalf("confirm call: %v", err)
|
||||
}
|
||||
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 99})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference: %v", err)
|
||||
}
|
||||
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||
slug := conferenceSlugFromLink(t, call.InviteLink)
|
||||
updates, err := f.router.onPhoneDiscardCall(aliceCtx, &tg.PhoneDiscardCallRequest{
|
||||
Peer: peer,
|
||||
Duration: 1,
|
||||
Reason: &tg.PhoneCallDiscardReasonMigrateConferenceCall{Slug: slug},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("discard migrate: %v", err)
|
||||
}
|
||||
discarded := findUpdate[*tg.UpdatePhoneCall](t, updates).PhoneCall.(*tg.PhoneCallDiscarded)
|
||||
reason, ok := discarded.Reason.(*tg.PhoneCallDiscardReasonMigrateConferenceCall)
|
||||
if !ok || reason.Slug != slug {
|
||||
t.Fatalf("discard reason = %#v, want slug %q", discarded.Reason, slug)
|
||||
}
|
||||
}
|
||||
|
||||
var _ clock.Clock = (*phoneTestClock)(nil)
|
||||
|
||||
func conferenceSlugFromLink(t *testing.T, link string) string {
|
||||
t.Helper()
|
||||
u, err := url.Parse(link)
|
||||
if err == nil {
|
||||
if slug := u.Query().Get("slug"); slug != "" {
|
||||
return slug
|
||||
}
|
||||
}
|
||||
idx := strings.LastIndex(link, "slug=")
|
||||
if idx < 0 {
|
||||
t.Fatalf("link %q does not contain slug", link)
|
||||
}
|
||||
return link[idx+len("slug="):]
|
||||
}
|
||||
|
||||
func conferenceTestBlock(constructor uint32, suffix string) []byte {
|
||||
block := make([]byte, 4+len(suffix))
|
||||
binary.LittleEndian.PutUint32(block[:4], constructor)
|
||||
copy(block[4:], suffix)
|
||||
return block
|
||||
}
|
||||
|
||||
func conferenceTestConstructor(block []byte) uint32 {
|
||||
if len(block) < 4 {
|
||||
return 0
|
||||
}
|
||||
return binary.LittleEndian.Uint32(block[:4])
|
||||
}
|
||||
|
||||
func lastPathSegmentFromLink(t *testing.T, link string) string {
|
||||
t.Helper()
|
||||
u, err := url.Parse(link)
|
||||
if err != nil {
|
||||
t.Fatalf("parse link %q: %v", link, err)
|
||||
}
|
||||
segments := strings.Split(strings.Trim(u.EscapedPath(), "/"), "/")
|
||||
if len(segments) == 0 || segments[len(segments)-1] == "" {
|
||||
t.Fatalf("link %q has no path segment", link)
|
||||
}
|
||||
segment, err := url.PathUnescape(segments[len(segments)-1])
|
||||
if err != nil {
|
||||
t.Fatalf("decode path segment in %q: %v", link, err)
|
||||
}
|
||||
return segment
|
||||
}
|
||||
|
|
@ -27,6 +27,8 @@ func groupCallErr(err error) error {
|
|||
return groupCallSSRCDuplicateErr()
|
||||
case errors.Is(err, domain.ErrGroupCallNotJoined):
|
||||
return groupCallJoinMissingErr()
|
||||
case errors.Is(err, domain.ErrConferenceChainInvalid):
|
||||
return confWriteChainInvalidErr()
|
||||
default:
|
||||
return internalErr()
|
||||
}
|
||||
|
|
@ -41,30 +43,69 @@ type groupCallScope struct {
|
|||
}
|
||||
|
||||
func (s *groupCallScope) canManage() bool {
|
||||
if s.call.Conference() {
|
||||
return s.userID != 0 && s.userID == s.call.CreatorUserID
|
||||
}
|
||||
return channelMemberIsAdmin(s.member)
|
||||
}
|
||||
|
||||
// groupCallScopeFrom 解析 InputGroupCallClass(仅 id+access_hash 变体;slug/
|
||||
// inviteMessage 属 conference 路径,返回 GROUPCALL_INVALID)并校验成员资格。
|
||||
// groupCallScopeFrom 解析 InputGroupCallClass 并校验访问权。普通 group call 继续
|
||||
// 走频道成员资格;conference call 走 creator/participant/invite/slug 访问模型。
|
||||
func (r *Router) groupCallScopeFrom(ctx context.Context, in tg.InputGroupCallClass) (*groupCallScope, error) {
|
||||
if r.deps.GroupCalls == nil || r.deps.Channels == nil {
|
||||
if r.deps.GroupCalls == nil {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
userID, err := r.phoneRequireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
callID, accessHash, err := inputGroupCallRef(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
call, found, err := r.deps.GroupCalls.Get(ctx, callID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !found || call.AccessHash != accessHash {
|
||||
var call domain.GroupCall
|
||||
var found bool
|
||||
allowBySlug := false
|
||||
switch v := in.(type) {
|
||||
case *tg.InputGroupCall:
|
||||
call, found, err = r.deps.GroupCalls.Get(ctx, v.ID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !found || call.AccessHash != v.AccessHash {
|
||||
return nil, groupCallInvalidErr()
|
||||
}
|
||||
case *tg.InputGroupCallSlug:
|
||||
call, found, err = r.deps.GroupCalls.GetBySlug(ctx, v.Slug)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !found || !call.Conference() {
|
||||
return nil, groupCallInvalidErr()
|
||||
}
|
||||
allowBySlug = true
|
||||
case *tg.InputGroupCallInviteMessage:
|
||||
call, _, found, err = r.deps.GroupCalls.GetByInviteMessage(ctx, userID, v.MsgID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !found || !call.Conference() {
|
||||
return nil, groupCallInvalidErr()
|
||||
}
|
||||
default:
|
||||
return nil, groupCallInvalidErr()
|
||||
}
|
||||
if call.Conference() {
|
||||
if !allowBySlug {
|
||||
allowed, err := r.conferenceCallCanAccess(ctx, call.ID, userID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !allowed {
|
||||
return nil, groupCallForbiddenErr()
|
||||
}
|
||||
}
|
||||
return &groupCallScope{userID: userID, call: call}, nil
|
||||
}
|
||||
if r.deps.Channels == nil {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
view, err := r.deps.Channels.GetChannel(ctx, userID, call.ChannelID)
|
||||
if err != nil {
|
||||
return nil, groupCallForbiddenErr()
|
||||
|
|
@ -75,6 +116,19 @@ func (r *Router) groupCallScopeFrom(ctx context.Context, in tg.InputGroupCallCla
|
|||
return &groupCallScope{userID: userID, call: call, channel: view.Channel, member: view.Self}, nil
|
||||
}
|
||||
|
||||
func (r *Router) conferenceCallCanAccess(ctx context.Context, callID, userID int64) (bool, error) {
|
||||
recipients, err := r.deps.GroupCalls.ConferenceRecipients(ctx, callID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, id := range recipients {
|
||||
if id == userID {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (r *Router) onPhoneCreateGroupCall(ctx context.Context, req *tg.PhoneCreateGroupCallRequest) (tg.UpdatesClass, error) {
|
||||
if req == nil {
|
||||
return nil, inputRequestInvalidErr()
|
||||
|
|
@ -175,6 +229,14 @@ func (r *Router) onPhoneJoinGroupCall(ctx context.Context, req *tg.PhoneJoinGrou
|
|||
}
|
||||
}
|
||||
now := int(r.clock.Now().Unix())
|
||||
var publicKey []byte
|
||||
if pk, ok := req.GetPublicKey(); ok {
|
||||
publicKey = append([]byte(nil), pk[:]...)
|
||||
}
|
||||
var joinBlock []byte
|
||||
if block, ok := req.GetBlock(); ok {
|
||||
joinBlock = append([]byte(nil), block...)
|
||||
}
|
||||
// 视频内部状态:endpoint 服务端铸造(join 响应 video.endpoint 与日后
|
||||
// participant.video.endpoint 必须逐字节一致);ssrc-groups 无论摄像头开关都
|
||||
// 存档——video_stopped=false(join flag 或后续 self-edit)时原样回放。
|
||||
|
|
@ -190,6 +252,8 @@ func (r *Router) onPhoneJoinGroupCall(ctx context.Context, req *tg.PhoneJoinGrou
|
|||
SSRC: ssrc,
|
||||
Muted: req.Muted,
|
||||
IsAdmin: scope.canManage(),
|
||||
PublicKey: publicKey,
|
||||
JoinBlock: joinBlock,
|
||||
VideoJSON: encodeVideoState(videoState),
|
||||
Now: now,
|
||||
})
|
||||
|
|
@ -215,6 +279,25 @@ func (r *Router) onPhoneJoinGroupCall(ctx context.Context, req *tg.PhoneJoinGrou
|
|||
_, _ = r.deps.GroupCalls.Leave(ctx, scope.call.ID, scope.userID, now)
|
||||
return nil, internalErr()
|
||||
}
|
||||
var conferenceJoinBlock domain.GroupCallChainBlock
|
||||
var hasConferenceJoinBlock bool
|
||||
if scope.call.Conference() && len(joinBlock) > 0 {
|
||||
block, err := r.deps.GroupCalls.AppendChainBlock(ctx, domain.GroupCallChainBlock{
|
||||
CallID: scope.call.ID,
|
||||
SubChainID: 0,
|
||||
Offset: -1,
|
||||
AuthorUserID: scope.userID,
|
||||
Block: joinBlock,
|
||||
CreatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
_ = sfuService.Leave(ctx, scope.call.ID, scope.userID, sfu.EndpointMain)
|
||||
_, _ = r.deps.GroupCalls.Leave(ctx, scope.call.ID, scope.userID, now)
|
||||
return nil, groupCallErr(err)
|
||||
}
|
||||
conferenceJoinBlock = block
|
||||
hasConferenceJoinBlock = true
|
||||
}
|
||||
// 扇出给房间/在线群成员(操作者其它设备含其中;本设备从 RPC 返回拿)。
|
||||
channel := r.groupCallMutationFanout(ctx, scope.channel, mut)
|
||||
// 响应(TDesktop 从本 RPC 返回的 Updates 摘取 updateGroupCallConnection,不会等推送):
|
||||
|
|
@ -226,8 +309,16 @@ func (r *Router) onPhoneJoinGroupCall(ctx context.Context, req *tg.PhoneJoinGrou
|
|||
}, []int64{scope.userID})
|
||||
out.Updates = append(out.Updates, &tg.UpdateGroupCallConnection{Params: tg.DataJSON{Data: params}})
|
||||
callUpdate := &tg.UpdateGroupCall{Call: tgGroupCall(mut.Call, scope.userID, scope.canManage())}
|
||||
callUpdate.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
|
||||
if channel.ID != 0 {
|
||||
callUpdate.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
|
||||
}
|
||||
out.Updates = append(out.Updates, callUpdate)
|
||||
if hasConferenceJoinBlock {
|
||||
nextOffset := conferenceJoinBlock.Offset + 1
|
||||
blocks := [][]byte{conferenceJoinBlock.Block}
|
||||
r.pushConferenceChainBlocks(ctx, mut.Call, conferenceJoinBlock.SubChainID, blocks, nextOffset)
|
||||
out.Updates = append(out.Updates, conferenceChainBlocksUpdate(mut.Call, conferenceJoinBlock.SubChainID, blocks, nextOffset))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
|
@ -253,12 +344,16 @@ func (r *Router) onPhoneLeaveGroupCall(ctx context.Context, req *tg.PhoneLeaveGr
|
|||
_ = r.deps.SFU.Leave(ctx, scope.call.ID, scope.userID, sfu.EndpointMain)
|
||||
}
|
||||
channel := r.groupCallMutationFanout(ctx, scope.channel, mut)
|
||||
return r.groupCallUpdateContainer(ctx, scope.userID, channel,
|
||||
out := r.groupCallUpdateContainer(ctx, scope.userID, channel,
|
||||
&tg.UpdateGroupCallParticipants{
|
||||
Call: &tg.InputGroupCall{ID: mut.Call.ID, AccessHash: mut.Call.AccessHash},
|
||||
Participants: tgGroupCallParticipants([]domain.GroupCallParticipant{mut.Participant}, scope.userID),
|
||||
Version: mut.Call.Version,
|
||||
}, []int64{scope.userID}), nil
|
||||
}, []int64{scope.userID})
|
||||
if mut.Call.Conference() && !mut.Call.Active() {
|
||||
out.Updates = append(out.Updates, groupCallUpdateFor(domain.Channel{}, mut.Call, scope.userID, scope.userID == mut.Call.CreatorUserID))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Router) onPhoneDiscardGroupCall(ctx context.Context, in tg.InputGroupCallClass) (tg.UpdatesClass, error) {
|
||||
|
|
@ -270,13 +365,18 @@ func (r *Router) onPhoneDiscardGroupCall(ctx context.Context, in tg.InputGroupCa
|
|||
return nil, tgerr400("CHAT_ADMIN_REQUIRED")
|
||||
}
|
||||
now := int(r.clock.Now().Unix())
|
||||
call, _, err := r.deps.GroupCalls.Discard(ctx, scope.call.ID, now)
|
||||
call, activeBeforeDiscard, err := r.deps.GroupCalls.Discard(ctx, scope.call.ID, now)
|
||||
if err != nil {
|
||||
return nil, groupCallErr(err)
|
||||
}
|
||||
if r.deps.SFU != nil {
|
||||
_ = r.deps.SFU.CloseRoom(ctx, call.ID)
|
||||
}
|
||||
if call.Conference() {
|
||||
r.pushConferenceGroupCallUpdateTo(ctx, call, groupCallParticipantUserIDs(activeBeforeDiscard))
|
||||
return r.groupCallUpdateContainer(ctx, scope.userID, domain.Channel{},
|
||||
groupCallUpdateFor(domain.Channel{}, call, scope.userID, true), nil), nil
|
||||
}
|
||||
// 清 channel 关联 + ended 服务消息(带 duration)。
|
||||
channel := scope.channel
|
||||
if updated, err := r.deps.Channels.SetActiveCall(ctx, channel.ID, 0, 0, false); err == nil {
|
||||
|
|
@ -328,11 +428,15 @@ func (r *Router) onPhoneGetGroupCall(ctx context.Context, req *tg.PhoneGetGroupC
|
|||
for _, p := range page.Participants {
|
||||
userIDs = append(userIDs, p.UserID)
|
||||
}
|
||||
chats := []tg.ChatClass{}
|
||||
if !scope.call.Conference() {
|
||||
chats = append(chats, tgChannel(scope.userID, scope.channel, &scope.member))
|
||||
}
|
||||
return &tg.PhoneGroupCall{
|
||||
Call: tgGroupCall(scope.call, scope.userID, scope.canManage()),
|
||||
Participants: tgGroupCallParticipants(page.Participants, scope.userID),
|
||||
ParticipantsNextOffset: page.NextOffset,
|
||||
Chats: []tg.ChatClass{tgChannel(scope.userID, scope.channel, &scope.member)},
|
||||
Chats: chats,
|
||||
Users: r.tgUsersForIDs(ctx, scope.userID, userIDs),
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -358,11 +462,15 @@ func (r *Router) onPhoneGetGroupParticipants(ctx context.Context, req *tg.PhoneG
|
|||
userIDs = append(userIDs, p.UserID)
|
||||
}
|
||||
// 响应 version=当前值:客户端 version 跳号后据此重建本地状态并恢复增量应用。
|
||||
chats := []tg.ChatClass{}
|
||||
if !scope.call.Conference() {
|
||||
chats = append(chats, tgChannel(scope.userID, scope.channel, &scope.member))
|
||||
}
|
||||
return &tg.PhoneGroupParticipants{
|
||||
Count: page.Count,
|
||||
Participants: tgGroupCallParticipants(page.Participants, scope.userID),
|
||||
NextOffset: page.NextOffset,
|
||||
Chats: []tg.ChatClass{tgChannel(scope.userID, scope.channel, &scope.member)},
|
||||
Chats: chats,
|
||||
Users: r.tgUsersForIDs(ctx, scope.userID, userIDs),
|
||||
Version: page.Version,
|
||||
}, nil
|
||||
|
|
@ -417,6 +525,8 @@ func (r *Router) onPhoneCheckGroupCall(ctx context.Context, req *tg.PhoneCheckGr
|
|||
|
||||
func groupCallUpdateFor(channel domain.Channel, call domain.GroupCall, viewerUserID int64, canManage bool) *tg.UpdateGroupCall {
|
||||
update := &tg.UpdateGroupCall{Call: tgGroupCall(call, viewerUserID, canManage)}
|
||||
update.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
|
||||
if channel.ID != 0 {
|
||||
update.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
|
||||
}
|
||||
return update
|
||||
}
|
||||
|
|
|
|||
|
|
@ -276,6 +276,9 @@ func (r *Router) onPhoneInviteToGroupCall(ctx context.Context, req *tg.PhoneInvi
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if scope.call.Conference() {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
if len(req.Users) == 0 || len(req.Users) > maxInviteToGroupCallUsers {
|
||||
return nil, limitInvalidErr()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,20 @@ type groupCallSessions struct {
|
|||
|
||||
func (s *groupCallSessions) IsUserOnline(userID int64) bool { return false }
|
||||
func (s *groupCallSessions) OnlineUserIDsForCandidates(candidateUserIDs []int64, limit int) []int64 {
|
||||
return nil
|
||||
online := map[int64]struct{}{}
|
||||
for _, id := range s.online {
|
||||
online[id] = struct{}{}
|
||||
}
|
||||
out := make([]int64, 0, len(candidateUserIDs))
|
||||
for _, id := range candidateUserIDs {
|
||||
if _, ok := online[id]; ok {
|
||||
out = append(out, id)
|
||||
if limit > 0 && len(out) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (s *groupCallSessions) TrackChannelInterest([8]byte, int64, int64, []int64) {}
|
||||
func (s *groupCallSessions) ClearChannelInterest([8]byte, int64, int64) {}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ func (r *Router) groupCallUpdateContainer(ctx context.Context, viewerUserID int6
|
|||
|
||||
// pushGroupCallUpdate 把 updateGroupCall(call 行变化)推给在线群成员。
|
||||
func (r *Router) pushGroupCallUpdate(ctx context.Context, channel domain.Channel, call domain.GroupCall) {
|
||||
if call.Conference() {
|
||||
r.pushConferenceGroupCallUpdate(ctx, call)
|
||||
return
|
||||
}
|
||||
recipients := r.groupCallOnlineRecipients(channel.ID)
|
||||
for _, viewerID := range recipients {
|
||||
update := &tg.UpdateGroupCall{Call: tgGroupCall(call, viewerID, false)}
|
||||
|
|
@ -54,6 +58,10 @@ func (r *Router) pushGroupCallUpdate(ctx context.Context, channel domain.Channel
|
|||
// pushGroupCallParticipantsUpdate 把参与者增量(version=N)推给在线群成员。
|
||||
// 每个 viewer 单独构建:participant.Self flag 是 per-viewer 的。
|
||||
func (r *Router) pushGroupCallParticipantsUpdate(ctx context.Context, channel domain.Channel, call domain.GroupCall, rows []domain.GroupCallParticipant) {
|
||||
if call.Conference() {
|
||||
r.pushConferenceGroupCallParticipantsUpdate(ctx, call, rows)
|
||||
return
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
|
|
@ -73,6 +81,62 @@ func (r *Router) pushGroupCallParticipantsUpdate(ctx context.Context, channel do
|
|||
}
|
||||
}
|
||||
|
||||
func (r *Router) conferenceCallRecipients(ctx context.Context, callID int64) []int64 {
|
||||
return r.conferenceCallRecipientsWith(ctx, callID, nil)
|
||||
}
|
||||
|
||||
func (r *Router) conferenceCallRecipientsWith(ctx context.Context, callID int64, extraUserIDs []int64) []int64 {
|
||||
if r.deps.GroupCalls == nil {
|
||||
return nil
|
||||
}
|
||||
recipients, err := r.deps.GroupCalls.ConferenceRecipients(ctx, callID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
recipients = append(recipients, extraUserIDs...)
|
||||
recipients = uniquePositiveUserIDs(recipients)
|
||||
if len(recipients) == 0 {
|
||||
return nil
|
||||
}
|
||||
if provider, ok := r.deps.Sessions.(OnlineUserProvider); ok {
|
||||
return provider.OnlineUserIDsForCandidates(recipients, domain.MaxChannelRealtimeFanout)
|
||||
}
|
||||
return recipients
|
||||
}
|
||||
|
||||
func (r *Router) pushConferenceGroupCallUpdate(ctx context.Context, call domain.GroupCall) {
|
||||
r.pushConferenceGroupCallUpdateTo(ctx, call, nil)
|
||||
}
|
||||
|
||||
func (r *Router) pushConferenceGroupCallUpdateTo(ctx context.Context, call domain.GroupCall, extraUserIDs []int64) {
|
||||
recipients := r.conferenceCallRecipientsWith(ctx, call.ID, extraUserIDs)
|
||||
for _, viewerID := range recipients {
|
||||
update := &tg.UpdateGroupCall{Call: tgGroupCall(call, viewerID, viewerID == call.CreatorUserID)}
|
||||
r.pushUserMessage(ctx, viewerID, "conference call update",
|
||||
r.groupCallUpdateContainer(ctx, viewerID, domain.Channel{}, update, []int64{call.CreatorUserID}))
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) pushConferenceGroupCallParticipantsUpdate(ctx context.Context, call domain.GroupCall, rows []domain.GroupCallParticipant) {
|
||||
if len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
userIDs := make([]int64, 0, len(rows))
|
||||
for _, p := range rows {
|
||||
userIDs = append(userIDs, p.UserID)
|
||||
}
|
||||
recipients := r.conferenceCallRecipients(ctx, call.ID)
|
||||
for _, viewerID := range recipients {
|
||||
update := &tg.UpdateGroupCallParticipants{
|
||||
Call: &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash},
|
||||
Participants: tgGroupCallParticipants(rows, viewerID),
|
||||
Version: call.Version,
|
||||
}
|
||||
r.pushUserMessage(ctx, viewerID, "conference call participants",
|
||||
r.groupCallUpdateContainer(ctx, viewerID, domain.Channel{}, update, userIDs))
|
||||
}
|
||||
}
|
||||
|
||||
// pushGroupCallServiceMessage 把 started/ended/invite 服务消息(带频道 pts)推给
|
||||
// 活跃成员(res.Recipients)。复用 channelOperationUpdates 的 per-viewer 构建。
|
||||
func (r *Router) pushGroupCallServiceMessage(ctx context.Context, originUserID int64, res domain.SendChannelMessageResult) {
|
||||
|
|
@ -93,6 +157,11 @@ func (r *Router) pushGroupCallServiceMessage(ctx context.Context, originUserID i
|
|||
// groupCallMutationFanout 是参与者维度变更后的统一扇出:participants 增量 +
|
||||
// call_not_empty 翻转时的 channel 维度刷新(Android banner 对 flag 依赖更重)。
|
||||
func (r *Router) groupCallMutationFanout(ctx context.Context, channel domain.Channel, mut domain.GroupCallMutation) domain.Channel {
|
||||
if mut.Call.Conference() {
|
||||
r.pushConferenceGroupCallParticipantsUpdate(ctx, mut.Call, []domain.GroupCallParticipant{mut.Participant})
|
||||
r.pushConferenceGroupCallUpdate(ctx, mut.Call)
|
||||
return domain.Channel{}
|
||||
}
|
||||
r.pushGroupCallParticipantsUpdate(ctx, channel, mut.Call, []domain.GroupCallParticipant{mut.Participant})
|
||||
wantNotEmpty := mut.Call.Active() && mut.Call.ParticipantsCount > 0
|
||||
if channel.ActiveCallNotEmpty != wantNotEmpty && r.deps.Channels != nil {
|
||||
|
|
@ -107,3 +176,33 @@ func (r *Router) groupCallMutationFanout(ctx context.Context, channel domain.Cha
|
|||
}
|
||||
return channel
|
||||
}
|
||||
|
||||
func groupCallParticipantUserIDs(rows []domain.GroupCallParticipant) []int64 {
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]int64, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, row.UserID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func uniquePositiveUserIDs(ids []int64) []int64 {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
out := make([]int64, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,10 +36,18 @@ func (r *Router) registerPhone(d *tg.ServerDispatcher) {
|
|||
d.OnPhoneGetGroupCall(r.onPhoneGetGroupCall)
|
||||
d.OnPhoneGetGroupParticipants(r.onPhoneGetGroupParticipants)
|
||||
d.OnPhoneCheckGroupCall(r.onPhoneCheckGroupCall)
|
||||
d.OnPhoneExportGroupCallInvite(r.onPhoneExportGroupCallInvite)
|
||||
d.OnPhoneEditGroupCallParticipant(r.onPhoneEditGroupCallParticipant)
|
||||
d.OnPhoneEditGroupCallTitle(r.onPhoneEditGroupCallTitle)
|
||||
d.OnPhoneToggleGroupCallSettings(r.onPhoneToggleGroupCallSettings)
|
||||
d.OnPhoneInviteToGroupCall(r.onPhoneInviteToGroupCall)
|
||||
// Ad-hoc E2E conference call(P2P 通话升级/拉人路径)。
|
||||
d.OnPhoneCreateConferenceCall(r.onPhoneCreateConferenceCall)
|
||||
d.OnPhoneInviteConferenceCallParticipant(r.onPhoneInviteConferenceCallParticipant)
|
||||
d.OnPhoneDeleteConferenceCallParticipants(r.onPhoneDeleteConferenceCallParticipants)
|
||||
d.OnPhoneSendConferenceCallBroadcast(r.onPhoneSendConferenceCallBroadcast)
|
||||
d.OnPhoneDeclineConferenceCallInvite(r.onPhoneDeclineConferenceCallInvite)
|
||||
d.OnPhoneGetGroupCallChainBlocks(r.onPhoneGetGroupCallChainBlocks)
|
||||
// 屏幕共享(M4):同参与者第二媒体连接。
|
||||
d.OnPhoneJoinGroupCallPresentation(r.onPhoneJoinGroupCallPresentation)
|
||||
d.OnPhoneLeaveGroupCallPresentation(r.onPhoneLeaveGroupCallPresentation)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ type ChannelStore interface {
|
|||
SetChannelWallpaper(ctx context.Context, req domain.SetChannelWallpaperRequest) (domain.SetChannelWallpaperResult, error)
|
||||
EditChannelAbout(ctx context.Context, req domain.EditChannelAboutRequest) (domain.Channel, error)
|
||||
EditChannelAdmin(ctx context.Context, req domain.EditChannelAdminRequest) (domain.EditChannelAdminResult, error)
|
||||
TransferChannelOwnership(ctx context.Context, req domain.TransferChannelOwnershipRequest) (domain.TransferChannelOwnershipResult, error)
|
||||
EditChannelMemberRank(ctx context.Context, req domain.EditChannelMemberRankRequest) (domain.EditChannelAdminResult, error)
|
||||
EditChannelBanned(ctx context.Context, req domain.EditChannelBannedRequest) (domain.EditChannelBannedResult, error)
|
||||
EditChannelDefaultBannedRights(ctx context.Context, req domain.EditChannelDefaultBannedRightsRequest) (domain.Channel, error)
|
||||
|
|
|
|||
|
|
@ -11,12 +11,21 @@ import (
|
|||
type GroupCallStore interface {
|
||||
// CreateGroupCall 建会;同频道已有活跃通话返回 domain.ErrGroupCallAlreadyStarted。
|
||||
CreateGroupCall(ctx context.Context, call domain.GroupCall) (domain.GroupCall, error)
|
||||
// CreateConferenceCall 建 ad-hoc conference;同一 creator+random_id 幂等返回既有活跃会。
|
||||
CreateConferenceCall(ctx context.Context, call domain.GroupCall) (domain.GroupCall, error)
|
||||
GetGroupCall(ctx context.Context, callID int64) (domain.GroupCall, bool, error)
|
||||
GetGroupCallBySlug(ctx context.Context, slug string) (domain.GroupCall, bool, error)
|
||||
GetGroupCallByInviteMessage(ctx context.Context, userID int64, msgID int) (domain.GroupCall, domain.GroupCallInvite, bool, error)
|
||||
// JoinGroupCall 加入/重进(同主键 upsert 换新 ssrc);ssrc 与他人撞活跃唯一
|
||||
// 约束返回 domain.ErrGroupCallSSRCDuplicate;version++。
|
||||
JoinGroupCall(ctx context.Context, req domain.JoinGroupCallRequest) (domain.GroupCallMutation, error)
|
||||
// LeaveGroupCall 置 left+version++;未在会返回 domain.ErrGroupCallNotJoined。
|
||||
// LeaveGroupCall 置 left+version++;conference 最后一名活跃参与者离开时同步转
|
||||
// discarded,普通 channel group call 允许空房间继续 active;未在会返回
|
||||
// domain.ErrGroupCallNotJoined。
|
||||
LeaveGroupCall(ctx context.Context, callID, userID int64, now int) (domain.GroupCallMutation, error)
|
||||
// RemoveConferenceCallParticipants 在同一事务内接受 conference E2E remove block、
|
||||
// 清理目标的 E2E 成员标记,并在 kick 时把活跃参与者置 left。
|
||||
RemoveConferenceCallParticipants(ctx context.Context, req domain.RemoveConferenceCallParticipantsRequest) (domain.RemoveConferenceCallParticipantsResult, error)
|
||||
// DiscardGroupCall 终结通话并清空参与者,返回终态 call 与此前活跃的参与者。
|
||||
DiscardGroupCall(ctx context.Context, callID int64, now int) (domain.GroupCall, []domain.GroupCallParticipant, error)
|
||||
// TouchParticipant 刷新 checkGroupCall 保活水位,返回该用户当前活跃 ssrc 集合
|
||||
|
|
@ -36,7 +45,7 @@ type GroupCallStore interface {
|
|||
//(每清一人 version++)。注意调用方必须叠加 SFU 媒体面活性做双过期判定。
|
||||
SweepStaleParticipants(ctx context.Context, checkOlderThan, now int, limit int) ([]domain.GroupCallMutation, error)
|
||||
// ResetAllParticipants 服务端重启恢复:把全部活跃通话的参与者批量置 left
|
||||
//(每通话 version++),返回受影响的通话。
|
||||
//(每通话 version++),conference 若因此变空则同步转 discarded,返回受影响的通话。
|
||||
ResetAllParticipants(ctx context.Context, now int) ([]domain.GroupCall, error)
|
||||
// NextRaiseHandRating 分配全局单调递增的举手序号(举手排序用)。
|
||||
NextRaiseHandRating(ctx context.Context, callID int64) (int64, error)
|
||||
|
|
@ -45,4 +54,13 @@ type GroupCallStore interface {
|
|||
SetParticipantOverride(ctx context.Context, callID, setterUserID, targetUserID int64, override domain.GroupCallParticipantOverride, clear bool) error
|
||||
// GetParticipantOverride 取某 setter 对某 target 的覆盖。
|
||||
GetParticipantOverride(ctx context.Context, callID, setterUserID, targetUserID int64) (domain.GroupCallParticipantOverride, bool, error)
|
||||
// CreateConferenceInvite 记录一条 conference 私聊邀请与其 message box id。
|
||||
CreateConferenceInvite(ctx context.Context, invite domain.GroupCallInvite) (domain.GroupCallInvite, error)
|
||||
SetConferenceInviteStatus(ctx context.Context, callID int64, inviteeUserID int64, msgID int, status domain.GroupCallInviteStatus, now int) (domain.GroupCallInvite, bool, error)
|
||||
// ListConferenceRecipientUserIDs 返回 conference 在线推送/访问候选人。
|
||||
// active 态只包含 creator、当前活跃参与者、pending/accepted invite 相关人;
|
||||
// discarded 态包含所有历史参与者与 invite 相关人,允许客户端收尾轮询读取终态。
|
||||
ListConferenceRecipientUserIDs(ctx context.Context, callID int64) ([]int64, error)
|
||||
AppendGroupCallChainBlock(ctx context.Context, block domain.GroupCallChainBlock) (domain.GroupCallChainBlock, error)
|
||||
ListGroupCallChainBlocks(ctx context.Context, callID int64, subChainID, offset, limit int) (domain.GroupCallChainBlockPage, error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,25 +48,12 @@ func (s *ChannelStore) CreateChannel(_ context.Context, req domain.CreateChannel
|
|||
}
|
||||
channel.HasLink = true
|
||||
creator := domain.ChannelMember{
|
||||
ChannelID: channelID,
|
||||
UserID: req.CreatorUserID,
|
||||
Role: domain.ChannelRoleCreator,
|
||||
Status: domain.ChannelMemberActive,
|
||||
JoinedAt: req.Date,
|
||||
AdminRights: domain.ChannelAdminRights{
|
||||
ChangeInfo: true,
|
||||
PostMessages: true,
|
||||
EditMessages: true,
|
||||
DeleteMessages: true,
|
||||
PostStories: true,
|
||||
EditStories: true,
|
||||
DeleteStories: true,
|
||||
BanUsers: true,
|
||||
InviteUsers: true,
|
||||
PinMessages: true,
|
||||
AddAdmins: true,
|
||||
ManageCall: true,
|
||||
},
|
||||
ChannelID: channelID,
|
||||
UserID: req.CreatorUserID,
|
||||
Role: domain.ChannelRoleCreator,
|
||||
Status: domain.ChannelMemberActive,
|
||||
JoinedAt: req.Date,
|
||||
AdminRights: domain.CreatorChannelAdminRights(),
|
||||
}
|
||||
s.channels[channelID] = channel
|
||||
s.invites[inviteHash] = domain.ChannelInvite{
|
||||
|
|
|
|||
|
|
@ -87,7 +87,6 @@ func (s *ChannelStore) SearchPublicChannels(_ context.Context, viewerUserID int6
|
|||
|
||||
type item struct {
|
||||
channel domain.Channel
|
||||
joined bool
|
||||
rank int
|
||||
}
|
||||
items := make([]item, 0, limit)
|
||||
|
|
@ -98,9 +97,11 @@ func (s *ChannelStore) SearchPublicChannels(_ context.Context, viewerUserID int6
|
|||
}
|
||||
member, joined := s.members[channelID][viewerUserID]
|
||||
joined = joined && member.Status == domain.ChannelMemberActive
|
||||
if joined {
|
||||
continue
|
||||
}
|
||||
items = append(items, item{
|
||||
channel: cloneChannel(channel),
|
||||
joined: joined,
|
||||
rank: rank,
|
||||
})
|
||||
}
|
||||
|
|
@ -108,9 +109,6 @@ func (s *ChannelStore) SearchPublicChannels(_ context.Context, viewerUserID int6
|
|||
if items[i].rank != items[j].rank {
|
||||
return items[i].rank < items[j].rank
|
||||
}
|
||||
if items[i].joined != items[j].joined {
|
||||
return items[i].joined
|
||||
}
|
||||
if items[i].channel.ParticipantsCount != items[j].channel.ParticipantsCount {
|
||||
return items[i].channel.ParticipantsCount > items[j].channel.ParticipantsCount
|
||||
}
|
||||
|
|
@ -122,14 +120,10 @@ func (s *ChannelStore) SearchPublicChannels(_ context.Context, viewerUserID int6
|
|||
|
||||
out := domain.PublicChannelSearchResult{}
|
||||
for _, item := range items {
|
||||
if len(out.MyResults)+len(out.Results) >= limit {
|
||||
if len(out.Results) >= limit {
|
||||
break
|
||||
}
|
||||
if item.joined {
|
||||
out.MyResults = append(out.MyResults, item.channel)
|
||||
} else {
|
||||
out.Results = append(out.Results, item.channel)
|
||||
}
|
||||
out.Results = append(out.Results, item.channel)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -311,7 +311,7 @@ func (s *ChannelStore) EditChannelAdmin(_ context.Context, req domain.EditChanne
|
|||
member.AvailableMinPts = minPts
|
||||
}
|
||||
}
|
||||
member.AdminRights = req.AdminRights
|
||||
member.AdminRights = domain.NormalizeFullMegagroupAdminRights(channel, req.AdminRights)
|
||||
member.Rank = req.Rank
|
||||
if zeroChannelAdminRights(req.AdminRights) {
|
||||
member.Role = domain.ChannelRoleMember
|
||||
|
|
@ -350,6 +350,89 @@ func (s *ChannelStore) EditChannelAdmin(_ context.Context, req domain.EditChanne
|
|||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) TransferChannelOwnership(_ context.Context, req domain.TransferChannelOwnershipRequest) (domain.TransferChannelOwnershipResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 || req.NewOwnerID == 0 || req.NewOwnerID == req.UserID {
|
||||
return domain.TransferChannelOwnershipResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.TransferChannelOwnershipResult{}, err
|
||||
}
|
||||
previousOwner := s.members[req.ChannelID][req.UserID]
|
||||
if channel.CreatorUserID != req.UserID || previousOwner.Role != domain.ChannelRoleCreator {
|
||||
return domain.TransferChannelOwnershipResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
previousNewOwner, ok := s.members[req.ChannelID][req.NewOwnerID]
|
||||
if !ok || previousNewOwner.Status != domain.ChannelMemberActive || previousNewOwner.BannedRights.ViewMessages {
|
||||
return domain.TransferChannelOwnershipResult{}, domain.ErrUserNotParticipant
|
||||
}
|
||||
if previousNewOwner.Role == domain.ChannelRoleCreator {
|
||||
return domain.TransferChannelOwnershipResult{}, domain.ErrChannelNotModified
|
||||
}
|
||||
oldOwner := previousOwner
|
||||
oldOwner.Role = domain.ChannelRoleAdmin
|
||||
oldOwner.AdminRights = creatorChannelAdminRights()
|
||||
oldOwner.Rank = ""
|
||||
oldOwner.Status = domain.ChannelMemberActive
|
||||
oldOwner.LeftAt = 0
|
||||
if oldOwner.InviterUserID == 0 {
|
||||
oldOwner.InviterUserID = req.UserID
|
||||
}
|
||||
newOwner := previousNewOwner
|
||||
newOwner.Role = domain.ChannelRoleCreator
|
||||
newOwner.AdminRights = creatorChannelAdminRights()
|
||||
newOwner.Rank = ""
|
||||
newOwner.Status = domain.ChannelMemberActive
|
||||
newOwner.LeftAt = 0
|
||||
if newOwner.JoinedAt == 0 {
|
||||
newOwner.JoinedAt = req.Date
|
||||
}
|
||||
channel.CreatorUserID = req.NewOwnerID
|
||||
s.channels[req.ChannelID] = channel
|
||||
s.members[req.ChannelID][req.UserID] = oldOwner
|
||||
s.members[req.ChannelID][req.NewOwnerID] = newOwner
|
||||
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
||||
ChannelID: req.ChannelID,
|
||||
UserID: req.UserID,
|
||||
Date: req.Date,
|
||||
Type: domain.ChannelAdminLogParticipantPromote,
|
||||
PrevParticipant: ptrChannelMember(previousOwner),
|
||||
NewParticipant: ptrChannelMember(oldOwner),
|
||||
})
|
||||
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
||||
ChannelID: req.ChannelID,
|
||||
UserID: req.UserID,
|
||||
Date: req.Date,
|
||||
Type: domain.ChannelAdminLogParticipantPromote,
|
||||
PrevParticipant: ptrChannelMember(previousNewOwner),
|
||||
NewParticipant: ptrChannelMember(newOwner),
|
||||
})
|
||||
s.refreshChannelCountsLocked(req.ChannelID)
|
||||
channel = s.channels[req.ChannelID]
|
||||
if msg, ok := s.findMessageLocked(req.ChannelID, channel.TopMessageID); ok {
|
||||
s.upsertChannelDialogLocked(oldOwner.UserID, channel, msg, false)
|
||||
s.upsertChannelDialogLocked(newOwner.UserID, channel, msg, false)
|
||||
}
|
||||
events := []domain.ChannelUpdateEvent{
|
||||
transientChannelParticipantEvent(channel.ID, req.UserID, previousOwner, oldOwner, req.Date),
|
||||
transientChannelParticipantEvent(channel.ID, req.UserID, previousNewOwner, newOwner, req.Date),
|
||||
}
|
||||
recipients := s.activeMemberIDsLocked(req.ChannelID, 0, 0)
|
||||
recipients = append(recipients, req.UserID, req.NewOwnerID)
|
||||
return domain.TransferChannelOwnershipResult{
|
||||
Channel: channel,
|
||||
PreviousOwner: previousOwner,
|
||||
OldOwner: oldOwner,
|
||||
PreviousNewOwner: previousNewOwner,
|
||||
NewOwner: newOwner,
|
||||
Events: events,
|
||||
Recipients: recipients,
|
||||
Date: req.Date,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) EditChannelMemberRank(_ context.Context, req domain.EditChannelMemberRankRequest) (domain.EditChannelAdminResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 || req.MemberID == 0 {
|
||||
return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid
|
||||
|
|
@ -966,20 +1049,7 @@ func zeroChannelBannedRights(rights domain.ChannelBannedRights) bool {
|
|||
}
|
||||
|
||||
func creatorChannelAdminRights() domain.ChannelAdminRights {
|
||||
return domain.ChannelAdminRights{
|
||||
ChangeInfo: true,
|
||||
PostMessages: true,
|
||||
EditMessages: true,
|
||||
DeleteMessages: true,
|
||||
PostStories: true,
|
||||
EditStories: true,
|
||||
DeleteStories: true,
|
||||
BanUsers: true,
|
||||
InviteUsers: true,
|
||||
PinMessages: true,
|
||||
AddAdmins: true,
|
||||
ManageCall: true,
|
||||
}
|
||||
return domain.CreatorChannelAdminRights()
|
||||
}
|
||||
|
||||
func cloneChannelMembers(in []domain.ChannelMember) []domain.ChannelMember {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
|
@ -18,11 +19,31 @@ type overrideKey struct {
|
|||
callID, setter, target int64
|
||||
}
|
||||
|
||||
type conferenceRandomKey struct {
|
||||
creatorID int64
|
||||
randomID int64
|
||||
}
|
||||
|
||||
type inviteMessageKey struct {
|
||||
userID int64
|
||||
msgID int
|
||||
}
|
||||
|
||||
type chainKey struct {
|
||||
callID int64
|
||||
subChainID int
|
||||
}
|
||||
|
||||
type GroupCallStore struct {
|
||||
mu sync.Mutex
|
||||
calls map[int64]domain.GroupCall
|
||||
activeByChan map[int64]int64 // channelID → active callID
|
||||
activeByChan map[int64]int64 // channelID → active callID
|
||||
bySlug map[string]int64
|
||||
byConferenceRnd map[conferenceRandomKey]int64
|
||||
participants map[int64]map[int64]domain.GroupCallParticipant // callID → userID → row
|
||||
invites map[int64][]domain.GroupCallInvite
|
||||
inviteByMessage map[inviteMessageKey]domain.GroupCallInvite
|
||||
chainBlocks map[chainKey][]domain.GroupCallChainBlock
|
||||
overrides map[overrideKey]domain.GroupCallParticipantOverride
|
||||
raiseHandSeq map[int64]int64 // callID → 单调举手序号
|
||||
nextSyntheticID int64
|
||||
|
|
@ -31,11 +52,16 @@ type GroupCallStore struct {
|
|||
// NewGroupCallStore 创建内存实现。
|
||||
func NewGroupCallStore() *GroupCallStore {
|
||||
return &GroupCallStore{
|
||||
calls: make(map[int64]domain.GroupCall),
|
||||
activeByChan: make(map[int64]int64),
|
||||
participants: make(map[int64]map[int64]domain.GroupCallParticipant),
|
||||
overrides: make(map[overrideKey]domain.GroupCallParticipantOverride),
|
||||
raiseHandSeq: make(map[int64]int64),
|
||||
calls: make(map[int64]domain.GroupCall),
|
||||
activeByChan: make(map[int64]int64),
|
||||
bySlug: make(map[string]int64),
|
||||
byConferenceRnd: make(map[conferenceRandomKey]int64),
|
||||
participants: make(map[int64]map[int64]domain.GroupCallParticipant),
|
||||
invites: make(map[int64][]domain.GroupCallInvite),
|
||||
inviteByMessage: make(map[inviteMessageKey]domain.GroupCallInvite),
|
||||
chainBlocks: make(map[chainKey][]domain.GroupCallChainBlock),
|
||||
overrides: make(map[overrideKey]domain.GroupCallParticipantOverride),
|
||||
raiseHandSeq: make(map[int64]int64),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -53,6 +79,7 @@ func (s *GroupCallStore) CreateGroupCall(_ context.Context, call domain.GroupCal
|
|||
if _, exists := s.calls[call.ID]; exists {
|
||||
return domain.GroupCall{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
call.Kind = domain.GroupCallKindChannel
|
||||
call.State = domain.GroupCallStateActive
|
||||
if call.Version <= 0 {
|
||||
call.Version = 1
|
||||
|
|
@ -64,6 +91,40 @@ func (s *GroupCallStore) CreateGroupCall(_ context.Context, call domain.GroupCal
|
|||
return call, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) CreateConferenceCall(_ context.Context, call domain.GroupCall) (domain.GroupCall, error) {
|
||||
if call.ID == 0 || call.AccessHash == 0 || call.CreatorUserID == 0 || call.InviteSlug == "" || call.InviteLink == "" {
|
||||
return domain.GroupCall{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if call.RandomID != 0 {
|
||||
if id, ok := s.byConferenceRnd[conferenceRandomKey{creatorID: call.CreatorUserID, randomID: call.RandomID}]; ok {
|
||||
existing := s.calls[id]
|
||||
return existing, nil
|
||||
}
|
||||
}
|
||||
if _, exists := s.calls[call.ID]; exists {
|
||||
return domain.GroupCall{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
if _, exists := s.bySlug[call.InviteSlug]; exists {
|
||||
return domain.GroupCall{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
call.Kind = domain.GroupCallKindConference
|
||||
call.ChannelID = 0
|
||||
call.State = domain.GroupCallStateActive
|
||||
if call.Version <= 0 {
|
||||
call.Version = 1
|
||||
}
|
||||
call.ParticipantsCount = 0
|
||||
s.calls[call.ID] = call
|
||||
s.bySlug[call.InviteSlug] = call.ID
|
||||
if call.RandomID != 0 {
|
||||
s.byConferenceRnd[conferenceRandomKey{creatorID: call.CreatorUserID, randomID: call.RandomID}] = call.ID
|
||||
}
|
||||
s.participants[call.ID] = make(map[int64]domain.GroupCallParticipant)
|
||||
return call, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) GetGroupCall(_ context.Context, callID int64) (domain.GroupCall, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
|
@ -71,6 +132,28 @@ func (s *GroupCallStore) GetGroupCall(_ context.Context, callID int64) (domain.G
|
|||
return call, ok, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) GetGroupCallBySlug(_ context.Context, slug string) (domain.GroupCall, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
id, ok := s.bySlug[slug]
|
||||
if !ok {
|
||||
return domain.GroupCall{}, false, nil
|
||||
}
|
||||
call, ok := s.calls[id]
|
||||
return call, ok, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) GetGroupCallByInviteMessage(_ context.Context, userID int64, msgID int) (domain.GroupCall, domain.GroupCallInvite, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
inv, ok := s.inviteByMessage[inviteMessageKey{userID: userID, msgID: msgID}]
|
||||
if !ok {
|
||||
return domain.GroupCall{}, domain.GroupCallInvite{}, false, nil
|
||||
}
|
||||
call, ok := s.calls[inv.CallID]
|
||||
return call, inv, ok, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) JoinGroupCall(_ context.Context, req domain.JoinGroupCallRequest) (domain.GroupCallMutation, error) {
|
||||
if req.SSRC == 0 {
|
||||
return domain.GroupCallMutation{}, domain.ErrGroupCallInvalid
|
||||
|
|
@ -102,6 +185,8 @@ func (s *GroupCallStore) JoinGroupCall(_ context.Context, req domain.JoinGroupCa
|
|||
// VideoJSON 整体替换、PresentationJSON 随全新行清空(rejoin 后客户端
|
||||
// 会重发 joinGroupCallPresentation,旧屏幕登记必须作废)。
|
||||
VideoJSON: append([]byte(nil), req.VideoJSON...),
|
||||
PublicKey: append([]byte(nil), req.PublicKey...),
|
||||
JoinBlock: append([]byte(nil), req.JoinBlock...),
|
||||
}
|
||||
if rejoining && wasActive {
|
||||
// 同设备换 ssrc 的 rejoin 保留原 join_date(列表排序稳定)。
|
||||
|
|
@ -113,6 +198,14 @@ func (s *GroupCallStore) JoinGroupCall(_ context.Context, req domain.JoinGroupCa
|
|||
p.MutedByAdmin = true
|
||||
}
|
||||
rows[req.UserID] = p
|
||||
for i, inv := range s.invites[req.CallID] {
|
||||
if inv.InviteeUserID == req.UserID && inv.Status == domain.GroupCallInvitePending {
|
||||
inv.Status = domain.GroupCallInviteAccepted
|
||||
inv.UpdatedAt = req.Now
|
||||
s.invites[req.CallID][i] = inv
|
||||
s.inviteByMessage[inviteMessageKey{userID: inv.InviteeUserID, msgID: inv.MessageID}] = inv
|
||||
}
|
||||
}
|
||||
if !wasActive {
|
||||
call.ParticipantsCount++
|
||||
}
|
||||
|
|
@ -139,10 +232,97 @@ func (s *GroupCallStore) LeaveGroupCall(_ context.Context, callID, userID int64,
|
|||
call.ParticipantsCount--
|
||||
}
|
||||
call.Version++
|
||||
discardEmptyConference(&call, now)
|
||||
s.calls[callID] = call
|
||||
return domain.GroupCallMutation{Call: call, Participant: p}, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) RemoveConferenceCallParticipants(_ context.Context, req domain.RemoveConferenceCallParticipantsRequest) (domain.RemoveConferenceCallParticipantsResult, error) {
|
||||
if req.CallID == 0 || len(req.TargetUserIDs) == 0 || req.OnlyLeft == req.Kick {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
call, ok := s.calls[req.CallID]
|
||||
if !ok || !call.Conference() {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
if !call.Active() {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, domain.ErrGroupCallDiscarded
|
||||
}
|
||||
rows := s.participants[req.CallID]
|
||||
targets := uniqueNonZeroInt64s(req.TargetUserIDs...)
|
||||
e2eTargets := make([]int64, 0, len(targets))
|
||||
mediaTargets := make([]int64, 0, len(targets))
|
||||
for _, targetID := range targets {
|
||||
p, ok := rows[targetID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
hasE2EMarker := len(p.JoinBlock) > 0
|
||||
if req.OnlyLeft {
|
||||
if p.Left && hasE2EMarker {
|
||||
e2eTargets = append(e2eTargets, targetID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if req.Kick {
|
||||
if hasE2EMarker {
|
||||
e2eTargets = append(e2eTargets, targetID)
|
||||
}
|
||||
if !p.Left {
|
||||
mediaTargets = append(mediaTargets, targetID)
|
||||
}
|
||||
}
|
||||
}
|
||||
out := domain.RemoveConferenceCallParticipantsResult{Call: call}
|
||||
if len(e2eTargets) > 0 {
|
||||
if len(req.Block) == 0 {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, domain.ErrConferenceChainInvalid
|
||||
}
|
||||
block, err := s.appendGroupCallChainBlockLocked(domain.GroupCallChainBlock{
|
||||
CallID: req.CallID,
|
||||
SubChainID: 0,
|
||||
Offset: -1,
|
||||
AuthorUserID: req.AuthorUserID,
|
||||
Block: req.Block,
|
||||
CreatedAt: req.Now,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, err
|
||||
}
|
||||
out.ChainBlock = block
|
||||
out.ChainBlockAppended = true
|
||||
for _, targetID := range e2eTargets {
|
||||
p := rows[targetID]
|
||||
p.PublicKey = nil
|
||||
p.JoinBlock = nil
|
||||
rows[targetID] = p
|
||||
}
|
||||
}
|
||||
if len(mediaTargets) > 0 {
|
||||
out.ParticipantsChanged = make([]domain.GroupCallParticipant, 0, len(mediaTargets))
|
||||
for _, targetID := range mediaTargets {
|
||||
p := rows[targetID]
|
||||
if p.Left {
|
||||
continue
|
||||
}
|
||||
p.Left = true
|
||||
p.ActiveDate = req.Now
|
||||
rows[targetID] = p
|
||||
if call.ParticipantsCount > 0 {
|
||||
call.ParticipantsCount--
|
||||
}
|
||||
call.Version++
|
||||
out.ParticipantsChanged = append(out.ParticipantsChanged, p)
|
||||
}
|
||||
discardEmptyConference(&call, req.Now)
|
||||
s.calls[req.CallID] = call
|
||||
out.Call = call
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) DiscardGroupCall(_ context.Context, callID int64, now int) (domain.GroupCall, []domain.GroupCallParticipant, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
|
@ -376,12 +556,22 @@ func (s *GroupCallStore) ResetAllParticipants(_ context.Context, now int) ([]dom
|
|||
}
|
||||
call.ParticipantsCount = 0
|
||||
call.Version++
|
||||
discardEmptyConference(&call, now)
|
||||
s.calls[callID] = call
|
||||
out = append(out, call)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func discardEmptyConference(call *domain.GroupCall, now int) {
|
||||
if call == nil || !call.Conference() || !call.Active() || call.ParticipantsCount > 0 {
|
||||
return
|
||||
}
|
||||
call.State = domain.GroupCallStateDiscarded
|
||||
call.DiscardedAt = now
|
||||
call.Duration = max(0, now-call.CreatedAt)
|
||||
}
|
||||
|
||||
func applyGroupCallParticipantUpdate(p *domain.GroupCallParticipant, u domain.GroupCallParticipantUpdate) bool {
|
||||
changed := false
|
||||
if u.Muted != nil && p.Muted != *u.Muted {
|
||||
|
|
@ -457,6 +647,162 @@ func (s *GroupCallStore) GetParticipantOverride(_ context.Context, callID, sette
|
|||
return ov, ok, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) CreateConferenceInvite(_ context.Context, invite domain.GroupCallInvite) (domain.GroupCallInvite, error) {
|
||||
if invite.CallID == 0 || invite.InviterUserID == 0 || invite.InviteeUserID == 0 || invite.MessageID == 0 {
|
||||
return domain.GroupCallInvite{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
call, ok := s.calls[invite.CallID]
|
||||
if !ok || !call.Conference() {
|
||||
return domain.GroupCallInvite{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
if invite.Status == "" {
|
||||
invite.Status = domain.GroupCallInvitePending
|
||||
}
|
||||
key := inviteMessageKey{userID: invite.InviteeUserID, msgID: invite.MessageID}
|
||||
if existing, ok := s.inviteByMessage[key]; ok {
|
||||
return existing, nil
|
||||
}
|
||||
s.invites[invite.CallID] = append(s.invites[invite.CallID], invite)
|
||||
s.inviteByMessage[key] = invite
|
||||
return invite, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) SetConferenceInviteStatus(_ context.Context, callID int64, inviteeUserID int64, msgID int, status domain.GroupCallInviteStatus, now int) (domain.GroupCallInvite, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
key := inviteMessageKey{userID: inviteeUserID, msgID: msgID}
|
||||
inv, ok := s.inviteByMessage[key]
|
||||
if !ok || inv.CallID != callID {
|
||||
return domain.GroupCallInvite{}, false, nil
|
||||
}
|
||||
if inv.Status == status {
|
||||
return inv, false, nil
|
||||
}
|
||||
inv.Status = status
|
||||
inv.UpdatedAt = now
|
||||
s.inviteByMessage[key] = inv
|
||||
for i, row := range s.invites[callID] {
|
||||
if row.InviteeUserID == inviteeUserID && row.MessageID == msgID {
|
||||
s.invites[callID][i] = inv
|
||||
break
|
||||
}
|
||||
}
|
||||
return inv, true, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) ListConferenceRecipientUserIDs(_ context.Context, callID int64) ([]int64, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
call, ok := s.calls[callID]
|
||||
if !ok {
|
||||
return nil, domain.ErrGroupCallInvalid
|
||||
}
|
||||
includeHistorical := !call.Active()
|
||||
seen := map[int64]struct{}{}
|
||||
if call.CreatorUserID != 0 {
|
||||
seen[call.CreatorUserID] = struct{}{}
|
||||
}
|
||||
for userID, p := range s.participants[callID] {
|
||||
if includeHistorical || !p.Left {
|
||||
seen[userID] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, inv := range s.invites[callID] {
|
||||
if includeHistorical || inv.Status == domain.GroupCallInvitePending || inv.Status == domain.GroupCallInviteAccepted {
|
||||
seen[inv.InviteeUserID] = struct{}{}
|
||||
seen[inv.InviterUserID] = struct{}{}
|
||||
}
|
||||
}
|
||||
out := make([]int64, 0, len(seen))
|
||||
for id := range seen {
|
||||
out = append(out, id)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) AppendGroupCallChainBlock(_ context.Context, block domain.GroupCallChainBlock) (domain.GroupCallChainBlock, error) {
|
||||
if block.CallID == 0 || len(block.Block) == 0 {
|
||||
return domain.GroupCallChainBlock{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.appendGroupCallChainBlockLocked(block)
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) appendGroupCallChainBlockLocked(block domain.GroupCallChainBlock) (domain.GroupCallChainBlock, error) {
|
||||
if call, ok := s.calls[block.CallID]; !ok || !call.Conference() {
|
||||
return domain.GroupCallChainBlock{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
key := chainKey{callID: block.CallID, subChainID: block.SubChainID}
|
||||
rows := s.chainBlocks[key]
|
||||
for _, row := range rows {
|
||||
if bytes.Equal(row.Block, block.Block) {
|
||||
return domain.GroupCallChainBlock{}, domain.ErrConferenceChainInvalid
|
||||
}
|
||||
}
|
||||
nextOffset := 0
|
||||
if len(rows) > 0 {
|
||||
nextOffset = rows[len(rows)-1].Offset + 1
|
||||
}
|
||||
if block.Offset < 0 {
|
||||
block.Offset = nextOffset
|
||||
}
|
||||
if block.Offset != nextOffset {
|
||||
return domain.GroupCallChainBlock{}, domain.ErrConferenceChainInvalid
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.Offset == block.Offset {
|
||||
return domain.GroupCallChainBlock{}, domain.ErrConferenceChainInvalid
|
||||
}
|
||||
}
|
||||
block.Block = append([]byte(nil), block.Block...)
|
||||
s.chainBlocks[key] = append(rows, block)
|
||||
sort.Slice(s.chainBlocks[key], func(i, j int) bool {
|
||||
return s.chainBlocks[key][i].Offset < s.chainBlocks[key][j].Offset
|
||||
})
|
||||
return block, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) ListGroupCallChainBlocks(_ context.Context, callID int64, subChainID, offset, limit int) (domain.GroupCallChainBlockPage, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if call, ok := s.calls[callID]; !ok || !call.Conference() {
|
||||
return domain.GroupCallChainBlockPage{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
rows := s.chainBlocks[chainKey{callID: callID, subChainID: subChainID}]
|
||||
if offset == domain.GroupCallChainBlockLatestOffset {
|
||||
page := domain.GroupCallChainBlockPage{NextOffset: 0}
|
||||
if len(rows) == 0 {
|
||||
return page, nil
|
||||
}
|
||||
block := rows[len(rows)-1]
|
||||
page.Blocks = append(page.Blocks, block)
|
||||
page.NextOffset = block.Offset + 1
|
||||
return page, nil
|
||||
}
|
||||
if offset < 0 {
|
||||
return domain.GroupCallChainBlockPage{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
page := domain.GroupCallChainBlockPage{NextOffset: offset}
|
||||
for _, row := range rows {
|
||||
if row.Offset < offset {
|
||||
continue
|
||||
}
|
||||
page.Blocks = append(page.Blocks, row)
|
||||
page.NextOffset = row.Offset + 1
|
||||
if len(page.Blocks) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
|
|
|
|||
|
|
@ -150,18 +150,18 @@ func (s *ChannelStore) SearchPublicChannels(ctx context.Context, viewerUserID in
|
|||
queryPrefix := escapeLike(queryLower) + "%"
|
||||
queryLike := "%" + escapeLike(queryLower) + "%"
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT `+channelColumns+`,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM channel_members m
|
||||
WHERE m.channel_id = c.id
|
||||
AND m.user_id = $1
|
||||
AND m.status = 'active'
|
||||
) AS viewer_member
|
||||
SELECT `+channelColumns+`
|
||||
FROM channels c
|
||||
WHERE NOT c.deleted
|
||||
AND (c.broadcast OR c.megagroup)
|
||||
AND COALESCE(c.username, '') <> ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM channel_members m
|
||||
WHERE m.channel_id = c.id
|
||||
AND m.user_id = $1
|
||||
AND m.status = 'active'
|
||||
)
|
||||
AND (
|
||||
lower(c.username) = $2
|
||||
OR lower(c.username) LIKE $3 ESCAPE '\'
|
||||
|
|
@ -176,7 +176,6 @@ ORDER BY CASE
|
|||
WHEN lower(c.title) LIKE $3 ESCAPE '\' THEN 3
|
||||
ELSE 4
|
||||
END,
|
||||
viewer_member DESC,
|
||||
c.participants_count DESC,
|
||||
c.date DESC,
|
||||
c.id DESC
|
||||
|
|
@ -186,19 +185,14 @@ LIMIT $5`, viewerUserID, queryLower, queryPrefix, queryLike, limit)
|
|||
}
|
||||
defer rows.Close()
|
||||
out := domain.PublicChannelSearchResult{
|
||||
MyResults: make([]domain.Channel, 0),
|
||||
Results: make([]domain.Channel, 0, limit),
|
||||
Results: make([]domain.Channel, 0, limit),
|
||||
}
|
||||
for rows.Next() {
|
||||
ch, viewerMember, err := scanChannelWithViewerMember(rows)
|
||||
ch, err := scanChannel(rows)
|
||||
if err != nil {
|
||||
return domain.PublicChannelSearchResult{}, err
|
||||
}
|
||||
if viewerMember {
|
||||
out.MyResults = append(out.MyResults, ch)
|
||||
} else {
|
||||
out.Results = append(out.Results, ch)
|
||||
}
|
||||
out.Results = append(out.Results, ch)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return domain.PublicChannelSearchResult{}, err
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ func (s *ChannelStore) EditChannelAdmin(ctx context.Context, req domain.EditChan
|
|||
member.AvailableMinPts = minPts
|
||||
}
|
||||
}
|
||||
member.AdminRights = req.AdminRights
|
||||
member.AdminRights = domain.NormalizeFullMegagroupAdminRights(channel, req.AdminRights)
|
||||
if zeroChannelAdminRights(req.AdminRights) {
|
||||
member.Role = domain.ChannelRoleMember
|
||||
member.Rank = ""
|
||||
|
|
@ -110,6 +110,146 @@ func (s *ChannelStore) EditChannelAdmin(ctx context.Context, req domain.EditChan
|
|||
return domain.EditChannelAdminResult{Channel: channel, Previous: previous, Participant: member, Event: event, Recipients: recipients, Date: req.Date}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) TransferChannelOwnership(ctx context.Context, req domain.TransferChannelOwnershipRequest) (domain.TransferChannelOwnershipResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 || req.NewOwnerID == 0 || req.NewOwnerID == req.UserID {
|
||||
return domain.TransferChannelOwnershipResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
beginner, ok := s.db.(txBeginner)
|
||||
if !ok {
|
||||
return domain.TransferChannelOwnershipResult{}, fmt.Errorf("transfer channel ownership: db does not support transactions")
|
||||
}
|
||||
if req.Date == 0 {
|
||||
req.Date = nowUnix()
|
||||
}
|
||||
tx, err := beginner.Begin(ctx)
|
||||
if err != nil {
|
||||
return domain.TransferChannelOwnershipResult{}, fmt.Errorf("begin transfer channel ownership: %w", err)
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback(ctx)
|
||||
}
|
||||
}()
|
||||
channel, previousOwner, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.TransferChannelOwnershipResult{}, err
|
||||
}
|
||||
if channel.CreatorUserID != req.UserID || previousOwner.Role != domain.ChannelRoleCreator {
|
||||
return domain.TransferChannelOwnershipResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
previousNewOwner, err := s.getChannelMember(ctx, tx, req.ChannelID, req.NewOwnerID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrChannelPrivate) {
|
||||
return domain.TransferChannelOwnershipResult{}, domain.ErrUserNotParticipant
|
||||
}
|
||||
return domain.TransferChannelOwnershipResult{}, err
|
||||
}
|
||||
if previousNewOwner.Status != domain.ChannelMemberActive || previousNewOwner.BannedRights.ViewMessages {
|
||||
return domain.TransferChannelOwnershipResult{}, domain.ErrUserNotParticipant
|
||||
}
|
||||
if previousNewOwner.Role == domain.ChannelRoleCreator {
|
||||
return domain.TransferChannelOwnershipResult{}, domain.ErrChannelNotModified
|
||||
}
|
||||
oldOwner := previousOwner
|
||||
oldOwner.Role = domain.ChannelRoleAdmin
|
||||
oldOwner.AdminRights = creatorChannelMember(req.ChannelID, req.UserID, req.Date).AdminRights
|
||||
oldOwner.Rank = ""
|
||||
oldOwner.Status = domain.ChannelMemberActive
|
||||
oldOwner.LeftAt = 0
|
||||
if oldOwner.InviterUserID == 0 {
|
||||
oldOwner.InviterUserID = req.UserID
|
||||
}
|
||||
newOwner := previousNewOwner
|
||||
newOwner.Role = domain.ChannelRoleCreator
|
||||
newOwner.AdminRights = creatorChannelMember(req.ChannelID, req.NewOwnerID, req.Date).AdminRights
|
||||
newOwner.Rank = ""
|
||||
newOwner.Status = domain.ChannelMemberActive
|
||||
newOwner.LeftAt = 0
|
||||
if newOwner.JoinedAt == 0 {
|
||||
newOwner.JoinedAt = req.Date
|
||||
}
|
||||
channel.CreatorUserID = req.NewOwnerID
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE channels
|
||||
SET creator_user_id = $2,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND NOT deleted`, req.ChannelID, req.NewOwnerID); err != nil {
|
||||
return domain.TransferChannelOwnershipResult{}, fmt.Errorf("update channel creator: %w", err)
|
||||
}
|
||||
if err := upsertChannelMemberTx(ctx, tx, channel, oldOwner); err != nil {
|
||||
return domain.TransferChannelOwnershipResult{}, err
|
||||
}
|
||||
if err := upsertChannelMemberTx(ctx, tx, channel, newOwner); err != nil {
|
||||
return domain.TransferChannelOwnershipResult{}, err
|
||||
}
|
||||
if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{
|
||||
ChannelID: req.ChannelID,
|
||||
UserID: req.UserID,
|
||||
Date: req.Date,
|
||||
Type: domain.ChannelAdminLogParticipantPromote,
|
||||
PrevParticipant: &previousOwner,
|
||||
NewParticipant: &oldOwner,
|
||||
}); err != nil {
|
||||
return domain.TransferChannelOwnershipResult{}, err
|
||||
}
|
||||
if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{
|
||||
ChannelID: req.ChannelID,
|
||||
UserID: req.UserID,
|
||||
Date: req.Date,
|
||||
Type: domain.ChannelAdminLogParticipantPromote,
|
||||
PrevParticipant: &previousNewOwner,
|
||||
NewParticipant: &newOwner,
|
||||
}); err != nil {
|
||||
return domain.TransferChannelOwnershipResult{}, err
|
||||
}
|
||||
channel, err = refreshChannelCountsTx(ctx, tx, channel)
|
||||
if err != nil {
|
||||
return domain.TransferChannelOwnershipResult{}, err
|
||||
}
|
||||
msg, _ := s.getChannelMessage(ctx, tx, req.ChannelID, channel.TopMessageID)
|
||||
if err := upsertChannelDialogTx(ctx, tx, oldOwner.UserID, channel, msg, oldOwner.ReadInboxMaxID, oldOwner.ReadOutboxMaxID); err != nil {
|
||||
return domain.TransferChannelOwnershipResult{}, err
|
||||
}
|
||||
if err := upsertChannelDialogTx(ctx, tx, newOwner.UserID, channel, msg, newOwner.ReadInboxMaxID, newOwner.ReadOutboxMaxID); err != nil {
|
||||
return domain.TransferChannelOwnershipResult{}, err
|
||||
}
|
||||
recipients, err := s.listActiveChannelMemberIDs(ctx, tx, req.ChannelID, 0)
|
||||
if err != nil {
|
||||
return domain.TransferChannelOwnershipResult{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.TransferChannelOwnershipResult{}, fmt.Errorf("commit transfer channel ownership: %w", err)
|
||||
}
|
||||
committed = true
|
||||
if s.rowCache != nil {
|
||||
s.rowCache.delete(req.ChannelID)
|
||||
}
|
||||
if s.memberCache != nil {
|
||||
s.memberCache.delete(req.ChannelID, req.UserID)
|
||||
s.memberCache.delete(req.ChannelID, req.NewOwnerID)
|
||||
}
|
||||
if s.dialogCache != nil {
|
||||
s.dialogCache.delete(oldOwner.UserID, req.ChannelID)
|
||||
s.dialogCache.delete(newOwner.UserID, req.ChannelID)
|
||||
}
|
||||
events := []domain.ChannelUpdateEvent{
|
||||
transientChannelParticipantEvent(channel.ID, req.UserID, previousOwner, oldOwner, req.Date),
|
||||
transientChannelParticipantEvent(channel.ID, req.UserID, previousNewOwner, newOwner, req.Date),
|
||||
}
|
||||
recipients = append(recipients, req.UserID, req.NewOwnerID)
|
||||
return domain.TransferChannelOwnershipResult{
|
||||
Channel: channel,
|
||||
PreviousOwner: previousOwner,
|
||||
OldOwner: oldOwner,
|
||||
PreviousNewOwner: previousNewOwner,
|
||||
NewOwner: newOwner,
|
||||
Events: events,
|
||||
Recipients: recipients,
|
||||
Date: req.Date,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) EditChannelMemberRank(ctx context.Context, req domain.EditChannelMemberRankRequest) (domain.EditChannelAdminResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 || req.MemberID == 0 {
|
||||
return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid
|
||||
|
|
|
|||
|
|
@ -351,25 +351,12 @@ func zeroChannelBannedRights(rights domain.ChannelBannedRights) bool {
|
|||
|
||||
func creatorChannelMember(channelID, userID int64, date int) domain.ChannelMember {
|
||||
return domain.ChannelMember{
|
||||
ChannelID: channelID,
|
||||
UserID: userID,
|
||||
Role: domain.ChannelRoleCreator,
|
||||
Status: domain.ChannelMemberActive,
|
||||
JoinedAt: date,
|
||||
AdminRights: domain.ChannelAdminRights{
|
||||
ChangeInfo: true,
|
||||
PostMessages: true,
|
||||
EditMessages: true,
|
||||
DeleteMessages: true,
|
||||
PostStories: true,
|
||||
EditStories: true,
|
||||
DeleteStories: true,
|
||||
BanUsers: true,
|
||||
InviteUsers: true,
|
||||
PinMessages: true,
|
||||
AddAdmins: true,
|
||||
ManageCall: true,
|
||||
},
|
||||
ChannelID: channelID,
|
||||
UserID: userID,
|
||||
Role: domain.ChannelRoleCreator,
|
||||
Status: domain.ChannelMemberActive,
|
||||
JoinedAt: date,
|
||||
AdminRights: domain.CreatorChannelAdminRights(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
|
@ -24,21 +25,24 @@ func NewGroupCallStore(db sqlcgen.DBTX) *GroupCallStore {
|
|||
return &GroupCallStore{db: db}
|
||||
}
|
||||
|
||||
const groupCallColumns = `call_id, access_hash, channel_id, creator_user_id, state, title, join_muted,
|
||||
version, participants_count, created_at, discarded_at, duration, started_msg_id`
|
||||
const groupCallColumns = `call_id, access_hash, channel_id, creator_user_id, kind, state, title, join_muted,
|
||||
version, participants_count, created_at, discarded_at, duration, started_msg_id,
|
||||
invite_slug, invite_link, random_id, migrated_from_phone_call_id`
|
||||
|
||||
const groupCallParticipantColumns = `call_id, user_id, ssrc, join_date, active_date, muted, muted_by_admin,
|
||||
volume_by_admin, raise_hand_rating, video_json, presentation_json, left_call, last_check_date`
|
||||
volume_by_admin, raise_hand_rating, video_json, presentation_json, public_key, join_block, left_call, last_check_date`
|
||||
|
||||
func scanGroupCall(row rowScanner) (domain.GroupCall, error) {
|
||||
var c domain.GroupCall
|
||||
var state string
|
||||
var kind, state string
|
||||
if err := row.Scan(
|
||||
&c.ID, &c.AccessHash, &c.ChannelID, &c.CreatorUserID, &state, &c.Title, &c.JoinMuted,
|
||||
&c.ID, &c.AccessHash, &c.ChannelID, &c.CreatorUserID, &kind, &state, &c.Title, &c.JoinMuted,
|
||||
&c.Version, &c.ParticipantsCount, &c.CreatedAt, &c.DiscardedAt, &c.Duration, &c.StartedMsgID,
|
||||
&c.InviteSlug, &c.InviteLink, &c.RandomID, &c.MigratedFromPhoneCallID,
|
||||
); err != nil {
|
||||
return domain.GroupCall{}, err
|
||||
}
|
||||
c.Kind = domain.GroupCallKind(kind)
|
||||
c.State = domain.GroupCallState(state)
|
||||
return c, nil
|
||||
}
|
||||
|
|
@ -47,13 +51,39 @@ func scanGroupCallParticipant(row rowScanner) (domain.GroupCallParticipant, erro
|
|||
var p domain.GroupCallParticipant
|
||||
if err := row.Scan(
|
||||
&p.CallID, &p.UserID, &p.SSRC, &p.JoinDate, &p.ActiveDate, &p.Muted, &p.MutedByAdmin,
|
||||
&p.VolumeByAdmin, &p.RaiseHandRating, &p.VideoJSON, &p.PresentationJSON, &p.Left, &p.LastCheckDate,
|
||||
&p.VolumeByAdmin, &p.RaiseHandRating, &p.VideoJSON, &p.PresentationJSON, &p.PublicKey, &p.JoinBlock, &p.Left, &p.LastCheckDate,
|
||||
); err != nil {
|
||||
return domain.GroupCallParticipant{}, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func prefixedGroupCallColumns(alias string) string {
|
||||
parts := strings.Split(groupCallColumns, ",")
|
||||
for i, part := range parts {
|
||||
parts[i] = alias + "." + strings.TrimSpace(part)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func scanGroupCallInviteJoined(row rowScanner) (domain.GroupCall, domain.GroupCallInvite, error) {
|
||||
var c domain.GroupCall
|
||||
var inv domain.GroupCallInvite
|
||||
var kind, state, status string
|
||||
if err := row.Scan(
|
||||
&c.ID, &c.AccessHash, &c.ChannelID, &c.CreatorUserID, &kind, &state, &c.Title, &c.JoinMuted,
|
||||
&c.Version, &c.ParticipantsCount, &c.CreatedAt, &c.DiscardedAt, &c.Duration, &c.StartedMsgID,
|
||||
&c.InviteSlug, &c.InviteLink, &c.RandomID, &c.MigratedFromPhoneCallID,
|
||||
&inv.CallID, &inv.InviterUserID, &inv.InviteeUserID, &inv.MessageID, &status, &inv.Video, &inv.CreatedAt, &inv.UpdatedAt,
|
||||
); err != nil {
|
||||
return domain.GroupCall{}, domain.GroupCallInvite{}, err
|
||||
}
|
||||
c.Kind = domain.GroupCallKind(kind)
|
||||
c.State = domain.GroupCallState(state)
|
||||
inv.Status = domain.GroupCallInviteStatus(status)
|
||||
return c, inv, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) begin(ctx context.Context, op string) (pgx.Tx, error) {
|
||||
beginner, ok := s.db.(txBeginner)
|
||||
if !ok {
|
||||
|
|
@ -92,6 +122,31 @@ RETURNING `+groupCallColumns, callID, countDelta))
|
|||
return call, nil
|
||||
}
|
||||
|
||||
func bumpGroupCallParticipantsTx(ctx context.Context, tx pgx.Tx, callID int64, countDelta, now int) (domain.GroupCall, error) {
|
||||
call, err := scanGroupCall(tx.QueryRow(ctx, `
|
||||
UPDATE group_calls
|
||||
SET version = version + 1,
|
||||
participants_count = GREATEST(0, participants_count + $2),
|
||||
state = CASE
|
||||
WHEN kind = 'conference' AND state = 'active' AND GREATEST(0, participants_count + $2) = 0 THEN 'discarded'
|
||||
ELSE state
|
||||
END,
|
||||
discarded_at = CASE
|
||||
WHEN kind = 'conference' AND state = 'active' AND GREATEST(0, participants_count + $2) = 0 THEN $3
|
||||
ELSE discarded_at
|
||||
END,
|
||||
duration = CASE
|
||||
WHEN kind = 'conference' AND state = 'active' AND GREATEST(0, participants_count + $2) = 0 THEN GREATEST(0, $3 - created_at)
|
||||
ELSE duration
|
||||
END
|
||||
WHERE call_id = $1
|
||||
RETURNING `+groupCallColumns, callID, countDelta, now))
|
||||
if err != nil {
|
||||
return domain.GroupCall{}, fmt.Errorf("bump group call participants: %w", err)
|
||||
}
|
||||
return call, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) CreateGroupCall(ctx context.Context, call domain.GroupCall) (domain.GroupCall, error) {
|
||||
if call.ID == 0 || call.ChannelID == 0 || call.AccessHash == 0 {
|
||||
return domain.GroupCall{}, domain.ErrGroupCallInvalid
|
||||
|
|
@ -100,8 +155,8 @@ func (s *GroupCallStore) CreateGroupCall(ctx context.Context, call domain.GroupC
|
|||
call.Version = 1
|
||||
}
|
||||
_, err := s.db.Exec(ctx, `
|
||||
INSERT INTO group_calls (call_id, access_hash, channel_id, creator_user_id, state, title, join_muted, version, participants_count, created_at)
|
||||
VALUES ($1, $2, $3, $4, 'active', $5, $6, $7, 0, $8)`,
|
||||
INSERT INTO group_calls (call_id, access_hash, channel_id, creator_user_id, kind, state, title, join_muted, version, participants_count, created_at)
|
||||
VALUES ($1, $2, $3, $4, 'channel', 'active', $5, $6, $7, 0, $8)`,
|
||||
call.ID, call.AccessHash, call.ChannelID, call.CreatorUserID, call.Title, call.JoinMuted, call.Version, call.CreatedAt)
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
|
|
@ -114,10 +169,67 @@ VALUES ($1, $2, $3, $4, 'active', $5, $6, $7, 0, $8)`,
|
|||
return domain.GroupCall{}, fmt.Errorf("insert group call: %w", err)
|
||||
}
|
||||
call.State = domain.GroupCallStateActive
|
||||
call.Kind = domain.GroupCallKindChannel
|
||||
call.ParticipantsCount = 0
|
||||
return call, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) CreateConferenceCall(ctx context.Context, call domain.GroupCall) (domain.GroupCall, error) {
|
||||
if call.ID == 0 || call.AccessHash == 0 || call.CreatorUserID == 0 || call.InviteSlug == "" || call.InviteLink == "" {
|
||||
return domain.GroupCall{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
if call.Version <= 0 {
|
||||
call.Version = 1
|
||||
}
|
||||
call.ChannelID = 0
|
||||
call.Kind = domain.GroupCallKindConference
|
||||
call.State = domain.GroupCallStateActive
|
||||
call.ParticipantsCount = 0
|
||||
created, err := scanGroupCall(s.db.QueryRow(ctx, `
|
||||
INSERT INTO group_calls (
|
||||
call_id, access_hash, channel_id, creator_user_id, kind, state, title, join_muted,
|
||||
version, participants_count, created_at, invite_slug, invite_link, random_id, migrated_from_phone_call_id
|
||||
) VALUES ($1, $2, 0, $3, 'conference', 'active', $4, FALSE, $5, 0, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING `+groupCallColumns,
|
||||
call.ID, call.AccessHash, call.CreatorUserID, call.Title, call.Version, call.CreatedAt,
|
||||
call.InviteSlug, call.InviteLink, call.RandomID, call.MigratedFromPhoneCallID))
|
||||
if err == nil {
|
||||
return created, nil
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.GroupCall{}, fmt.Errorf("insert conference call: %w", err)
|
||||
}
|
||||
if call.RandomID != 0 {
|
||||
existing, found, getErr := s.getConferenceByRandom(ctx, call.CreatorUserID, call.RandomID)
|
||||
if getErr != nil {
|
||||
return domain.GroupCall{}, getErr
|
||||
}
|
||||
if found {
|
||||
return existing, nil
|
||||
}
|
||||
}
|
||||
if existing, found, getErr := s.GetGroupCallBySlug(ctx, call.InviteSlug); getErr != nil {
|
||||
return domain.GroupCall{}, getErr
|
||||
} else if found {
|
||||
return existing, nil
|
||||
}
|
||||
return domain.GroupCall{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) getConferenceByRandom(ctx context.Context, creatorID, randomID int64) (domain.GroupCall, bool, error) {
|
||||
call, err := scanGroupCall(s.db.QueryRow(ctx,
|
||||
`SELECT `+groupCallColumns+` FROM group_calls WHERE kind = 'conference' AND creator_user_id = $1 AND random_id = $2`,
|
||||
creatorID, randomID))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.GroupCall{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.GroupCall{}, false, fmt.Errorf("get conference by random: %w", err)
|
||||
}
|
||||
return call, true, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) GetGroupCall(ctx context.Context, callID int64) (domain.GroupCall, bool, error) {
|
||||
call, err := scanGroupCall(s.db.QueryRow(ctx,
|
||||
`SELECT `+groupCallColumns+` FROM group_calls WHERE call_id = $1`, callID))
|
||||
|
|
@ -130,6 +242,34 @@ func (s *GroupCallStore) GetGroupCall(ctx context.Context, callID int64) (domain
|
|||
return call, true, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) GetGroupCallBySlug(ctx context.Context, slug string) (domain.GroupCall, bool, error) {
|
||||
call, err := scanGroupCall(s.db.QueryRow(ctx,
|
||||
`SELECT `+groupCallColumns+` FROM group_calls WHERE invite_slug = $1`, slug))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.GroupCall{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.GroupCall{}, false, fmt.Errorf("get group call by slug: %w", err)
|
||||
}
|
||||
return call, true, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) GetGroupCallByInviteMessage(ctx context.Context, userID int64, msgID int) (domain.GroupCall, domain.GroupCallInvite, bool, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
SELECT `+prefixedGroupCallColumns("c")+`, i.call_id, i.inviter_user_id, i.invitee_user_id, i.message_id, i.status, i.video, i.created_at, i.updated_at
|
||||
FROM group_call_invites i
|
||||
JOIN group_calls c ON c.call_id = i.call_id
|
||||
WHERE i.invitee_user_id = $1 AND i.message_id = $2`, userID, msgID)
|
||||
call, inv, err := scanGroupCallInviteJoined(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.GroupCall{}, domain.GroupCallInvite{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.GroupCall{}, domain.GroupCallInvite{}, false, fmt.Errorf("get group call by invite message: %w", err)
|
||||
}
|
||||
return call, inv, true, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) JoinGroupCall(ctx context.Context, req domain.JoinGroupCallRequest) (domain.GroupCallMutation, error) {
|
||||
if req.SSRC == 0 {
|
||||
return domain.GroupCallMutation{}, domain.ErrGroupCallInvalid
|
||||
|
|
@ -165,6 +305,8 @@ func (s *GroupCallStore) JoinGroupCall(ctx context.Context, req domain.JoinGroup
|
|||
JoinDate: req.Now,
|
||||
ActiveDate: req.Now,
|
||||
LastCheckDate: req.Now,
|
||||
PublicKey: append([]byte(nil), req.PublicKey...),
|
||||
JoinBlock: append([]byte(nil), req.JoinBlock...),
|
||||
}
|
||||
if wasActive {
|
||||
// 同人换 ssrc 的 rejoin 保留原 join_date(列表排序稳定)。
|
||||
|
|
@ -178,8 +320,8 @@ func (s *GroupCallStore) JoinGroupCall(ctx context.Context, req domain.JoinGroup
|
|||
// video_json 整体替换、presentation_json 清空(rejoin 后客户端会重发
|
||||
// joinGroupCallPresentation,旧屏幕登记必须作废)。
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO group_call_participants (call_id, user_id, ssrc, join_date, active_date, muted, muted_by_admin, volume_by_admin, raise_hand_rating, video_json, left_call, last_check_date)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 0, 0, $8, FALSE, $9)
|
||||
INSERT INTO group_call_participants (call_id, user_id, ssrc, join_date, active_date, muted, muted_by_admin, volume_by_admin, raise_hand_rating, video_json, public_key, join_block, left_call, last_check_date)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 0, 0, $8, $9, $10, FALSE, $11)
|
||||
ON CONFLICT (call_id, user_id) DO UPDATE SET
|
||||
ssrc = EXCLUDED.ssrc,
|
||||
join_date = EXCLUDED.join_date,
|
||||
|
|
@ -190,15 +332,25 @@ ON CONFLICT (call_id, user_id) DO UPDATE SET
|
|||
raise_hand_rating = 0,
|
||||
video_json = EXCLUDED.video_json,
|
||||
presentation_json = NULL,
|
||||
public_key = EXCLUDED.public_key,
|
||||
join_block = EXCLUDED.join_block,
|
||||
left_call = FALSE,
|
||||
last_check_date = EXCLUDED.last_check_date`,
|
||||
req.CallID, req.UserID, req.SSRC, p.JoinDate, p.ActiveDate, p.Muted, p.MutedByAdmin, nullableJSON(p.VideoJSON), p.LastCheckDate); err != nil {
|
||||
req.CallID, req.UserID, req.SSRC, p.JoinDate, p.ActiveDate, p.Muted, p.MutedByAdmin,
|
||||
nullableJSON(p.VideoJSON), nullableGroupCallBytes(p.PublicKey), nullableGroupCallBytes(p.JoinBlock), p.LastCheckDate); err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return domain.GroupCallMutation{}, domain.ErrGroupCallSSRCDuplicate
|
||||
}
|
||||
return domain.GroupCallMutation{}, fmt.Errorf("upsert group call participant: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE group_call_invites
|
||||
SET status = 'accepted', updated_at = $3
|
||||
WHERE call_id = $1 AND invitee_user_id = $2 AND status = 'pending'`,
|
||||
req.CallID, req.UserID, req.Now); err != nil {
|
||||
return domain.GroupCallMutation{}, fmt.Errorf("accept conference invites: %w", err)
|
||||
}
|
||||
countDelta := 0
|
||||
if !wasActive {
|
||||
countDelta = 1
|
||||
|
|
@ -239,7 +391,7 @@ RETURNING `+groupCallParticipantColumns, callID, userID, now))
|
|||
if err != nil {
|
||||
return domain.GroupCallMutation{}, fmt.Errorf("leave group call participant: %w", err)
|
||||
}
|
||||
call, err := bumpGroupCallVersionTx(ctx, tx, callID, -1)
|
||||
call, err := bumpGroupCallParticipantsTx(ctx, tx, callID, -1, now)
|
||||
if err != nil {
|
||||
return domain.GroupCallMutation{}, err
|
||||
}
|
||||
|
|
@ -250,6 +402,143 @@ RETURNING `+groupCallParticipantColumns, callID, userID, now))
|
|||
return domain.GroupCallMutation{Call: call, Participant: p}, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) RemoveConferenceCallParticipants(ctx context.Context, req domain.RemoveConferenceCallParticipantsRequest) (domain.RemoveConferenceCallParticipantsResult, error) {
|
||||
if req.CallID == 0 || len(req.TargetUserIDs) == 0 || req.OnlyLeft == req.Kick {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
tx, err := s.begin(ctx, "remove conference call participants")
|
||||
if err != nil {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, err
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback(ctx)
|
||||
}
|
||||
}()
|
||||
call, err := lockGroupCallTx(ctx, tx, req.CallID)
|
||||
if err != nil {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, err
|
||||
}
|
||||
if !call.Conference() {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
if !call.Active() {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, domain.ErrGroupCallDiscarded
|
||||
}
|
||||
targets := uniqueNonZeroInt64s(req.TargetUserIDs...)
|
||||
if len(targets) == 0 {
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, fmt.Errorf("commit no-op conference participant removal: %w", err)
|
||||
}
|
||||
committed = true
|
||||
return domain.RemoveConferenceCallParticipantsResult{Call: call}, nil
|
||||
}
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT `+groupCallParticipantColumns+`
|
||||
FROM group_call_participants
|
||||
WHERE call_id = $1 AND user_id = ANY($2)
|
||||
FOR UPDATE`, req.CallID, targets)
|
||||
if err != nil {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, fmt.Errorf("lock conference participants: %w", err)
|
||||
}
|
||||
byID := make(map[int64]domain.GroupCallParticipant, len(targets))
|
||||
for rows.Next() {
|
||||
p, err := scanGroupCallParticipant(rows)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, err
|
||||
}
|
||||
byID[p.UserID] = p
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, err
|
||||
}
|
||||
e2eTargets := make([]int64, 0, len(targets))
|
||||
mediaTargets := make([]int64, 0, len(targets))
|
||||
for _, targetID := range targets {
|
||||
p, ok := byID[targetID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
hasE2EMarker := len(p.JoinBlock) > 0
|
||||
if req.OnlyLeft {
|
||||
if p.Left && hasE2EMarker {
|
||||
e2eTargets = append(e2eTargets, targetID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if req.Kick {
|
||||
if hasE2EMarker {
|
||||
e2eTargets = append(e2eTargets, targetID)
|
||||
}
|
||||
if !p.Left {
|
||||
mediaTargets = append(mediaTargets, targetID)
|
||||
}
|
||||
}
|
||||
}
|
||||
out := domain.RemoveConferenceCallParticipantsResult{Call: call}
|
||||
if len(e2eTargets) > 0 {
|
||||
if len(req.Block) == 0 {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, domain.ErrConferenceChainInvalid
|
||||
}
|
||||
block, err := appendGroupCallChainBlockTx(ctx, tx, domain.GroupCallChainBlock{
|
||||
CallID: req.CallID,
|
||||
SubChainID: 0,
|
||||
Offset: -1,
|
||||
AuthorUserID: req.AuthorUserID,
|
||||
Block: req.Block,
|
||||
CreatedAt: req.Now,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, err
|
||||
}
|
||||
out.ChainBlock = block
|
||||
out.ChainBlockAppended = true
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE group_call_participants
|
||||
SET public_key = NULL, join_block = NULL
|
||||
WHERE call_id = $1 AND user_id = ANY($2)`, req.CallID, e2eTargets); err != nil {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, fmt.Errorf("clear conference e2e participants: %w", err)
|
||||
}
|
||||
}
|
||||
if len(mediaTargets) > 0 {
|
||||
rows, err := tx.Query(ctx, `
|
||||
UPDATE group_call_participants
|
||||
SET left_call = TRUE, active_date = $3
|
||||
WHERE call_id = $1 AND user_id = ANY($2) AND NOT left_call
|
||||
RETURNING `+groupCallParticipantColumns, req.CallID, mediaTargets, req.Now)
|
||||
if err != nil {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, fmt.Errorf("leave kicked conference participants: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
p, err := scanGroupCallParticipant(rows)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, err
|
||||
}
|
||||
out.ParticipantsChanged = append(out.ParticipantsChanged, p)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, err
|
||||
}
|
||||
if len(out.ParticipantsChanged) > 0 {
|
||||
call, err = bumpGroupCallParticipantsTx(ctx, tx, req.CallID, -len(out.ParticipantsChanged), req.Now)
|
||||
if err != nil {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, err
|
||||
}
|
||||
out.Call = call
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, fmt.Errorf("commit conference participant removal: %w", err)
|
||||
}
|
||||
committed = true
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) DiscardGroupCall(ctx context.Context, callID int64, now int) (domain.GroupCall, []domain.GroupCallParticipant, error) {
|
||||
tx, err := s.begin(ctx, "discard group call")
|
||||
if err != nil {
|
||||
|
|
@ -562,8 +851,23 @@ WHERE call_id = $1 AND NOT left_call`, callID, now); err != nil {
|
|||
return out, fmt.Errorf("reset group call participants: %w", err)
|
||||
}
|
||||
call, err := scanGroupCall(tx.QueryRow(ctx, `
|
||||
UPDATE group_calls SET participants_count = 0, version = version + 1
|
||||
WHERE call_id = $1 RETURNING `+groupCallColumns, callID))
|
||||
UPDATE group_calls
|
||||
SET participants_count = 0,
|
||||
version = version + 1,
|
||||
state = CASE
|
||||
WHEN kind = 'conference' AND state = 'active' THEN 'discarded'
|
||||
ELSE state
|
||||
END,
|
||||
discarded_at = CASE
|
||||
WHEN kind = 'conference' AND state = 'active' THEN $2
|
||||
ELSE discarded_at
|
||||
END,
|
||||
duration = CASE
|
||||
WHEN kind = 'conference' AND state = 'active' THEN GREATEST(0, $2 - created_at)
|
||||
ELSE duration
|
||||
END
|
||||
WHERE call_id = $1
|
||||
RETURNING `+groupCallColumns, callID, now))
|
||||
if err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
return out, fmt.Errorf("reset group call version: %w", err)
|
||||
|
|
@ -613,6 +917,13 @@ func nullableJSON(b []byte) any {
|
|||
return b
|
||||
}
|
||||
|
||||
func nullableGroupCallBytes(b []byte) any {
|
||||
if len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func parseGroupCallOffset(offset string) (joinDate int, userID int64, ok bool) {
|
||||
if offset == "" {
|
||||
return 0, 0, false
|
||||
|
|
@ -673,3 +984,216 @@ func (s *GroupCallStore) GetParticipantOverride(ctx context.Context, callID, set
|
|||
}
|
||||
return ov, true, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) CreateConferenceInvite(ctx context.Context, invite domain.GroupCallInvite) (domain.GroupCallInvite, error) {
|
||||
if invite.CallID == 0 || invite.InviterUserID == 0 || invite.InviteeUserID == 0 || invite.MessageID == 0 {
|
||||
return domain.GroupCallInvite{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
if invite.Status == "" {
|
||||
invite.Status = domain.GroupCallInvitePending
|
||||
}
|
||||
var status string
|
||||
err := s.db.QueryRow(ctx, `
|
||||
INSERT INTO group_call_invites (call_id, inviter_user_id, invitee_user_id, message_id, status, video, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (call_id, invitee_user_id, message_id) DO UPDATE SET
|
||||
inviter_user_id = EXCLUDED.inviter_user_id,
|
||||
video = EXCLUDED.video
|
||||
RETURNING call_id, inviter_user_id, invitee_user_id, message_id, status, video, created_at, updated_at`,
|
||||
invite.CallID, invite.InviterUserID, invite.InviteeUserID, invite.MessageID, string(invite.Status), invite.Video, invite.CreatedAt, invite.UpdatedAt,
|
||||
).Scan(&invite.CallID, &invite.InviterUserID, &invite.InviteeUserID, &invite.MessageID, &status, &invite.Video, &invite.CreatedAt, &invite.UpdatedAt)
|
||||
if err != nil {
|
||||
return domain.GroupCallInvite{}, fmt.Errorf("create conference invite: %w", err)
|
||||
}
|
||||
invite.Status = domain.GroupCallInviteStatus(status)
|
||||
return invite, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) SetConferenceInviteStatus(ctx context.Context, callID int64, inviteeUserID int64, msgID int, status domain.GroupCallInviteStatus, now int) (domain.GroupCallInvite, bool, error) {
|
||||
var inv domain.GroupCallInvite
|
||||
var newStatus string
|
||||
err := s.db.QueryRow(ctx, `
|
||||
UPDATE group_call_invites
|
||||
SET status = $4, updated_at = $5
|
||||
WHERE call_id = $1 AND invitee_user_id = $2 AND message_id = $3
|
||||
RETURNING call_id, inviter_user_id, invitee_user_id, message_id, status, video, created_at, updated_at`,
|
||||
callID, inviteeUserID, msgID, string(status), now,
|
||||
).Scan(&inv.CallID, &inv.InviterUserID, &inv.InviteeUserID, &inv.MessageID, &newStatus, &inv.Video, &inv.CreatedAt, &inv.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.GroupCallInvite{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.GroupCallInvite{}, false, fmt.Errorf("set conference invite status: %w", err)
|
||||
}
|
||||
inv.Status = domain.GroupCallInviteStatus(newStatus)
|
||||
return inv, true, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) ListConferenceRecipientUserIDs(ctx context.Context, callID int64) ([]int64, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
WITH c AS (
|
||||
SELECT creator_user_id, state FROM group_calls WHERE call_id = $1
|
||||
)
|
||||
SELECT creator_user_id FROM c
|
||||
UNION
|
||||
SELECT p.user_id
|
||||
FROM group_call_participants p CROSS JOIN c
|
||||
WHERE p.call_id = $1 AND (c.state <> 'active' OR NOT p.left_call)
|
||||
UNION
|
||||
SELECT i.inviter_user_id
|
||||
FROM group_call_invites i CROSS JOIN c
|
||||
WHERE i.call_id = $1 AND (c.state <> 'active' OR i.status IN ('pending', 'accepted'))
|
||||
UNION
|
||||
SELECT i.invitee_user_id
|
||||
FROM group_call_invites i CROSS JOIN c
|
||||
WHERE i.call_id = $1 AND (c.state <> 'active' OR i.status IN ('pending', 'accepted'))
|
||||
ORDER BY 1`, callID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list conference recipients: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if id != 0 {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) AppendGroupCallChainBlock(ctx context.Context, block domain.GroupCallChainBlock) (domain.GroupCallChainBlock, error) {
|
||||
if block.CallID == 0 || len(block.Block) == 0 {
|
||||
return domain.GroupCallChainBlock{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
tx, err := s.begin(ctx, "append group call chain block")
|
||||
if err != nil {
|
||||
return domain.GroupCallChainBlock{}, err
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback(ctx)
|
||||
}
|
||||
}()
|
||||
call, err := lockGroupCallTx(ctx, tx, block.CallID)
|
||||
if err != nil {
|
||||
return domain.GroupCallChainBlock{}, err
|
||||
}
|
||||
if !call.Conference() {
|
||||
return domain.GroupCallChainBlock{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
block, err = appendGroupCallChainBlockTx(ctx, tx, block)
|
||||
if err != nil {
|
||||
return domain.GroupCallChainBlock{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.GroupCallChainBlock{}, fmt.Errorf("commit append group call chain block: %w", err)
|
||||
}
|
||||
committed = true
|
||||
return block, nil
|
||||
}
|
||||
|
||||
func appendGroupCallChainBlockTx(ctx context.Context, tx pgx.Tx, block domain.GroupCallChainBlock) (domain.GroupCallChainBlock, error) {
|
||||
if block.CallID == 0 || len(block.Block) == 0 {
|
||||
return domain.GroupCallChainBlock{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
var existing domain.GroupCallChainBlock
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT call_id, sub_chain_id, block_offset, author_user_id, block, created_at
|
||||
FROM group_call_chain_blocks
|
||||
WHERE call_id = $1 AND sub_chain_id = $2 AND block = $3
|
||||
ORDER BY block_offset ASC
|
||||
LIMIT 1`, block.CallID, block.SubChainID, block.Block).Scan(
|
||||
&existing.CallID, &existing.SubChainID, &existing.Offset, &existing.AuthorUserID, &existing.Block, &existing.CreatedAt,
|
||||
)
|
||||
if err == nil {
|
||||
return domain.GroupCallChainBlock{}, domain.ErrConferenceChainInvalid
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.GroupCallChainBlock{}, fmt.Errorf("get existing group call chain block: %w", err)
|
||||
}
|
||||
var nextOffset int
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT COALESCE(MAX(block_offset) + 1, 0)
|
||||
FROM group_call_chain_blocks
|
||||
WHERE call_id = $1 AND sub_chain_id = $2`, block.CallID, block.SubChainID).Scan(&nextOffset); err != nil {
|
||||
return domain.GroupCallChainBlock{}, fmt.Errorf("next chain block offset: %w", err)
|
||||
}
|
||||
if block.Offset < 0 {
|
||||
block.Offset = nextOffset
|
||||
}
|
||||
if block.Offset != nextOffset {
|
||||
return domain.GroupCallChainBlock{}, domain.ErrConferenceChainInvalid
|
||||
}
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO group_call_chain_blocks (call_id, sub_chain_id, block_offset, author_user_id, block, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING call_id, sub_chain_id, block_offset, author_user_id, block, created_at`,
|
||||
block.CallID, block.SubChainID, block.Offset, block.AuthorUserID, block.Block, block.CreatedAt,
|
||||
).Scan(&block.CallID, &block.SubChainID, &block.Offset, &block.AuthorUserID, &block.Block, &block.CreatedAt)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return domain.GroupCallChainBlock{}, domain.ErrConferenceChainInvalid
|
||||
}
|
||||
return domain.GroupCallChainBlock{}, fmt.Errorf("append group call chain block: %w", err)
|
||||
}
|
||||
return block, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) ListGroupCallChainBlocks(ctx context.Context, callID int64, subChainID, offset, limit int) (domain.GroupCallChainBlockPage, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
if offset == domain.GroupCallChainBlockLatestOffset {
|
||||
var block domain.GroupCallChainBlock
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT call_id, sub_chain_id, block_offset, author_user_id, block, created_at
|
||||
FROM group_call_chain_blocks
|
||||
WHERE call_id = $1 AND sub_chain_id = $2
|
||||
ORDER BY block_offset DESC
|
||||
LIMIT 1`, callID, subChainID).Scan(&block.CallID, &block.SubChainID, &block.Offset, &block.AuthorUserID, &block.Block, &block.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.GroupCallChainBlockPage{NextOffset: 0}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.GroupCallChainBlockPage{}, fmt.Errorf("get latest group call chain block: %w", err)
|
||||
}
|
||||
return domain.GroupCallChainBlockPage{
|
||||
Blocks: []domain.GroupCallChainBlock{block},
|
||||
NextOffset: block.Offset + 1,
|
||||
}, nil
|
||||
}
|
||||
if offset < 0 {
|
||||
return domain.GroupCallChainBlockPage{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT call_id, sub_chain_id, block_offset, author_user_id, block, created_at
|
||||
FROM group_call_chain_blocks
|
||||
WHERE call_id = $1 AND sub_chain_id = $2 AND block_offset >= $3
|
||||
ORDER BY block_offset ASC
|
||||
LIMIT $4`, callID, subChainID, offset, limit)
|
||||
if err != nil {
|
||||
return domain.GroupCallChainBlockPage{}, fmt.Errorf("list group call chain blocks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
page := domain.GroupCallChainBlockPage{NextOffset: offset}
|
||||
for rows.Next() {
|
||||
var block domain.GroupCallChainBlock
|
||||
if err := rows.Scan(&block.CallID, &block.SubChainID, &block.Offset, &block.AuthorUserID, &block.Block, &block.CreatedAt); err != nil {
|
||||
return domain.GroupCallChainBlockPage{}, err
|
||||
}
|
||||
page.Blocks = append(page.Blocks, block)
|
||||
page.NextOffset = block.Offset + 1
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return domain.GroupCallChainBlockPage{}, err
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ package storetest
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -36,6 +38,9 @@ func RunGroupCallStoreContract(t *testing.T, factory GroupCallStoreFactory) {
|
|||
t.Run("UpdateParticipant", func(t *testing.T) { contractUpdateParticipant(t, factory) })
|
||||
t.Run("ResetAllParticipants", func(t *testing.T) { contractReset(t, factory) })
|
||||
t.Run("JoinVideoStateLifecycle", func(t *testing.T) { contractJoinVideoState(t, factory) })
|
||||
t.Run("ConferenceChainBlocks", func(t *testing.T) { contractConferenceChainBlocks(t, factory) })
|
||||
t.Run("ConferenceRecipientsTerminalAccess", func(t *testing.T) { contractConferenceRecipientsTerminalAccess(t, factory) })
|
||||
t.Run("ConferenceEmptyDiscards", func(t *testing.T) { contractConferenceEmptyDiscards(t, factory) })
|
||||
}
|
||||
|
||||
func newContractCall(t *testing.T, st store.GroupCallStore, channelID, id int64) domain.GroupCall {
|
||||
|
|
@ -134,7 +139,7 @@ func contractSSRC(t *testing.T, factory GroupCallStoreFactory) {
|
|||
}
|
||||
|
||||
// contractJoinVideoState:join 携带 VideoJSON 整体替换、rejoin 清空 presentation
|
||||
//(主连接 rejoin 后客户端会重发 joinGroupCallPresentation,旧屏幕登记必须作废)。
|
||||
// (主连接 rejoin 后客户端会重发 joinGroupCallPresentation,旧屏幕登记必须作废)。
|
||||
func contractJoinVideoState(t *testing.T, factory GroupCallStoreFactory) {
|
||||
st, channelID := factory(t)
|
||||
ctx := context.Background()
|
||||
|
|
@ -328,6 +333,157 @@ func contractReset(t *testing.T, factory GroupCallStoreFactory) {
|
|||
}
|
||||
}
|
||||
|
||||
func contractConferenceChainBlocks(t *testing.T, factory GroupCallStoreFactory) {
|
||||
st, channelID := factory(t)
|
||||
ctx := context.Background()
|
||||
now := baseNow()
|
||||
slug := fmt.Sprintf("contract-chain-%d", channelID)
|
||||
call, err := st.CreateConferenceCall(ctx, domain.GroupCall{
|
||||
ID: channelID*100 + 51, AccessHash: channelID*100 + 58, CreatorUserID: 1,
|
||||
InviteSlug: slug, InviteLink: "https://telesrv.net/call/" + slug + "?slug=" + slug,
|
||||
RandomID: channelID*100 + 51, CreatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference call: %v", err)
|
||||
}
|
||||
firstBlock := []byte("same-chain-block")
|
||||
first, err := st.AppendGroupCallChainBlock(ctx, domain.GroupCallChainBlock{
|
||||
CallID: call.ID, SubChainID: 0, Offset: -1, Block: firstBlock, CreatedAt: now,
|
||||
})
|
||||
if err != nil || first.Offset != 0 {
|
||||
t.Fatalf("append first chain block = %+v err=%v", first, err)
|
||||
}
|
||||
dup, err := st.AppendGroupCallChainBlock(ctx, domain.GroupCallChainBlock{
|
||||
CallID: call.ID, SubChainID: 0, Offset: -1, Block: append([]byte(nil), firstBlock...), CreatedAt: now + 1,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrConferenceChainInvalid) {
|
||||
t.Fatalf("append duplicate chain block = %+v err=%v, want ErrConferenceChainInvalid", dup, err)
|
||||
}
|
||||
secondBlock := []byte("next-chain-block")
|
||||
second, err := st.AppendGroupCallChainBlock(ctx, domain.GroupCallChainBlock{
|
||||
CallID: call.ID, SubChainID: 0, Offset: -1, Block: secondBlock, CreatedAt: now + 2,
|
||||
})
|
||||
if err != nil || second.Offset != 1 {
|
||||
t.Fatalf("append second chain block = %+v err=%v", second, err)
|
||||
}
|
||||
if _, err := st.AppendGroupCallChainBlock(ctx, domain.GroupCallChainBlock{
|
||||
CallID: call.ID, SubChainID: 0, Offset: 0, Block: []byte("stale-offset-block"), CreatedAt: now + 3,
|
||||
}); !errors.Is(err, domain.ErrConferenceChainInvalid) {
|
||||
t.Fatalf("append stale offset chain block err=%v, want ErrConferenceChainInvalid", err)
|
||||
}
|
||||
page, err := st.ListGroupCallChainBlocks(ctx, call.ID, 0, 0, 10)
|
||||
if err != nil || page.NextOffset != 2 || len(page.Blocks) != 2 {
|
||||
t.Fatalf("list chain blocks = %+v err=%v", page, err)
|
||||
}
|
||||
latest, err := st.ListGroupCallChainBlocks(ctx, call.ID, 0, domain.GroupCallChainBlockLatestOffset, 1)
|
||||
if err != nil || latest.NextOffset != 2 || len(latest.Blocks) != 1 || string(latest.Blocks[0].Block) != string(secondBlock) {
|
||||
t.Fatalf("latest chain block = %+v err=%v", latest, err)
|
||||
}
|
||||
}
|
||||
|
||||
func contractConferenceRecipientsTerminalAccess(t *testing.T, factory GroupCallStoreFactory) {
|
||||
st, channelID := factory(t)
|
||||
ctx := context.Background()
|
||||
now := baseNow()
|
||||
slug := fmt.Sprintf("contract-recipient-%d", channelID)
|
||||
call, err := st.CreateConferenceCall(ctx, domain.GroupCall{
|
||||
ID: channelID*100 + 61, AccessHash: channelID*100 + 68, CreatorUserID: 1,
|
||||
InviteSlug: slug, InviteLink: "https://telesrv.net/call/" + slug + "?slug=" + slug,
|
||||
RandomID: channelID*100 + 61, CreatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference call: %v", err)
|
||||
}
|
||||
join(t, st, call.ID, 2, 7102, now+1)
|
||||
join(t, st, call.ID, 3, 7103, now+2)
|
||||
if _, err := st.LeaveGroupCall(ctx, call.ID, 3, now+3); err != nil {
|
||||
t.Fatalf("leave historical participant: %v", err)
|
||||
}
|
||||
if _, err := st.CreateConferenceInvite(ctx, domain.GroupCallInvite{
|
||||
CallID: call.ID, InviterUserID: 1, InviteeUserID: 4, MessageID: 401,
|
||||
Status: domain.GroupCallInvitePending, CreatedAt: now + 4,
|
||||
}); err != nil {
|
||||
t.Fatalf("create pending invite: %v", err)
|
||||
}
|
||||
if _, err := st.CreateConferenceInvite(ctx, domain.GroupCallInvite{
|
||||
CallID: call.ID, InviterUserID: 1, InviteeUserID: 5, MessageID: 501,
|
||||
Status: domain.GroupCallInviteDeclined, CreatedAt: now + 5, UpdatedAt: now + 5,
|
||||
}); err != nil {
|
||||
t.Fatalf("create declined invite: %v", err)
|
||||
}
|
||||
activeRecipients, err := st.ListConferenceRecipientUserIDs(ctx, call.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("active recipients: %v", err)
|
||||
}
|
||||
if want := []int64{1, 2, 4}; !reflect.DeepEqual(activeRecipients, want) {
|
||||
t.Fatalf("active recipients = %v, want %v", activeRecipients, want)
|
||||
}
|
||||
if _, _, err := st.DiscardGroupCall(ctx, call.ID, now+10); err != nil {
|
||||
t.Fatalf("discard conference: %v", err)
|
||||
}
|
||||
discardedRecipients, err := st.ListConferenceRecipientUserIDs(ctx, call.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("discarded recipients: %v", err)
|
||||
}
|
||||
if want := []int64{1, 2, 3, 4, 5}; !reflect.DeepEqual(discardedRecipients, want) {
|
||||
t.Fatalf("discarded recipients = %v, want %v", discardedRecipients, want)
|
||||
}
|
||||
}
|
||||
|
||||
func contractConferenceEmptyDiscards(t *testing.T, factory GroupCallStoreFactory) {
|
||||
st, channelID := factory(t)
|
||||
ctx := context.Background()
|
||||
now := baseNow()
|
||||
call, err := st.CreateConferenceCall(ctx, domain.GroupCall{
|
||||
ID: channelID*100 + 71, AccessHash: channelID*100 + 78, CreatorUserID: 1,
|
||||
InviteSlug: fmt.Sprintf("contract-empty-%d", channelID),
|
||||
InviteLink: fmt.Sprintf("https://telesrv.net/call/contract-empty-%d?slug=contract-empty-%d", channelID, channelID),
|
||||
RandomID: channelID*100 + 71,
|
||||
CreatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference call: %v", err)
|
||||
}
|
||||
join(t, st, call.ID, 1, 7201, now+1)
|
||||
join(t, st, call.ID, 2, 7202, now+2)
|
||||
firstLeave, err := st.LeaveGroupCall(ctx, call.ID, 2, now+3)
|
||||
if err != nil {
|
||||
t.Fatalf("first leave conference: %v", err)
|
||||
}
|
||||
if !firstLeave.Call.Active() || firstLeave.Call.ParticipantsCount != 1 {
|
||||
t.Fatalf("first leave call = %+v, want still active with one participant", firstLeave.Call)
|
||||
}
|
||||
lastLeave, err := st.LeaveGroupCall(ctx, call.ID, 1, now+4)
|
||||
if err != nil {
|
||||
t.Fatalf("last leave conference: %v", err)
|
||||
}
|
||||
if lastLeave.Call.Active() || lastLeave.Call.ParticipantsCount != 0 || lastLeave.Call.DiscardedAt != now+4 {
|
||||
t.Fatalf("last leave call = %+v, want discarded empty conference", lastLeave.Call)
|
||||
}
|
||||
if _, err := st.JoinGroupCall(ctx, domain.JoinGroupCallRequest{CallID: call.ID, UserID: 3, SSRC: 7203, Now: now + 5}); !errors.Is(err, domain.ErrGroupCallDiscarded) {
|
||||
t.Fatalf("join empty discarded conference err = %v, want ErrGroupCallDiscarded", err)
|
||||
}
|
||||
|
||||
resetCall, err := st.CreateConferenceCall(ctx, domain.GroupCall{
|
||||
ID: channelID*100 + 81, AccessHash: channelID*100 + 88, CreatorUserID: 1,
|
||||
InviteSlug: fmt.Sprintf("contract-reset-empty-%d", channelID),
|
||||
InviteLink: fmt.Sprintf("https://telesrv.net/call/contract-reset-empty-%d?slug=contract-reset-empty-%d", channelID, channelID),
|
||||
RandomID: channelID*100 + 81,
|
||||
CreatedAt: now + 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create reset conference call: %v", err)
|
||||
}
|
||||
join(t, st, resetCall.ID, 1, 7301, now+11)
|
||||
reset, err := st.ResetAllParticipants(ctx, now+12)
|
||||
if err != nil || len(reset) != 1 {
|
||||
t.Fatalf("reset conferences = %+v err=%v, want one affected call", reset, err)
|
||||
}
|
||||
if reset[0].ID != resetCall.ID || reset[0].Active() || reset[0].ParticipantsCount != 0 {
|
||||
t.Fatalf("reset conference call = %+v, want discarded empty conference", reset[0])
|
||||
}
|
||||
}
|
||||
|
||||
// 以下为 M2 契约:per-viewer overrides 与举手序号(追加进 RunGroupCallStoreContract
|
||||
// 之外单独可调,避免改既有签名——两实现测试各自调用 RunGroupCallStoreM2Contract)。
|
||||
|
||||
|
|
|
|||
146
scripts/analyze-media-download-log.ps1
Normal file
146
scripts/analyze-media-download-log.ps1
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
<#
|
||||
.SYNOPSIS
|
||||
Summarizes upload.getFile requests in telesrv logs.
|
||||
|
||||
.DESCRIPTION
|
||||
Use this after opening media history in TDesktop or Android. It groups
|
||||
upload.getFile RPCs by client_type/app_version and reports request count and
|
||||
duration percentiles. Pass -SinceLine from a previous baseline if desired.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ServerLogPath,
|
||||
[int]$SinceLine = 0,
|
||||
[int]$Tail = 0,
|
||||
[switch]$ShowSamples
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
|
||||
if (-not $ServerLogPath) {
|
||||
$latestLog = Get-ChildItem (Join-Path $RepoRoot "logs") -Filter "telesrv-*.err.log" -ErrorAction SilentlyContinue |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
if ($latestLog) {
|
||||
$ServerLogPath = $latestLog.FullName
|
||||
} else {
|
||||
$ServerLogPath = Join-Path $RepoRoot "logs\telesrv.err.log"
|
||||
}
|
||||
}
|
||||
|
||||
function Read-SharedLogLines {
|
||||
if (-not (Test-Path -LiteralPath $ServerLogPath)) {
|
||||
return @()
|
||||
}
|
||||
$stream = [System.IO.File]::Open($ServerLogPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
|
||||
try {
|
||||
$reader = New-Object System.IO.StreamReader($stream)
|
||||
try {
|
||||
$lines = New-Object System.Collections.Generic.List[string]
|
||||
while (-not $reader.EndOfStream) {
|
||||
$lines.Add($reader.ReadLine()) | Out-Null
|
||||
}
|
||||
return $lines
|
||||
} finally {
|
||||
$reader.Dispose()
|
||||
}
|
||||
} finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Get-Field {
|
||||
param([string]$Line, [string]$Name, [string]$Default = "")
|
||||
if ($Line -match ('"' + [regex]::Escape($Name) + '"\s*:\s*"([^"]*)"')) {
|
||||
return $Matches[1]
|
||||
}
|
||||
if ($Line -match ('"' + [regex]::Escape($Name) + '"\s*:\s*([^,}]+)')) {
|
||||
return $Matches[1]
|
||||
}
|
||||
return $Default
|
||||
}
|
||||
|
||||
function Convert-DurationMs([string]$Text) {
|
||||
if (-not $Text) { return 0.0 }
|
||||
if ($Text -match '^([0-9.]+)ms$') { return [double]$Matches[1] }
|
||||
if ($Text -match '^([0-9.]+)s$') { return [double]$Matches[1] * 1000.0 }
|
||||
if ($Text -match '^([0-9.]+)µs$') { return [double]$Matches[1] / 1000.0 }
|
||||
if ($Text -match '^([0-9.]+)us$') { return [double]$Matches[1] / 1000.0 }
|
||||
if ($Text -match '^([0-9.]+)ns$') { return [double]$Matches[1] / 1000000.0 }
|
||||
return 0.0
|
||||
}
|
||||
|
||||
function Percentile {
|
||||
param([double[]]$Values, [double]$P)
|
||||
if ($Values.Count -eq 0) { return 0.0 }
|
||||
$sorted = @($Values | Sort-Object)
|
||||
$idx = [int][Math]::Ceiling($P * $sorted.Count) - 1
|
||||
if ($idx -lt 0) { $idx = 0 }
|
||||
if ($idx -ge $sorted.Count) { $idx = $sorted.Count - 1 }
|
||||
return [double]$sorted[$idx]
|
||||
}
|
||||
|
||||
$allLines = @(Read-SharedLogLines)
|
||||
$lineCount = $allLines.Count
|
||||
$lines = $allLines
|
||||
if ($SinceLine -gt 0) {
|
||||
$lines = @($lines | Select-Object -Skip $SinceLine)
|
||||
}
|
||||
if ($Tail -gt 0) {
|
||||
$lines = @($lines | Select-Object -Last $Tail)
|
||||
}
|
||||
|
||||
$items = New-Object System.Collections.Generic.List[object]
|
||||
foreach ($line in $lines) {
|
||||
if ($line -notlike "*upload.getFile*") {
|
||||
continue
|
||||
}
|
||||
$method = Get-Field $line "method"
|
||||
if ($method -notlike "upload.getFile*") {
|
||||
continue
|
||||
}
|
||||
$client = Get-Field $line "client_type" "unknown"
|
||||
$app = Get-Field $line "app_version" ""
|
||||
$dur = Convert-DurationMs (Get-Field $line "dur" "0ms")
|
||||
$items.Add([pscustomobject]@{
|
||||
Client = $client
|
||||
AppVersion = $app
|
||||
DurationMs = $dur
|
||||
Line = $line
|
||||
}) | Out-Null
|
||||
}
|
||||
|
||||
Write-Host "log=$ServerLogPath"
|
||||
Write-Host "total_lines=$lineCount analyzed_lines=$($lines.Count) since_line=$SinceLine upload_get_file=$($items.Count)"
|
||||
Write-Host ""
|
||||
|
||||
if ($items.Count -eq 0) {
|
||||
Write-Host "No upload.getFile entries found."
|
||||
exit 0
|
||||
}
|
||||
|
||||
$groups = $items | Group-Object Client, AppVersion
|
||||
foreach ($group in $groups) {
|
||||
$values = @($group.Group | ForEach-Object { [double]$_.DurationMs })
|
||||
$sum = 0.0
|
||||
foreach ($v in $values) { $sum += $v }
|
||||
$avg = $sum / [Math]::Max(1, $values.Count)
|
||||
[pscustomobject]@{
|
||||
Client = ($group.Group[0].Client)
|
||||
AppVersion = ($group.Group[0].AppVersion)
|
||||
Count = $values.Count
|
||||
AvgMs = [Math]::Round($avg, 3)
|
||||
P50Ms = [Math]::Round((Percentile $values 0.50), 3)
|
||||
P95Ms = [Math]::Round((Percentile $values 0.95), 3)
|
||||
P99Ms = [Math]::Round((Percentile $values 0.99), 3)
|
||||
MaxMs = [Math]::Round((($values | Measure-Object -Maximum).Maximum), 3)
|
||||
}
|
||||
}
|
||||
|
||||
if ($ShowSamples) {
|
||||
Write-Host ""
|
||||
Write-Host "Samples:"
|
||||
$items | Select-Object -Last 20 | ForEach-Object { Write-Host $_.Line }
|
||||
}
|
||||
257
scripts/check-local-runtime.ps1
Normal file
257
scripts/check-local-runtime.ps1
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
<#
|
||||
.SYNOPSIS
|
||||
Checks the local telesrv runtime state.
|
||||
|
||||
.DESCRIPTION
|
||||
Reports the listening PID/process, git commit, schema version, MTProto port,
|
||||
Android connection/package status, and recent server log errors. The script is
|
||||
read-only and is intended to run before/after Android and TDesktop validation.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[int]$Port = 2398,
|
||||
[string]$ServerLogPath,
|
||||
[string]$AndroidPackage = "org.telegram.messenger.beta",
|
||||
[string]$DeviceSerial,
|
||||
[string]$PostgresContainer = "telesrv-postgres",
|
||||
[string]$Database = "telesrv",
|
||||
[string]$DbUser = "telesrv",
|
||||
[int]$RecentLogLines = 1200,
|
||||
[switch]$SkipAdb
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
|
||||
if (-not $ServerLogPath) {
|
||||
$latestLog = Get-ChildItem (Join-Path $RepoRoot "logs") -Filter "telesrv-*.err.log" -ErrorAction SilentlyContinue |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
if ($latestLog) {
|
||||
$ServerLogPath = $latestLog.FullName
|
||||
} else {
|
||||
$ServerLogPath = Join-Path $RepoRoot "logs\telesrv.err.log"
|
||||
}
|
||||
}
|
||||
|
||||
$Failures = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
function Write-Step([string]$Message) {
|
||||
Write-Host ""
|
||||
Write-Host "== $Message =="
|
||||
}
|
||||
|
||||
function Add-Failure([string]$Message) {
|
||||
$script:Failures.Add($Message) | Out-Null
|
||||
Write-Host "[fail] $Message"
|
||||
}
|
||||
|
||||
function Write-Ok([string]$Message) {
|
||||
Write-Host "[ok] $Message"
|
||||
}
|
||||
|
||||
function Invoke-External {
|
||||
param(
|
||||
[string]$FilePath,
|
||||
[string[]]$Arguments,
|
||||
[switch]$AllowFailure
|
||||
)
|
||||
$oldErrorActionPreference = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
try {
|
||||
$output = & $FilePath @Arguments 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
} finally {
|
||||
$ErrorActionPreference = $oldErrorActionPreference
|
||||
}
|
||||
$text = ($output | ForEach-Object { $_.ToString() }) -join "`n"
|
||||
if ($exitCode -ne 0 -and -not $AllowFailure) {
|
||||
throw "$FilePath $($Arguments -join ' ') failed with exit code ${exitCode}:`n$text"
|
||||
}
|
||||
[pscustomobject]@{ ExitCode = $exitCode; Output = $text }
|
||||
}
|
||||
|
||||
function Invoke-PsqlScalar([string]$Sql) {
|
||||
$result = Invoke-External "docker" @(
|
||||
"exec", $PostgresContainer,
|
||||
"psql", "-U", $DbUser, "-d", $Database,
|
||||
"-v", "ON_ERROR_STOP=1",
|
||||
"-At", "-c", $Sql
|
||||
) -AllowFailure
|
||||
if ($result.ExitCode -ne 0) {
|
||||
Add-Failure "PostgreSQL query failed: $($result.Output)"
|
||||
return ""
|
||||
}
|
||||
return $result.Output.Trim()
|
||||
}
|
||||
|
||||
function Read-SharedLogLines {
|
||||
if (-not (Test-Path -LiteralPath $ServerLogPath)) {
|
||||
return @()
|
||||
}
|
||||
$stream = [System.IO.File]::Open($ServerLogPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
|
||||
try {
|
||||
$reader = New-Object System.IO.StreamReader($stream)
|
||||
try {
|
||||
$lines = New-Object System.Collections.Generic.List[string]
|
||||
while (-not $reader.EndOfStream) {
|
||||
$lines.Add($reader.ReadLine()) | Out-Null
|
||||
}
|
||||
return $lines
|
||||
} finally {
|
||||
$reader.Dispose()
|
||||
}
|
||||
} finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Get-AdbArgs([string[]]$Arguments) {
|
||||
if ($DeviceSerial) {
|
||||
return @("-s", $DeviceSerial) + $Arguments
|
||||
}
|
||||
return $Arguments
|
||||
}
|
||||
|
||||
function Invoke-Adb([string[]]$Arguments, [switch]$AllowFailure) {
|
||||
Invoke-External "adb" (Get-AdbArgs $Arguments) -AllowFailure:$AllowFailure
|
||||
}
|
||||
|
||||
function Get-JsonFieldFromLog {
|
||||
param([string[]]$Lines, [string]$Field)
|
||||
for ($i = $Lines.Count - 1; $i -ge 0; $i--) {
|
||||
if ($Lines[$i] -match ('"' + [regex]::Escape($Field) + '"\s*:\s*"?([^",}]+)"?')) {
|
||||
return $Matches[1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
Write-Step "Git"
|
||||
Push-Location $RepoRoot
|
||||
try {
|
||||
$head = (Invoke-External "git" @("rev-parse", "HEAD") -AllowFailure).Output.Trim()
|
||||
$branch = (Invoke-External "git" @("branch", "--show-current") -AllowFailure).Output.Trim()
|
||||
$dirty = (Invoke-External "git" @("status", "--porcelain", "--untracked-files=no") -AllowFailure).Output.Trim()
|
||||
Write-Host "branch=$branch"
|
||||
Write-Host "head=$head"
|
||||
if ($dirty) {
|
||||
Write-Host "tree_state=dirty"
|
||||
} else {
|
||||
Write-Host "tree_state=clean"
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Write-Step "Process and Port"
|
||||
$listeners = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue)
|
||||
if ($listeners.Count -eq 0) {
|
||||
Add-Failure "no process is listening on port $Port"
|
||||
} else {
|
||||
foreach ($ownerPid in @($listeners | Select-Object -ExpandProperty OwningProcess -Unique)) {
|
||||
$proc = Get-Process -Id $ownerPid -ErrorAction SilentlyContinue
|
||||
if ($proc) {
|
||||
$path = $null
|
||||
try { $path = $proc.Path } catch { $path = "" }
|
||||
Write-Host ("pid={0} name={1} start={2} path={3}" -f $proc.Id, $proc.ProcessName, $proc.StartTime, $path)
|
||||
} else {
|
||||
Write-Host "pid=$ownerPid"
|
||||
}
|
||||
}
|
||||
Write-Ok "port $Port is listening"
|
||||
}
|
||||
$established = @(Get-NetTCPConnection -LocalPort $Port -State Established -ErrorAction SilentlyContinue)
|
||||
Write-Host "established_connections=$($established.Count)"
|
||||
foreach ($conn in $established | Select-Object -First 12) {
|
||||
Write-Host (" {0}:{1} -> {2}:{3} pid={4}" -f $conn.LocalAddress, $conn.LocalPort, $conn.RemoteAddress, $conn.RemotePort, $conn.OwningProcess)
|
||||
}
|
||||
|
||||
Write-Step "PostgreSQL Schema"
|
||||
$schema = Invoke-PsqlScalar "SELECT version::text || '|' || dirty::text FROM schema_migrations ORDER BY version DESC LIMIT 1;"
|
||||
if ($schema) {
|
||||
$parts = $schema -split "\|", 2
|
||||
Write-Host "schema_version=$($parts[0])"
|
||||
Write-Host "schema_dirty=$($parts[1])"
|
||||
if ($parts.Count -gt 1 -and $parts[1] -eq "f") {
|
||||
Write-Ok "schema is clean"
|
||||
} else {
|
||||
Add-Failure "schema_migrations is dirty"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Step "Server Log"
|
||||
Write-Host "log=$ServerLogPath"
|
||||
$lines = @(Read-SharedLogLines)
|
||||
if ($lines.Count -eq 0) {
|
||||
Add-Failure "server log is missing or empty"
|
||||
} else {
|
||||
$readyLines = @($lines | Where-Object { $_ -like "*telesrv 服务就绪*" })
|
||||
if ($readyLines.Count -gt 0) {
|
||||
$ready = @($readyLines)[-1]
|
||||
Write-Host $ready
|
||||
$runtimeCommit = Get-JsonFieldFromLog @($ready) "git_commit"
|
||||
$runtimeSchema = Get-JsonFieldFromLog @($ready) "schema_version"
|
||||
$runtimePID = Get-JsonFieldFromLog @($ready) "pid"
|
||||
Write-Host "runtime_git_commit=$runtimeCommit"
|
||||
Write-Host "runtime_schema_version=$runtimeSchema"
|
||||
Write-Host "runtime_pid=$runtimePID"
|
||||
if ($head -and $runtimeCommit -and $runtimeCommit -ne $head) {
|
||||
Add-Failure "runtime git_commit $runtimeCommit != HEAD $head"
|
||||
} elseif ($runtimeCommit) {
|
||||
Write-Ok "runtime commit matches HEAD"
|
||||
}
|
||||
} else {
|
||||
Add-Failure "server log has no 'telesrv 服务就绪' line"
|
||||
}
|
||||
$recent = @($lines | Select-Object -Last $RecentLogLines)
|
||||
$bad = @($recent | Where-Object {
|
||||
$_ -cmatch "INTERNAL_SERVER_ERROR|Unhandled RPC|NOT_IMPLEMENTED|bad_msg|panic|\tERROR\t"
|
||||
})
|
||||
if ($bad.Count -eq 0) {
|
||||
Write-Ok "recent log has no internal/unhandled/bad_msg errors"
|
||||
} else {
|
||||
Add-Failure "recent log has $($bad.Count) suspicious error lines"
|
||||
$bad | Select-Object -Last 40 | ForEach-Object { Write-Host $_ }
|
||||
}
|
||||
}
|
||||
|
||||
Write-Step "Android"
|
||||
if ($SkipAdb) {
|
||||
Write-Host "adb checks skipped"
|
||||
} else {
|
||||
$adb = Get-Command adb -ErrorAction SilentlyContinue
|
||||
if (-not $adb) {
|
||||
Add-Failure "adb is not available"
|
||||
} else {
|
||||
$devices = Invoke-Adb @("devices") -AllowFailure
|
||||
$deviceLines = @($devices.Output -split "`r?`n" | Where-Object { $_ -match "\tdevice$" })
|
||||
Write-Host "adb_devices=$($deviceLines.Count)"
|
||||
if ($deviceLines.Count -lt 1) {
|
||||
Add-Failure "no adb device connected"
|
||||
} elseif ($deviceLines.Count -gt 1 -and -not $DeviceSerial) {
|
||||
Add-Failure "multiple adb devices; pass -DeviceSerial"
|
||||
} else {
|
||||
$model = (Invoke-Adb @("shell", "getprop", "ro.product.model") -AllowFailure).Output.Trim()
|
||||
$sdk = (Invoke-Adb @("shell", "getprop", "ro.build.version.sdk") -AllowFailure).Output.Trim()
|
||||
$pkg = Invoke-Adb @("shell", "dumpsys", "package", $AndroidPackage) -AllowFailure
|
||||
Write-Host "device_model=$model sdk=$sdk"
|
||||
if ($pkg.Output -match "versionName=([^\r\n]+)") {
|
||||
Write-Ok "Android package $AndroidPackage installed version=$($Matches[1])"
|
||||
} else {
|
||||
Add-Failure "Android package $AndroidPackage not found"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
if ($Failures.Count -gt 0) {
|
||||
Write-Host "Runtime check failed:"
|
||||
foreach ($failure in $Failures) {
|
||||
Write-Host " - $failure"
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Runtime check passed."
|
||||
347
scripts/restart-local-server.ps1
Normal file
347
scripts/restart-local-server.ps1
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
<#
|
||||
.SYNOPSIS
|
||||
Builds and restarts the local telesrv process with explicit runtime logs.
|
||||
|
||||
.DESCRIPTION
|
||||
This helper is meant for Windows development loops. It builds the current
|
||||
workspace into a staging executable, stops the repo-local process currently
|
||||
listening on the configured MTProto port, promotes the new executable, starts it
|
||||
hidden, and verifies that the port is listening again.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Listen = "0.0.0.0:2398",
|
||||
[string]$AdvertiseIP,
|
||||
[string]$ExePath,
|
||||
[string]$LogDir,
|
||||
[int]$HealthTimeoutSeconds = 20,
|
||||
[int]$Tail = 80,
|
||||
[switch]$SkipBuild,
|
||||
[switch]$NoStart
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
|
||||
if (-not $ExePath) {
|
||||
$ExePath = Join-Path $RepoRoot "bin\telesrv.exe"
|
||||
}
|
||||
if (-not $LogDir) {
|
||||
$LogDir = Join-Path $RepoRoot "logs"
|
||||
}
|
||||
|
||||
$ExePath = [System.IO.Path]::GetFullPath($ExePath)
|
||||
$LogDir = [System.IO.Path]::GetFullPath($LogDir)
|
||||
$BinDir = Split-Path -Parent $ExePath
|
||||
$NextExePath = Join-Path $BinDir "telesrv.next.exe"
|
||||
|
||||
function Write-Step {
|
||||
param([string]$Message)
|
||||
Write-Host ""
|
||||
Write-Host "== $Message =="
|
||||
}
|
||||
|
||||
function Invoke-External {
|
||||
param(
|
||||
[string]$FilePath,
|
||||
[string[]]$Arguments,
|
||||
[switch]$AllowFailure
|
||||
)
|
||||
$oldErrorActionPreference = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
try {
|
||||
$output = & $FilePath @Arguments 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
} finally {
|
||||
$ErrorActionPreference = $oldErrorActionPreference
|
||||
}
|
||||
$text = ($output | ForEach-Object { $_.ToString() }) -join "`n"
|
||||
if ($exitCode -ne 0 -and -not $AllowFailure) {
|
||||
throw "$FilePath $($Arguments -join ' ') failed with exit code ${exitCode}:`n$text"
|
||||
}
|
||||
[pscustomobject]@{
|
||||
ExitCode = $exitCode
|
||||
Output = $text
|
||||
}
|
||||
}
|
||||
|
||||
function Get-GitOutput {
|
||||
param([string[]]$Arguments, [string]$Default = "unknown")
|
||||
$res = Invoke-External "git" $Arguments -AllowFailure
|
||||
if ($res.ExitCode -ne 0) {
|
||||
return $Default
|
||||
}
|
||||
$text = $res.Output.Trim()
|
||||
if ($text.Length -eq 0) {
|
||||
return $Default
|
||||
}
|
||||
return $text
|
||||
}
|
||||
|
||||
function Get-ListenPort {
|
||||
param([string]$Address)
|
||||
if ($Address -match '^\[.+\]:(\d+)$') {
|
||||
return [int]$Matches[1]
|
||||
}
|
||||
if ($Address -match ':(\d+)$') {
|
||||
return [int]$Matches[1]
|
||||
}
|
||||
throw "Cannot parse listen port from '$Address'"
|
||||
}
|
||||
|
||||
function Test-PathUnderRepo {
|
||||
param([string]$Path)
|
||||
if (-not $Path) {
|
||||
return $false
|
||||
}
|
||||
$full = [System.IO.Path]::GetFullPath($Path)
|
||||
return $full.StartsWith($RepoRoot, [System.StringComparison]::OrdinalIgnoreCase)
|
||||
}
|
||||
|
||||
function Test-RepoTelesrvProcess {
|
||||
param(
|
||||
[object]$Process,
|
||||
[string]$ExePath
|
||||
)
|
||||
if (-not $Process) {
|
||||
return $false
|
||||
}
|
||||
|
||||
$path = $null
|
||||
try {
|
||||
$path = $Process.Path
|
||||
} catch {
|
||||
$path = $null
|
||||
}
|
||||
|
||||
if ($path) {
|
||||
$fullPath = [System.IO.Path]::GetFullPath($path)
|
||||
$fullExePath = [System.IO.Path]::GetFullPath($ExePath)
|
||||
$binDir = [System.IO.Path]::GetFullPath((Split-Path -Parent $fullExePath))
|
||||
$fileName = [System.IO.Path]::GetFileName($fullPath)
|
||||
|
||||
if ($fullPath.Equals($fullExePath, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
return $true
|
||||
}
|
||||
if ($fullPath.StartsWith($binDir, [System.StringComparison]::OrdinalIgnoreCase) -and ($fileName -like "telesrv*.exe*")) {
|
||||
return $true
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
# Path can be unavailable for protected or already-exiting processes. Only
|
||||
# take ownership of telesrv-looking processes in that ambiguous state.
|
||||
return (($Process.ProcessName -eq "telesrv") -or ($Process.ProcessName -like "telesrv*"))
|
||||
}
|
||||
|
||||
function Get-RepoTelesrvProcesses {
|
||||
param([int]$Port, [string]$ExePath)
|
||||
# 按 PID 去重,合并两条发现路径:
|
||||
# 1) 端口监听者——主路径,但 Get-NetTCPConnection 偶发返回空(曾漏判成 "no listener",
|
||||
# 导致旧进程没被停、promote 复制撞文件锁)。
|
||||
# 2) 按进程名/路径 + 仓库 bin 下 telesrv* 可执行文件——兜底覆盖端口漏报,并能抓到
|
||||
# “持有 telesrv.exe / telesrv.exe~ 文件锁但端口尚未就绪”的实例(promote 复制前必须停掉)。
|
||||
$foundByPid = @{}
|
||||
|
||||
$listenerPids = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique)
|
||||
foreach ($ownerPid in $listenerPids) {
|
||||
if (-not $ownerPid) {
|
||||
continue
|
||||
}
|
||||
$proc = Get-Process -Id $ownerPid -ErrorAction SilentlyContinue
|
||||
if (-not $proc) {
|
||||
continue
|
||||
}
|
||||
$path = $null
|
||||
try {
|
||||
$path = $proc.Path
|
||||
} catch {
|
||||
$path = $null
|
||||
}
|
||||
$procId = [int]$proc.Id
|
||||
$isRepoProcess = Test-RepoTelesrvProcess -Process $proc -ExePath $ExePath
|
||||
if ($isRepoProcess) {
|
||||
$foundByPid[$procId] = $proc
|
||||
} else {
|
||||
throw "Port $Port is held by non-repo process PID $($proc.Id) ($($proc.ProcessName)) at '$path'"
|
||||
}
|
||||
}
|
||||
|
||||
$candidateProcesses = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.ProcessName -eq "telesrv" -or $_.ProcessName -like "telesrv*" })
|
||||
foreach ($proc in $candidateProcesses) {
|
||||
$procId = [int]$proc.Id
|
||||
if ($foundByPid.ContainsKey($procId)) {
|
||||
continue
|
||||
}
|
||||
# 只接管仓库内的实例:路径在 repo/bin 下,或路径不可读但进程名看起来就是 telesrv(兜底)。
|
||||
# 仓库外的同名进程(用户在别处跑的)一律不动。
|
||||
$isRepoProcess = Test-RepoTelesrvProcess -Process $proc -ExePath $ExePath
|
||||
if ($isRepoProcess) {
|
||||
$foundByPid[$procId] = $proc
|
||||
}
|
||||
}
|
||||
|
||||
return @($foundByPid.Values)
|
||||
}
|
||||
|
||||
function Wait-ProcessesExited {
|
||||
param([int[]]$Pids)
|
||||
if (-not $Pids -or $Pids.Count -eq 0) {
|
||||
return
|
||||
}
|
||||
$deadline = (Get-Date).AddSeconds(10)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
$alive = @($Pids | Where-Object { Get-Process -Id $_ -ErrorAction SilentlyContinue })
|
||||
if ($alive.Count -eq 0) {
|
||||
return
|
||||
}
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
throw "Timed out waiting for old telesrv process(es) to exit: $($Pids -join ', ')"
|
||||
}
|
||||
|
||||
function Wait-PortFree {
|
||||
param([int]$Port, [int[]]$Pids)
|
||||
$deadline = (Get-Date).AddSeconds(10)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
$stillListening = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue |
|
||||
Where-Object { $Pids -contains $_.OwningProcess })
|
||||
if ($stillListening.Count -eq 0) {
|
||||
return
|
||||
}
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
throw "Timed out waiting for old telesrv listener on port $Port to stop"
|
||||
}
|
||||
|
||||
$ListenPort = Get-ListenPort $Listen
|
||||
New-Item -ItemType Directory -Force -Path $BinDir | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
|
||||
|
||||
Push-Location $RepoRoot
|
||||
try {
|
||||
if (-not $SkipBuild) {
|
||||
Write-Step "Build telesrv"
|
||||
$commit = Get-GitOutput @("rev-parse", "HEAD")
|
||||
$branch = Get-GitOutput @("branch", "--show-current")
|
||||
$dirty = Get-GitOutput @("status", "--porcelain", "--untracked-files=no") -Default ""
|
||||
$treeState = "clean"
|
||||
if ($dirty.Length -gt 0) {
|
||||
$treeState = "dirty"
|
||||
}
|
||||
$buildTime = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||
$ldflags = "-X main.gitCommit=$commit -X main.gitBranch=$branch -X main.gitTreeState=$treeState -X main.buildTime=$buildTime"
|
||||
|
||||
Remove-Item -LiteralPath $NextExePath -ErrorAction SilentlyContinue
|
||||
Invoke-External "go" @("build", "-ldflags", $ldflags, "-o", $NextExePath, ".\cmd\telesrv") | Out-Null
|
||||
Write-Host "[ok] built $NextExePath"
|
||||
Write-Host "[ok] commit=$commit branch=$branch tree=$treeState build_time=$buildTime"
|
||||
}
|
||||
|
||||
Write-Step "Stop old telesrv processes"
|
||||
$oldProcesses = @(Get-RepoTelesrvProcesses $ListenPort $ExePath)
|
||||
if ($oldProcesses.Count -eq 0) {
|
||||
Write-Host "[ok] no existing repo-local listener on port $ListenPort"
|
||||
} else {
|
||||
$oldPids = @($oldProcesses | Select-Object -ExpandProperty Id)
|
||||
foreach ($proc in $oldProcesses) {
|
||||
Write-Host "[stop] PID $($proc.Id) $($proc.ProcessName) $($proc.Path)"
|
||||
Stop-Process -Id $proc.Id -Force
|
||||
}
|
||||
Wait-ProcessesExited $oldPids
|
||||
Wait-PortFree $ListenPort $oldPids
|
||||
Write-Host "[ok] stopped old listener(s): $($oldPids -join ', ')"
|
||||
}
|
||||
|
||||
if (-not $SkipBuild) {
|
||||
Write-Step "Promote executable"
|
||||
# telesrv.exe 可能被外部 watcher/watchdog 抢先重生的实例占用文件锁;停掉持有者后短暂重试,
|
||||
# 避免直接撞 "being used by another process" 复制失败(曾因此 promote 失败)。
|
||||
$promoted = $false
|
||||
for ($attempt = 1; $attempt -le 10; $attempt++) {
|
||||
try {
|
||||
Copy-Item -LiteralPath $NextExePath -Destination $ExePath -Force -ErrorAction Stop
|
||||
$promoted = $true
|
||||
break
|
||||
} catch {
|
||||
$holders = @(Get-RepoTelesrvProcesses $ListenPort $ExePath)
|
||||
foreach ($holder in $holders) {
|
||||
Write-Host "[stop] PID $($holder.Id) holding $ExePath; retry $attempt/10"
|
||||
Stop-Process -Id $holder.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Start-Sleep -Milliseconds 300
|
||||
}
|
||||
}
|
||||
if (-not $promoted) {
|
||||
throw "Failed to promote $ExePath after retries (file kept locked; an external watcher may be respawning telesrv)"
|
||||
}
|
||||
Remove-Item -LiteralPath $NextExePath -ErrorAction SilentlyContinue
|
||||
Write-Host "[ok] promoted $ExePath"
|
||||
} elseif (-not (Test-Path -LiteralPath $ExePath)) {
|
||||
throw "Executable not found: $ExePath"
|
||||
}
|
||||
|
||||
if ($NoStart) {
|
||||
Write-Host "[ok] NoStart requested; executable is ready but not running"
|
||||
return
|
||||
}
|
||||
|
||||
Write-Step "Start telesrv"
|
||||
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||
$stdoutPath = Join-Path $LogDir "telesrv-$stamp.out.log"
|
||||
$stderrPath = Join-Path $LogDir "telesrv-$stamp.err.log"
|
||||
|
||||
$env:TELESRV_LISTEN = $Listen
|
||||
if ($AdvertiseIP) {
|
||||
$env:TELESRV_ADVERTISE_IP = $AdvertiseIP
|
||||
}
|
||||
|
||||
$proc = Start-Process -FilePath $ExePath `
|
||||
-WorkingDirectory $RepoRoot `
|
||||
-RedirectStandardOutput $stdoutPath `
|
||||
-RedirectStandardError $stderrPath `
|
||||
-PassThru `
|
||||
-WindowStyle Hidden
|
||||
|
||||
$deadline = (Get-Date).AddSeconds($HealthTimeoutSeconds)
|
||||
$listening = $false
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
$proc.Refresh()
|
||||
if ($proc.HasExited) {
|
||||
$errTail = ""
|
||||
if (Test-Path -LiteralPath $stderrPath) {
|
||||
$errTail = (Get-Content -LiteralPath $stderrPath -Tail $Tail -ErrorAction SilentlyContinue) -join "`n"
|
||||
}
|
||||
throw "telesrv exited during startup with code $($proc.ExitCode):`n$errTail"
|
||||
}
|
||||
$conn = @(Get-NetTCPConnection -LocalPort $ListenPort -State Listen -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.OwningProcess -eq $proc.Id })
|
||||
if ($conn.Count -gt 0) {
|
||||
$listening = $true
|
||||
break
|
||||
}
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
if (-not $listening) {
|
||||
throw "telesrv PID $($proc.Id) did not listen on port $ListenPort within ${HealthTimeoutSeconds}s"
|
||||
}
|
||||
|
||||
Write-Host "[ok] started PID $($proc.Id), listening on $Listen"
|
||||
Write-Host "[ok] stdout: $stdoutPath"
|
||||
Write-Host "[ok] stderr: $stderrPath"
|
||||
if (Test-Path -LiteralPath $stderrPath) {
|
||||
Get-Content -LiteralPath $stderrPath -Tail $Tail
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
Pid = $proc.Id
|
||||
Listen = $Listen
|
||||
AdvertiseIP = $env:TELESRV_ADVERTISE_IP
|
||||
Exe = $ExePath
|
||||
Stdout = $stdoutPath
|
||||
Stderr = $stderrPath
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
515
scripts/validate-android-big-video-upload.ps1
Normal file
515
scripts/validate-android-big-video-upload.ps1
Normal file
|
|
@ -0,0 +1,515 @@
|
|||
<#
|
||||
.SYNOPSIS
|
||||
Validates Android big video upload and interruption recovery.
|
||||
|
||||
.DESCRIPTION
|
||||
This helper covers the stable parts of the big-upload loop:
|
||||
fixture generation/push, baseline snapshots, optional server restart when
|
||||
upload.saveBigFilePart appears, post-send media assertions, and upload temp
|
||||
cleanup checks. It intentionally does not automate Android media picker taps.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet("Preflight", "Prepare", "BeforeSend", "WatchRestart", "AfterSend", "All")]
|
||||
[string]$Phase = "Preflight",
|
||||
|
||||
[long]$SenderUserId = 1780269504,
|
||||
[long]$RecipientUserId = 1780269505,
|
||||
|
||||
[string]$AndroidPackage = "org.telegram.messenger.beta",
|
||||
[string]$DeviceSerial,
|
||||
|
||||
[string]$PostgresContainer = "telesrv-postgres",
|
||||
[string]$Database = "telesrv",
|
||||
[string]$DbUser = "telesrv",
|
||||
|
||||
[string]$ServerLogPath,
|
||||
[string]$StatePath,
|
||||
[string]$FixtureDir,
|
||||
[string]$VideoFixture,
|
||||
[string]$BlobDir,
|
||||
[string]$RemoteMovieDir = "/sdcard/Movies/telesrv",
|
||||
[int64]$MinBigFileBytes = 12MB,
|
||||
[int]$RestartAfterParts = 2,
|
||||
[int]$WatchTimeoutSeconds = 90,
|
||||
[string]$RestartScript,
|
||||
|
||||
[switch]$SkipAdb,
|
||||
[switch]$AllowMissingThumb,
|
||||
[switch]$BuildOnRestart
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
|
||||
if (-not $ServerLogPath) {
|
||||
$latestLog = Get-ChildItem (Join-Path $RepoRoot "logs") -Filter "telesrv-*.err.log" -ErrorAction SilentlyContinue |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
if ($latestLog) {
|
||||
$ServerLogPath = $latestLog.FullName
|
||||
} else {
|
||||
$ServerLogPath = Join-Path $RepoRoot "logs\telesrv.err.log"
|
||||
}
|
||||
}
|
||||
if (-not $StatePath) {
|
||||
$StatePath = Join-Path $RepoRoot "logs\android-big-video-upload-state.json"
|
||||
}
|
||||
if (-not $FixtureDir) {
|
||||
$FixtureDir = Join-Path $RepoRoot "logs\media-fixtures"
|
||||
}
|
||||
if (-not $BlobDir) {
|
||||
$BlobDir = Join-Path $RepoRoot "data\blobs"
|
||||
}
|
||||
if (-not $RestartScript) {
|
||||
$RestartScript = Join-Path $RepoRoot "scripts\restart-local-server.ps1"
|
||||
}
|
||||
|
||||
$Failures = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
function Write-Step([string]$Message) {
|
||||
Write-Host ""
|
||||
Write-Host "== $Message =="
|
||||
}
|
||||
|
||||
function Write-Ok([string]$Message) {
|
||||
Write-Host "[ok] $Message"
|
||||
}
|
||||
|
||||
function Write-Warn([string]$Message) {
|
||||
Write-Host "[warn] $Message"
|
||||
}
|
||||
|
||||
function Add-Failure([string]$Message) {
|
||||
$script:Failures.Add($Message) | Out-Null
|
||||
Write-Host "[fail] $Message"
|
||||
}
|
||||
|
||||
function Assert-Check([bool]$Condition, [string]$Message) {
|
||||
if ($Condition) { Write-Ok $Message } else { Add-Failure $Message }
|
||||
}
|
||||
|
||||
function Invoke-External {
|
||||
param(
|
||||
[string]$FilePath,
|
||||
[string[]]$Arguments,
|
||||
[switch]$AllowFailure
|
||||
)
|
||||
$oldErrorActionPreference = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
try {
|
||||
$output = & $FilePath @Arguments 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
} finally {
|
||||
$ErrorActionPreference = $oldErrorActionPreference
|
||||
}
|
||||
$text = ($output | ForEach-Object { $_.ToString() }) -join "`n"
|
||||
if ($exitCode -ne 0 -and -not $AllowFailure) {
|
||||
throw "$FilePath $($Arguments -join ' ') failed with exit code ${exitCode}:`n$text"
|
||||
}
|
||||
[pscustomobject]@{ ExitCode = $exitCode; Output = $text }
|
||||
}
|
||||
|
||||
function Invoke-PsqlRows([string]$Sql) {
|
||||
$result = Invoke-External "docker" @(
|
||||
"exec", $PostgresContainer,
|
||||
"psql", "-U", $DbUser, "-d", $Database,
|
||||
"-v", "ON_ERROR_STOP=1",
|
||||
"-At", "-F", "|",
|
||||
"-c", $Sql
|
||||
)
|
||||
if ([string]::IsNullOrWhiteSpace($result.Output)) {
|
||||
return @()
|
||||
}
|
||||
return @($result.Output -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
|
||||
}
|
||||
|
||||
function Read-SharedLogLines([string]$Path = $ServerLogPath) {
|
||||
if (-not (Test-Path -LiteralPath $Path)) {
|
||||
return @()
|
||||
}
|
||||
$stream = [System.IO.File]::Open($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
|
||||
try {
|
||||
$reader = New-Object System.IO.StreamReader($stream)
|
||||
try {
|
||||
$lines = New-Object System.Collections.Generic.List[string]
|
||||
while (-not $reader.EndOfStream) {
|
||||
$lines.Add($reader.ReadLine()) | Out-Null
|
||||
}
|
||||
return $lines
|
||||
} finally {
|
||||
$reader.Dispose()
|
||||
}
|
||||
} finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Get-LogLineCount {
|
||||
return @(Read-SharedLogLines).Count
|
||||
}
|
||||
|
||||
function Get-LogLinesSince([int]$Skip, [string]$Path = $ServerLogPath) {
|
||||
return @(Read-SharedLogLines $Path | Select-Object -Skip $Skip)
|
||||
}
|
||||
|
||||
function Get-AdbArgs([string[]]$Arguments) {
|
||||
if ($DeviceSerial) { return @("-s", $DeviceSerial) + $Arguments }
|
||||
return $Arguments
|
||||
}
|
||||
|
||||
function Invoke-Adb([string[]]$Arguments, [switch]$AllowFailure) {
|
||||
Invoke-External "adb" (Get-AdbArgs $Arguments) -AllowFailure:$AllowFailure
|
||||
}
|
||||
|
||||
function Save-State([pscustomobject]$State) {
|
||||
$dir = Split-Path -Parent $StatePath
|
||||
if ($dir) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
|
||||
$State | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $StatePath -Encoding UTF8
|
||||
Write-Ok "state saved to $StatePath"
|
||||
}
|
||||
|
||||
function Load-State {
|
||||
if (-not (Test-Path -LiteralPath $StatePath)) {
|
||||
throw "state file not found: $StatePath. Run -Phase BeforeSend first."
|
||||
}
|
||||
Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function Get-PrivateMessageMaxId {
|
||||
$rows = @(Invoke-PsqlRows @"
|
||||
SELECT COALESCE(MAX(id), 0)
|
||||
FROM private_messages
|
||||
WHERE (sender_user_id = $SenderUserId AND recipient_user_id = $RecipientUserId)
|
||||
OR (sender_user_id = $RecipientUserId AND recipient_user_id = $SenderUserId);
|
||||
"@)
|
||||
if ($rows.Count -eq 0) { return 0 }
|
||||
return [long]$rows[0]
|
||||
}
|
||||
|
||||
function Get-UploadPartUsage {
|
||||
$rows = @(Invoke-PsqlRows @"
|
||||
SELECT COUNT(*), COALESCE(SUM(size), 0)
|
||||
FROM upload_parts
|
||||
WHERE owner_user_id = $SenderUserId;
|
||||
"@)
|
||||
if ($rows.Count -eq 0) {
|
||||
return [pscustomobject]@{ Parts = 0; Bytes = 0L }
|
||||
}
|
||||
$parts = $rows[0] -split "\|"
|
||||
return [pscustomobject]@{ Parts = [int]$parts[0]; Bytes = [long]$parts[1] }
|
||||
}
|
||||
|
||||
function Get-UploadTempStats {
|
||||
$root = Join-Path (Join-Path $BlobDir "upload_parts") ([string]$SenderUserId)
|
||||
if (-not (Test-Path -LiteralPath $root)) {
|
||||
return [pscustomobject]@{ Files = 0; Bytes = 0L }
|
||||
}
|
||||
$files = @(Get-ChildItem -LiteralPath $root -Recurse -File -ErrorAction SilentlyContinue)
|
||||
$bytes = 0L
|
||||
foreach ($file in $files) { $bytes += [long]$file.Length }
|
||||
return [pscustomobject]@{ Files = $files.Count; Bytes = $bytes }
|
||||
}
|
||||
|
||||
function Get-NewVideoMessages([long]$AfterMessageId) {
|
||||
$rows = @(Invoke-PsqlRows @"
|
||||
SELECT
|
||||
id,
|
||||
COALESCE(media->>'kind', ''),
|
||||
COALESCE(media->>'video', 'false'),
|
||||
COALESCE(media->'document'->>'id', '0'),
|
||||
COALESCE(media->'document'->>'mime_type', ''),
|
||||
COALESCE(media->'document'->>'size', '0'),
|
||||
COALESCE(jsonb_array_length(COALESCE(media->'document'->'thumbs', '[]'::jsonb)), 0)
|
||||
FROM private_messages
|
||||
WHERE id > $AfterMessageId
|
||||
AND sender_user_id = $SenderUserId
|
||||
AND recipient_user_id = $RecipientUserId
|
||||
ORDER BY id;
|
||||
"@)
|
||||
$items = @()
|
||||
foreach ($row in $rows) {
|
||||
$parts = $row -split "\|", 7
|
||||
$items += [pscustomobject]@{
|
||||
MessageId = [long]$parts[0]
|
||||
Kind = $parts[1]
|
||||
Video = $parts[2]
|
||||
DocumentId = [long]$parts[3]
|
||||
MimeType = $parts[4]
|
||||
Size = [long]$parts[5]
|
||||
ThumbCount = [int]$parts[6]
|
||||
}
|
||||
}
|
||||
return $items
|
||||
}
|
||||
|
||||
function Get-DocumentRows([long[]]$DocumentIds) {
|
||||
if ($DocumentIds.Count -eq 0) { return @() }
|
||||
$ids = ($DocumentIds | ForEach-Object { $_.ToString() }) -join ","
|
||||
$rows = @(Invoke-PsqlRows @"
|
||||
SELECT id, mime_type, size, jsonb_array_length(COALESCE(thumbs, '[]'::jsonb))
|
||||
FROM documents
|
||||
WHERE id IN ($ids)
|
||||
ORDER BY id;
|
||||
"@)
|
||||
$items = @()
|
||||
foreach ($row in $rows) {
|
||||
$parts = $row -split "\|", 4
|
||||
$items += [pscustomobject]@{
|
||||
DocumentId = [long]$parts[0]
|
||||
MimeType = $parts[1]
|
||||
Size = [long]$parts[2]
|
||||
ThumbCount = [int]$parts[3]
|
||||
}
|
||||
}
|
||||
return $items
|
||||
}
|
||||
|
||||
function Get-FileBlobRows([long[]]$DocumentIds) {
|
||||
if ($DocumentIds.Count -eq 0) { return @() }
|
||||
$keys = @()
|
||||
foreach ($docId in $DocumentIds) {
|
||||
$keys += "doc:$docId"
|
||||
$keys += "doc:${docId}:m"
|
||||
}
|
||||
$quoted = ($keys | ForEach-Object { "'" + $_.Replace("'", "''") + "'" }) -join ","
|
||||
$rows = @(Invoke-PsqlRows @"
|
||||
SELECT location_key, backend, object_key, size, mime_type
|
||||
FROM file_blobs
|
||||
WHERE location_key IN ($quoted)
|
||||
ORDER BY location_key;
|
||||
"@)
|
||||
$items = @()
|
||||
foreach ($row in $rows) {
|
||||
$parts = $row -split "\|", 5
|
||||
$items += [pscustomobject]@{
|
||||
LocationKey = $parts[0]
|
||||
Backend = $parts[1]
|
||||
ObjectKey = $parts[2]
|
||||
Size = [long]$parts[3]
|
||||
MimeType = $parts[4]
|
||||
}
|
||||
}
|
||||
return $items
|
||||
}
|
||||
|
||||
function Get-BlobFilePath([string]$ObjectKey) {
|
||||
if ($ObjectKey.Length -lt 4) {
|
||||
return Join-Path $BlobDir $ObjectKey
|
||||
}
|
||||
return Join-Path (Join-Path (Join-Path $BlobDir $ObjectKey.Substring(0, 2)) $ObjectKey.Substring(2, 2)) $ObjectKey
|
||||
}
|
||||
|
||||
function New-BigVideoFixture([string]$Path) {
|
||||
$ffmpeg = Get-Command ffmpeg -ErrorAction SilentlyContinue
|
||||
if (-not $ffmpeg) {
|
||||
Add-Failure "ffmpeg is available or -VideoFixture points at an existing >10MB mp4"
|
||||
return
|
||||
}
|
||||
Invoke-External "ffmpeg" @(
|
||||
"-y",
|
||||
"-f", "lavfi",
|
||||
"-i", "testsrc2=size=1280x720:rate=30",
|
||||
"-f", "lavfi",
|
||||
"-i", "sine=frequency=660:sample_rate=44100",
|
||||
"-t", "16",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-c:v", "libx264",
|
||||
"-preset", "ultrafast",
|
||||
"-b:v", "8M",
|
||||
"-maxrate", "8M",
|
||||
"-bufsize", "16M",
|
||||
"-c:a", "aac",
|
||||
"-shortest",
|
||||
$Path
|
||||
) | Out-Null
|
||||
}
|
||||
|
||||
function Ensure-BigVideoFixture {
|
||||
New-Item -ItemType Directory -Force -Path $FixtureDir | Out-Null
|
||||
if (-not $VideoFixture) {
|
||||
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||
$script:VideoFixture = Join-Path $FixtureDir "telesrv-android-big-video-$stamp.mp4"
|
||||
New-BigVideoFixture $script:VideoFixture
|
||||
}
|
||||
Assert-Check ($VideoFixture -and (Test-Path -LiteralPath $VideoFixture)) "video fixture exists: $VideoFixture"
|
||||
if ($VideoFixture -and (Test-Path -LiteralPath $VideoFixture)) {
|
||||
$size = (Get-Item -LiteralPath $VideoFixture).Length
|
||||
Write-Host "fixture_size=$size"
|
||||
Assert-Check ($size -ge $MinBigFileBytes) "fixture is large enough to trigger upload.saveBigFilePart"
|
||||
}
|
||||
}
|
||||
|
||||
function Push-BigVideoFixture {
|
||||
if ($SkipAdb) {
|
||||
Write-Warn "adb push skipped"
|
||||
return
|
||||
}
|
||||
Invoke-Adb @("shell", "mkdir", "-p", $RemoteMovieDir) | Out-Null
|
||||
Invoke-Adb @("push", $VideoFixture, "$RemoteMovieDir/") | Out-Null
|
||||
$leaf = Split-Path -Leaf $VideoFixture
|
||||
Invoke-Adb @("shell", "am", "broadcast", "-a", "android.intent.action.MEDIA_SCANNER_SCAN_FILE", "-d", "file://$RemoteMovieDir/$leaf") | Out-Null
|
||||
Write-Ok "big video pushed to Android: $RemoteMovieDir/$leaf"
|
||||
}
|
||||
|
||||
function Run-Preflight {
|
||||
Write-Step "Preflight"
|
||||
Assert-Check (Test-Path -LiteralPath $ServerLogPath) "server log exists: $ServerLogPath"
|
||||
Invoke-PsqlRows "SELECT 1;" | Out-Null
|
||||
Write-Ok "PostgreSQL is reachable"
|
||||
if (-not $SkipAdb) {
|
||||
Assert-Check ([bool](Get-Command adb -ErrorAction SilentlyContinue)) "adb is available"
|
||||
$devices = Invoke-Adb @("devices")
|
||||
$deviceLines = @($devices.Output -split "`r?`n" | Where-Object { $_ -match "\tdevice$" })
|
||||
Assert-Check ($deviceLines.Count -ge 1) "adb has a connected device"
|
||||
Assert-Check (($deviceLines.Count -eq 1) -or [bool]$DeviceSerial) "adb selects a single device or -DeviceSerial is set"
|
||||
if (($deviceLines.Count -eq 1) -or [bool]$DeviceSerial) {
|
||||
$pkg = Invoke-Adb @("shell", "dumpsys", "package", $AndroidPackage)
|
||||
Assert-Check ($pkg.Output -match "versionName=") "Android package $AndroidPackage is installed"
|
||||
}
|
||||
} else {
|
||||
Write-Warn "adb checks skipped"
|
||||
}
|
||||
}
|
||||
|
||||
function Run-Prepare {
|
||||
Write-Step "Prepare big video"
|
||||
Run-Preflight
|
||||
Ensure-BigVideoFixture
|
||||
Push-BigVideoFixture
|
||||
Write-Host "Manual step: send the pushed >10MB video from Android/Alice to Bob."
|
||||
}
|
||||
|
||||
function Run-BeforeSend {
|
||||
Write-Step "BeforeSend snapshot"
|
||||
$usage = Get-UploadPartUsage
|
||||
$temp = Get-UploadTempStats
|
||||
$state = [pscustomobject]@{
|
||||
SenderUserId = $SenderUserId
|
||||
RecipientUserId = $RecipientUserId
|
||||
BaselinePrivateMessageId = Get-PrivateMessageMaxId
|
||||
BaselineUploadParts = $usage.Parts
|
||||
BaselineUploadPartBytes = $usage.Bytes
|
||||
BaselineTempFiles = $temp.Files
|
||||
BaselineTempBytes = $temp.Bytes
|
||||
BaselineLogLineCount = Get-LogLineCount
|
||||
ServerLogPath = $ServerLogPath
|
||||
BlobDir = $BlobDir
|
||||
RestartTriggered = $false
|
||||
RestartedAt = ""
|
||||
ObservedBigPart = $false
|
||||
CreatedAt = (Get-Date -Format o)
|
||||
VideoFixture = $VideoFixture
|
||||
}
|
||||
Write-Host "private_messages max id before send: $($state.BaselinePrivateMessageId)"
|
||||
Write-Host "upload_parts before send: parts=$($usage.Parts) bytes=$($usage.Bytes)"
|
||||
Write-Host "temp upload files before send: files=$($temp.Files) bytes=$($temp.Bytes)"
|
||||
Save-State $state
|
||||
}
|
||||
|
||||
function Run-WatchRestart {
|
||||
Write-Step "Watch upload.saveBigFilePart and restart"
|
||||
$state = Load-State
|
||||
$deadline = (Get-Date).AddSeconds($WatchTimeoutSeconds)
|
||||
$restartDone = $false
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
$lines = @(Get-LogLinesSince ([int]$state.BaselineLogLineCount) ([string]$state.ServerLogPath))
|
||||
$hits = @($lines | Where-Object {
|
||||
$_ -like "*upload.saveBigFilePart*" -and $_ -like '*client_type": "android"*'
|
||||
})
|
||||
if ($hits.Count -ge $RestartAfterParts) {
|
||||
Write-Host "observed upload.saveBigFilePart lines=$($hits.Count); restarting server"
|
||||
$state.ObservedBigPart = $true
|
||||
$state.RestartTriggered = $true
|
||||
$state.RestartedAt = (Get-Date -Format o)
|
||||
Save-State $state
|
||||
$args = @()
|
||||
if (-not $BuildOnRestart) {
|
||||
$args += "-SkipBuild"
|
||||
}
|
||||
Invoke-External "powershell" (@("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $RestartScript) + $args) | Out-Null
|
||||
$restartDone = $true
|
||||
break
|
||||
}
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
Assert-Check $restartDone "server restarted after upload.saveBigFilePart was observed"
|
||||
}
|
||||
|
||||
function Run-AfterSend {
|
||||
Write-Step "AfterSend assertions"
|
||||
$state = Load-State
|
||||
$messages = @(Get-NewVideoMessages ([long]$state.BaselinePrivateMessageId))
|
||||
foreach ($message in $messages) {
|
||||
Write-Host ("new private message id={0} kind={1} video={2} doc={3} mime={4} size={5} thumbs={6}" -f $message.MessageId, $message.Kind, $message.Video, $message.DocumentId, $message.MimeType, $message.Size, $message.ThumbCount)
|
||||
}
|
||||
$videos = @($messages | Where-Object {
|
||||
$_.Kind -eq "document" -and $_.Video -eq "true" -and $_.MimeType -eq "video/mp4" -and $_.DocumentId -gt 0 -and $_.Size -ge $MinBigFileBytes
|
||||
})
|
||||
Assert-Check ($videos.Count -ge 1) "new private message includes a >10MB uploaded video/mp4 document"
|
||||
|
||||
$docIds = @($videos | Select-Object -ExpandProperty DocumentId -Unique)
|
||||
$documents = @(Get-DocumentRows $docIds)
|
||||
$blobs = @(Get-FileBlobRows $docIds)
|
||||
Assert-Check (@($documents | Where-Object { $_.MimeType -eq "video/mp4" -and $_.Size -ge $MinBigFileBytes }).Count -ge 1) "documents row persisted for big video"
|
||||
if (-not $AllowMissingThumb) {
|
||||
Assert-Check (@($documents | Where-Object { $_.ThumbCount -gt 0 }).Count -ge 1) "big video document has thumbnail metadata"
|
||||
}
|
||||
$bodyBlobs = @($blobs | Where-Object { $_.LocationKey -like "doc:*" -and $_.LocationKey -notlike "*:m" -and $_.Size -ge $MinBigFileBytes })
|
||||
Assert-Check ($bodyBlobs.Count -ge 1) "big video body file_blobs row exists"
|
||||
if (-not $AllowMissingThumb) {
|
||||
Assert-Check (@($blobs | Where-Object { $_.LocationKey -like "doc:*:m" -and $_.Size -gt 0 }).Count -ge 1) "big video thumbnail file_blobs row exists"
|
||||
}
|
||||
foreach ($blob in $blobs) {
|
||||
if ($blob.Backend -eq "localfs" -and $blob.ObjectKey) {
|
||||
Assert-Check (Test-Path -LiteralPath (Get-BlobFilePath $blob.ObjectKey)) "localfs blob exists: $($blob.LocationKey)"
|
||||
}
|
||||
}
|
||||
|
||||
$usage = Get-UploadPartUsage
|
||||
$temp = Get-UploadTempStats
|
||||
Write-Host "upload_parts after send: parts=$($usage.Parts) bytes=$($usage.Bytes)"
|
||||
Write-Host "temp upload files after send: files=$($temp.Files) bytes=$($temp.Bytes)"
|
||||
Assert-Check ($usage.Parts -le [int]$state.BaselineUploadParts) "upload_parts metadata cleaned after successful big upload"
|
||||
Assert-Check ($temp.Files -le [int]$state.BaselineTempFiles) "upload temp files cleaned after successful big upload"
|
||||
|
||||
$oldLines = @(Get-LogLinesSince ([int]$state.BaselineLogLineCount) ([string]$state.ServerLogPath))
|
||||
$newLines = @(Read-SharedLogLines)
|
||||
$combined = @($oldLines + $newLines)
|
||||
$bigHits = @($combined | Where-Object { $_ -like "*upload.saveBigFilePart*" -and $_ -like '*client_type": "android"*' })
|
||||
$sendMediaHits = @($combined | Where-Object { $_ -like "*messages.sendMedia*" -and $_ -like '*client_type": "android"*' })
|
||||
$bad = @($combined | Where-Object { $_ -cmatch "INTERNAL_SERVER_ERROR|rpc error|Unhandled RPC|NOT_IMPLEMENTED|bad_msg|panic|\tERROR\t" })
|
||||
Assert-Check (($bigHits.Count -ge 1) -or [bool]$state.ObservedBigPart) "server log has Android upload.saveBigFilePart"
|
||||
Assert-Check ($sendMediaHits.Count -ge 1) "server log has Android messages.sendMedia"
|
||||
Assert-Check ($bad.Count -eq 0) "server logs have no big-upload-era internal errors or unhandled RPCs"
|
||||
}
|
||||
|
||||
function Finish-Run {
|
||||
if ($Failures.Count -gt 0) {
|
||||
Write-Host ""
|
||||
Write-Host "Validation failed:"
|
||||
foreach ($failure in $Failures) { Write-Host " - $failure" }
|
||||
exit 1
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host "Validation passed."
|
||||
}
|
||||
|
||||
switch ($Phase) {
|
||||
"Preflight" { Run-Preflight }
|
||||
"Prepare" { Run-Prepare }
|
||||
"BeforeSend" { Run-BeforeSend }
|
||||
"WatchRestart" { Run-WatchRestart }
|
||||
"AfterSend" { Run-AfterSend }
|
||||
"All" {
|
||||
Run-Prepare
|
||||
Run-BeforeSend
|
||||
Write-Host "Start sending the pushed video from Android/Alice now."
|
||||
Run-WatchRestart
|
||||
Read-Host "After Android finishes sending the video, press Enter"
|
||||
Run-AfterSend
|
||||
}
|
||||
}
|
||||
|
||||
Finish-Run
|
||||
536
scripts/validate-android-offline-media-recovery.ps1
Normal file
536
scripts/validate-android-offline-media-recovery.ps1
Normal file
|
|
@ -0,0 +1,536 @@
|
|||
<#
|
||||
.SYNOPSIS
|
||||
Semi-automates the Android -> TDesktop offline channel media recovery check.
|
||||
|
||||
.DESCRIPTION
|
||||
This helper covers the stable parts of the local validation loop:
|
||||
preflight checks, fixture generation/push, PostgreSQL state snapshots, server
|
||||
log checks, and pass/fail assertions. It intentionally does not drive the
|
||||
Android media picker or Telegram Desktop UI; those remain manual steps because
|
||||
their coordinates and cached state are device/client dependent.
|
||||
|
||||
Typical flow:
|
||||
1. Run -Phase Prepare to generate and push fixtures to Android.
|
||||
2. Close DebugBob/TDesktop, then run -Phase BeforeSend.
|
||||
3. Send photo, video, and document from Android/Alice.
|
||||
4. Run -Phase AfterSend.
|
||||
5. Start DebugBob/TDesktop, open the channel, confirm media is visible.
|
||||
6. Run -Phase AfterBobOpen.
|
||||
|
||||
Use -Phase All for the same flow with interactive pauses.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet("Preflight", "Prepare", "BeforeSend", "AfterSend", "AfterBobOpen", "All")]
|
||||
[string]$Phase = "Preflight",
|
||||
|
||||
[long]$ChannelId = 24,
|
||||
[long]$AndroidUserId = 1780269504,
|
||||
[long]$BobUserId = 1780269505,
|
||||
|
||||
[string]$AndroidPackage = "org.telegram.messenger.beta",
|
||||
[string]$DeviceSerial,
|
||||
|
||||
[string]$PostgresContainer = "telesrv-postgres",
|
||||
[string]$Database = "telesrv",
|
||||
[string]$DbUser = "telesrv",
|
||||
|
||||
[string]$ServerLogPath,
|
||||
[string]$StatePath,
|
||||
[string]$FixtureDir,
|
||||
|
||||
[string]$PhotoFixture,
|
||||
[string]$VideoFixture,
|
||||
[string]$DocumentFixture,
|
||||
|
||||
[string]$RemotePictureDir = "/sdcard/Pictures/telesrv",
|
||||
[string]$RemoteMovieDir = "/sdcard/Movies/telesrv",
|
||||
[string]$RemoteDocumentDir = "/sdcard/Download/telesrv",
|
||||
|
||||
[int]$ExpectedNewMessages = 3,
|
||||
|
||||
[switch]$AllowMissingVideo,
|
||||
[switch]$AllowMissingGenericDocument,
|
||||
[switch]$SkipAdb
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
|
||||
if (-not $ServerLogPath) {
|
||||
$ServerLogPath = Join-Path $RepoRoot "logs\app-version-observe-20260609-165537.err.log"
|
||||
}
|
||||
if (-not $StatePath) {
|
||||
$StatePath = Join-Path $RepoRoot "logs\android-offline-media-recovery-state.json"
|
||||
}
|
||||
if (-not $FixtureDir) {
|
||||
$FixtureDir = Join-Path $RepoRoot "logs\media-fixtures"
|
||||
}
|
||||
|
||||
$Failures = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
function Write-Step {
|
||||
param([string]$Message)
|
||||
Write-Host ""
|
||||
Write-Host "== $Message =="
|
||||
}
|
||||
|
||||
function Write-Ok {
|
||||
param([string]$Message)
|
||||
Write-Host "[ok] $Message"
|
||||
}
|
||||
|
||||
function Write-Warn {
|
||||
param([string]$Message)
|
||||
Write-Host "[warn] $Message"
|
||||
}
|
||||
|
||||
function Add-Failure {
|
||||
param([string]$Message)
|
||||
$script:Failures.Add($Message) | Out-Null
|
||||
Write-Host "[fail] $Message"
|
||||
}
|
||||
|
||||
function Assert-Check {
|
||||
param(
|
||||
[bool]$Condition,
|
||||
[string]$Message
|
||||
)
|
||||
if ($Condition) {
|
||||
Write-Ok $Message
|
||||
} else {
|
||||
Add-Failure $Message
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-External {
|
||||
param(
|
||||
[string]$FilePath,
|
||||
[string[]]$Arguments,
|
||||
[switch]$AllowFailure
|
||||
)
|
||||
$oldErrorActionPreference = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
try {
|
||||
$output = & $FilePath @Arguments 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
} finally {
|
||||
$ErrorActionPreference = $oldErrorActionPreference
|
||||
}
|
||||
$text = ($output | ForEach-Object { $_.ToString() }) -join "`n"
|
||||
if ($exitCode -ne 0 -and -not $AllowFailure) {
|
||||
throw "$FilePath $($Arguments -join ' ') failed with exit code ${exitCode}:`n$text"
|
||||
}
|
||||
[pscustomobject]@{
|
||||
ExitCode = $exitCode
|
||||
Output = $text
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-Command {
|
||||
param([string]$Name)
|
||||
$cmd = Get-Command $Name -ErrorAction SilentlyContinue
|
||||
Assert-Check ([bool]$cmd) "$Name is available"
|
||||
return [bool]$cmd
|
||||
}
|
||||
|
||||
function Get-AdbArgs {
|
||||
param([string[]]$Arguments)
|
||||
if ($DeviceSerial) {
|
||||
return @("-s", $DeviceSerial) + $Arguments
|
||||
}
|
||||
return $Arguments
|
||||
}
|
||||
|
||||
function Invoke-Adb {
|
||||
param([string[]]$Arguments)
|
||||
Invoke-External "adb" (Get-AdbArgs $Arguments)
|
||||
}
|
||||
|
||||
function Invoke-PsqlRows {
|
||||
param([string]$Sql)
|
||||
$args = @(
|
||||
"exec", $PostgresContainer,
|
||||
"psql", "-U", $DbUser, "-d", $Database,
|
||||
"-v", "ON_ERROR_STOP=1",
|
||||
"-At", "-F", "|",
|
||||
"-c", $Sql
|
||||
)
|
||||
$result = Invoke-External "docker" $args
|
||||
if ([string]::IsNullOrWhiteSpace($result.Output)) {
|
||||
return @()
|
||||
}
|
||||
return @($result.Output -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
|
||||
}
|
||||
|
||||
function Get-LogLineCount {
|
||||
if (-not (Test-Path -LiteralPath $ServerLogPath)) {
|
||||
return 0
|
||||
}
|
||||
return @((Get-Content -LiteralPath $ServerLogPath)).Count
|
||||
}
|
||||
|
||||
function Get-LogLinesSince {
|
||||
param([int]$Skip)
|
||||
if (-not (Test-Path -LiteralPath $ServerLogPath)) {
|
||||
Add-Failure "server log exists at $ServerLogPath"
|
||||
return @()
|
||||
}
|
||||
return @(Get-Content -LiteralPath $ServerLogPath | Select-Object -Skip $Skip)
|
||||
}
|
||||
|
||||
function Get-DialogState {
|
||||
param([long]$UserId)
|
||||
$rows = @(Invoke-PsqlRows @"
|
||||
SELECT top_message_id, read_inbox_max_id, unread_count
|
||||
FROM channel_dialogs
|
||||
WHERE channel_id = $ChannelId AND user_id = $UserId;
|
||||
"@)
|
||||
if ($rows.Count -ne 1) {
|
||||
Add-Failure "channel_dialogs has one row for channel_id=$ChannelId user_id=$UserId"
|
||||
return $null
|
||||
}
|
||||
$parts = $rows[0] -split "\|"
|
||||
[pscustomobject]@{
|
||||
UserId = $UserId
|
||||
TopMessageId = [int]$parts[0]
|
||||
ReadInboxMaxId = [int]$parts[1]
|
||||
UnreadCount = [int]$parts[2]
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ChannelPts {
|
||||
$rows = @(Invoke-PsqlRows "SELECT COALESCE(MAX(pts), 0) FROM channel_update_events WHERE channel_id = $ChannelId;")
|
||||
if ($rows.Count -eq 0) {
|
||||
return 0
|
||||
}
|
||||
return [int]$rows[0]
|
||||
}
|
||||
|
||||
function Get-NewMessages {
|
||||
param([int]$AfterMessageId)
|
||||
$rows = @(Invoke-PsqlRows @"
|
||||
SELECT
|
||||
id,
|
||||
sender_user_id,
|
||||
media->>'kind',
|
||||
COALESCE(media->'document'->>'mime_type', ''),
|
||||
COALESCE((
|
||||
SELECT attr->>'file_name'
|
||||
FROM jsonb_array_elements(COALESCE(media->'document'->'attributes', '[]'::jsonb)) attr
|
||||
WHERE attr->>'kind' = 'filename'
|
||||
LIMIT 1
|
||||
), '')
|
||||
FROM channel_messages
|
||||
WHERE channel_id = $ChannelId AND id > $AfterMessageId
|
||||
ORDER BY id;
|
||||
"@)
|
||||
$items = @()
|
||||
foreach ($row in $rows) {
|
||||
$parts = $row -split "\|", 5
|
||||
$items += [pscustomobject]@{
|
||||
Id = [int]$parts[0]
|
||||
SenderUserId = [long]$parts[1]
|
||||
Kind = $parts[2]
|
||||
MimeType = $parts[3]
|
||||
FileName = $parts[4]
|
||||
}
|
||||
}
|
||||
return $items
|
||||
}
|
||||
|
||||
function Get-NewEventSummary {
|
||||
param([int]$AfterPts)
|
||||
$rows = @(Invoke-PsqlRows @"
|
||||
SELECT COUNT(*), COALESCE(MAX(pts), 0)
|
||||
FROM channel_update_events
|
||||
WHERE channel_id = $ChannelId
|
||||
AND pts > $AfterPts
|
||||
AND event_type = 'new_channel_message';
|
||||
"@)
|
||||
if ($rows.Count -eq 0) {
|
||||
return [pscustomobject]@{ Count = 0; MaxPts = 0 }
|
||||
}
|
||||
$parts = $rows[0] -split "\|"
|
||||
[pscustomobject]@{
|
||||
Count = [int]$parts[0]
|
||||
MaxPts = [int]$parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
function Save-State {
|
||||
param([pscustomobject]$State)
|
||||
$dir = Split-Path -Parent $StatePath
|
||||
if ($dir) {
|
||||
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
||||
}
|
||||
$State | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $StatePath -Encoding UTF8
|
||||
Write-Ok "state saved to $StatePath"
|
||||
}
|
||||
|
||||
function Load-State {
|
||||
if (-not (Test-Path -LiteralPath $StatePath)) {
|
||||
throw "state file not found: $StatePath. Run -Phase BeforeSend first."
|
||||
}
|
||||
Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function New-PhotoFixture {
|
||||
param([string]$Path)
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
$bitmap = New-Object System.Drawing.Bitmap 900, 520
|
||||
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||
$graphics.Clear([System.Drawing.Color]::FromArgb(34, 137, 116))
|
||||
$fontLarge = New-Object System.Drawing.Font("Arial", 36, [System.Drawing.FontStyle]::Bold)
|
||||
$fontSmall = New-Object System.Drawing.Font("Arial", 22, [System.Drawing.FontStyle]::Regular)
|
||||
$brushWhite = [System.Drawing.Brushes]::White
|
||||
$brushYellow = New-Object System.Drawing.SolidBrush([System.Drawing.Color]::FromArgb(245, 184, 60))
|
||||
$graphics.DrawString("telesrv offline photo", $fontLarge, $brushWhite, 55, 85)
|
||||
$graphics.DrawString((Get-Date -Format "yyyyMMdd-HHmmss"), $fontSmall, $brushWhite, 58, 150)
|
||||
$graphics.FillRectangle($brushYellow, 58, 300, 780, 42)
|
||||
$bitmap.Save($Path, [System.Drawing.Imaging.ImageFormat]::Jpeg)
|
||||
$graphics.Dispose()
|
||||
$bitmap.Dispose()
|
||||
}
|
||||
|
||||
function New-DocumentFixture {
|
||||
param([string]$Path)
|
||||
$content = @"
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<telesrv-offline-media-recovery generated_at="$(Get-Date -Format o)">
|
||||
<purpose>Android to TDesktop offline media recovery validation</purpose>
|
||||
</telesrv-offline-media-recovery>
|
||||
"@
|
||||
Set-Content -LiteralPath $Path -Value $content -Encoding UTF8
|
||||
}
|
||||
|
||||
function New-VideoFixture {
|
||||
param([string]$Path)
|
||||
$ffmpeg = Get-Command ffmpeg -ErrorAction SilentlyContinue
|
||||
if (-not $ffmpeg) {
|
||||
Write-Warn "ffmpeg not found; video fixture was not generated"
|
||||
return $false
|
||||
}
|
||||
Invoke-External "ffmpeg" @(
|
||||
"-y",
|
||||
"-f", "lavfi",
|
||||
"-i", "testsrc=size=640x360:rate=30",
|
||||
"-t", "1",
|
||||
"-pix_fmt", "yuv420p",
|
||||
$Path
|
||||
) | Out-Null
|
||||
return $true
|
||||
}
|
||||
|
||||
function Ensure-Fixtures {
|
||||
New-Item -ItemType Directory -Force -Path $FixtureDir | Out-Null
|
||||
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||
if (-not $PhotoFixture) {
|
||||
$script:PhotoFixture = Join-Path $FixtureDir "telesrv-offline-photo-$stamp.jpg"
|
||||
New-PhotoFixture $script:PhotoFixture
|
||||
}
|
||||
if (-not $DocumentFixture) {
|
||||
$script:DocumentFixture = Join-Path $FixtureDir "telesrv-offline-doc-$stamp.xml"
|
||||
New-DocumentFixture $script:DocumentFixture
|
||||
}
|
||||
if (-not $VideoFixture) {
|
||||
$candidate = Join-Path $FixtureDir "telesrv-offline-video-$stamp.mp4"
|
||||
if (New-VideoFixture $candidate) {
|
||||
$script:VideoFixture = $candidate
|
||||
}
|
||||
}
|
||||
Assert-Check (Test-Path -LiteralPath $PhotoFixture) "photo fixture exists: $PhotoFixture"
|
||||
Assert-Check (Test-Path -LiteralPath $DocumentFixture) "document fixture exists: $DocumentFixture"
|
||||
if (-not $AllowMissingVideo) {
|
||||
Assert-Check ($VideoFixture -and (Test-Path -LiteralPath $VideoFixture)) "video fixture exists: $VideoFixture"
|
||||
} elseif ($VideoFixture) {
|
||||
Assert-Check (Test-Path -LiteralPath $VideoFixture) "video fixture exists: $VideoFixture"
|
||||
}
|
||||
}
|
||||
|
||||
function Push-Fixtures {
|
||||
if ($SkipAdb) {
|
||||
Write-Warn "adb push skipped"
|
||||
return
|
||||
}
|
||||
Invoke-Adb @("shell", "mkdir", "-p", $RemotePictureDir, $RemoteMovieDir, $RemoteDocumentDir) | Out-Null
|
||||
Invoke-Adb @("push", $PhotoFixture, "$RemotePictureDir/") | Out-Null
|
||||
Invoke-Adb @("push", $DocumentFixture, "$RemoteDocumentDir/") | Out-Null
|
||||
Invoke-Adb @("shell", "am", "broadcast", "-a", "android.intent.action.MEDIA_SCANNER_SCAN_FILE", "-d", "file://$RemotePictureDir/$(Split-Path -Leaf $PhotoFixture)") | Out-Null
|
||||
Invoke-Adb @("shell", "am", "broadcast", "-a", "android.intent.action.MEDIA_SCANNER_SCAN_FILE", "-d", "file://$RemoteDocumentDir/$(Split-Path -Leaf $DocumentFixture)") | Out-Null
|
||||
if ($VideoFixture -and (Test-Path -LiteralPath $VideoFixture)) {
|
||||
Invoke-Adb @("push", $VideoFixture, "$RemoteMovieDir/") | Out-Null
|
||||
Invoke-Adb @("shell", "am", "broadcast", "-a", "android.intent.action.MEDIA_SCANNER_SCAN_FILE", "-d", "file://$RemoteMovieDir/$(Split-Path -Leaf $VideoFixture)") | Out-Null
|
||||
}
|
||||
Write-Ok "fixtures pushed to Android media folders"
|
||||
}
|
||||
|
||||
function Run-Preflight {
|
||||
Write-Step "Preflight"
|
||||
if (-not $SkipAdb) {
|
||||
if (Assert-Command "adb") {
|
||||
$devices = Invoke-Adb @("devices")
|
||||
$deviceLines = @($devices.Output -split "`r?`n" | Where-Object { $_ -match "\tdevice$" })
|
||||
Assert-Check ($deviceLines.Count -ge 1) "adb has at least one connected device"
|
||||
Assert-Check (($deviceLines.Count -eq 1) -or [bool]$DeviceSerial) "adb selects a single device or -DeviceSerial is set"
|
||||
if (($deviceLines.Count -eq 1) -or [bool]$DeviceSerial) {
|
||||
$pkg = Invoke-Adb @("shell", "dumpsys", "package", $AndroidPackage)
|
||||
Assert-Check ($pkg.Output -match "versionName=") "Android package $AndroidPackage is installed"
|
||||
$model = (Invoke-Adb @("shell", "getprop", "ro.product.model")).Output.Trim()
|
||||
$sdk = (Invoke-Adb @("shell", "getprop", "ro.build.version.sdk")).Output.Trim()
|
||||
Write-Host "Android device: model=$model sdk=$sdk"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Write-Warn "adb checks skipped"
|
||||
}
|
||||
if (Assert-Command "docker") {
|
||||
Invoke-PsqlRows "SELECT 1;" | Out-Null
|
||||
Write-Ok "PostgreSQL is reachable through docker container $PostgresContainer"
|
||||
}
|
||||
Assert-Check (Test-Path -LiteralPath $ServerLogPath) "server log exists: $ServerLogPath"
|
||||
}
|
||||
|
||||
function Run-Prepare {
|
||||
Write-Step "Prepare fixtures"
|
||||
Run-Preflight
|
||||
Ensure-Fixtures
|
||||
Push-Fixtures
|
||||
Write-Host "Manual step: close DebugBob/TDesktop, then send these files from Android/Alice:"
|
||||
Write-Host " Photo: $PhotoFixture"
|
||||
if ($VideoFixture) {
|
||||
Write-Host " Video: $VideoFixture"
|
||||
}
|
||||
Write-Host " Document: $DocumentFixture"
|
||||
}
|
||||
|
||||
function Run-BeforeSend {
|
||||
Write-Step "BeforeSend snapshot"
|
||||
$bob = Get-DialogState $BobUserId
|
||||
$alice = Get-DialogState $AndroidUserId
|
||||
$pts = Get-ChannelPts
|
||||
$lineCount = Get-LogLineCount
|
||||
Assert-Check ($null -ne $bob) "Bob dialog state can be read"
|
||||
Assert-Check ($null -ne $alice) "Android/Alice dialog state can be read"
|
||||
if ($bob) {
|
||||
Write-Host "Bob before send: top=$($bob.TopMessageId) read=$($bob.ReadInboxMaxId) unread=$($bob.UnreadCount)"
|
||||
}
|
||||
if ($alice) {
|
||||
Write-Host "Alice before send: top=$($alice.TopMessageId) read=$($alice.ReadInboxMaxId) unread=$($alice.UnreadCount)"
|
||||
}
|
||||
Write-Host "Channel pts before send: $pts"
|
||||
$state = [pscustomobject]@{
|
||||
ChannelId = $ChannelId
|
||||
AndroidUserId = $AndroidUserId
|
||||
BobUserId = $BobUserId
|
||||
BaselineTopMessageId = if ($bob) { $bob.TopMessageId } else { 0 }
|
||||
BaselineBobReadInboxMaxId = if ($bob) { $bob.ReadInboxMaxId } else { 0 }
|
||||
BaselineBobUnreadCount = if ($bob) { $bob.UnreadCount } else { 0 }
|
||||
BaselineChannelPts = $pts
|
||||
BaselineLogLineCount = $lineCount
|
||||
ServerLogPath = $ServerLogPath
|
||||
CreatedAt = (Get-Date -Format o)
|
||||
PhotoFixture = $PhotoFixture
|
||||
VideoFixture = $VideoFixture
|
||||
DocumentFixture = $DocumentFixture
|
||||
}
|
||||
Save-State $state
|
||||
}
|
||||
|
||||
function Run-AfterSend {
|
||||
Write-Step "AfterSend assertions"
|
||||
$state = Load-State
|
||||
$bob = Get-DialogState $BobUserId
|
||||
$messages = @(Get-NewMessages ([int]$state.BaselineTopMessageId))
|
||||
$events = Get-NewEventSummary ([int]$state.BaselineChannelPts)
|
||||
foreach ($message in $messages) {
|
||||
Write-Host ("new message id={0} sender={1} kind={2} mime={3} file={4}" -f $message.Id, $message.SenderUserId, $message.Kind, $message.MimeType, $message.FileName)
|
||||
}
|
||||
if ($bob) {
|
||||
Write-Host "Bob after send: top=$($bob.TopMessageId) read=$($bob.ReadInboxMaxId) unread=$($bob.UnreadCount)"
|
||||
}
|
||||
Write-Host "new channel events after baseline: count=$($events.Count) max_pts=$($events.MaxPts)"
|
||||
Assert-Check ($messages.Count -ge $ExpectedNewMessages) "at least $ExpectedNewMessages new channel messages were written"
|
||||
Assert-Check (@($messages | Where-Object { $_.SenderUserId -eq $AndroidUserId }).Count -ge $ExpectedNewMessages) "new channel messages are from Android/Alice"
|
||||
Assert-Check (@($messages | Where-Object { $_.Kind -eq "photo" }).Count -ge 1) "new messages include uploaded photo"
|
||||
Assert-Check (@($messages | Where-Object { $_.Kind -eq "document" }).Count -ge 1) "new messages include uploaded document"
|
||||
if (-not $AllowMissingVideo) {
|
||||
Assert-Check (@($messages | Where-Object { $_.MimeType -eq "video/mp4" }).Count -ge 1) "new messages include video/mp4 document"
|
||||
}
|
||||
if (-not $AllowMissingGenericDocument) {
|
||||
Assert-Check (@($messages | Where-Object { $_.Kind -eq "document" -and $_.MimeType -ne "video/mp4" }).Count -ge 1) "new messages include a generic non-video document"
|
||||
}
|
||||
if ($bob) {
|
||||
Assert-Check ($bob.TopMessageId -gt [int]$state.BaselineTopMessageId) "Bob top_message_id advanced while offline"
|
||||
Assert-Check ($bob.ReadInboxMaxId -eq [int]$state.BaselineBobReadInboxMaxId) "Bob read_inbox_max_id did not advance before opening TDesktop"
|
||||
Assert-Check ($bob.UnreadCount -ge $ExpectedNewMessages) "Bob unread_count reflects offline messages"
|
||||
}
|
||||
Assert-Check ($events.Count -ge $ExpectedNewMessages) "durable channel_update_events exist for new messages"
|
||||
}
|
||||
|
||||
function Run-AfterBobOpen {
|
||||
Write-Step "AfterBobOpen assertions"
|
||||
$state = Load-State
|
||||
$bob = Get-DialogState $BobUserId
|
||||
if ($bob) {
|
||||
Write-Host "Bob after open: top=$($bob.TopMessageId) read=$($bob.ReadInboxMaxId) unread=$($bob.UnreadCount)"
|
||||
Assert-Check ($bob.ReadInboxMaxId -ge $bob.TopMessageId) "Bob read_inbox_max_id catches up to current top"
|
||||
Assert-Check ($bob.UnreadCount -eq 0) "Bob unread_count is cleared after opening the channel"
|
||||
}
|
||||
$lines = @(Get-LogLinesSince ([int]$state.BaselineLogLineCount))
|
||||
$bad = @($lines | Where-Object { $_ -match "Unhandled RPC|NOT_IMPLEMENTED|bad_msg" })
|
||||
Assert-Check ($bad.Count -eq 0) "server log has no new Unhandled RPC / NOT_IMPLEMENTED / bad_msg entries"
|
||||
$expectedTDesktop = @(
|
||||
"messages.getHistory",
|
||||
"messages.getPeerDialogs",
|
||||
"upload.getFile",
|
||||
"channels.readHistory",
|
||||
"updates.getChannelDifference"
|
||||
)
|
||||
foreach ($method in $expectedTDesktop) {
|
||||
$hits = @($lines | Where-Object { $_ -like "*$method*" -and $_ -like '*client_type": "tdesktop"*' })
|
||||
Assert-Check ($hits.Count -ge 1) "TDesktop issued $method after Bob opened the channel"
|
||||
}
|
||||
}
|
||||
|
||||
function Finish-Run {
|
||||
if ($Failures.Count -gt 0) {
|
||||
Write-Host ""
|
||||
Write-Host "Validation failed:"
|
||||
foreach ($failure in $Failures) {
|
||||
Write-Host " - $failure"
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host "Validation phase '$Phase' passed."
|
||||
}
|
||||
|
||||
switch ($Phase) {
|
||||
"Preflight" {
|
||||
Run-Preflight
|
||||
}
|
||||
"Prepare" {
|
||||
Run-Prepare
|
||||
}
|
||||
"BeforeSend" {
|
||||
Run-BeforeSend
|
||||
}
|
||||
"AfterSend" {
|
||||
Run-AfterSend
|
||||
}
|
||||
"AfterBobOpen" {
|
||||
Run-AfterBobOpen
|
||||
}
|
||||
"All" {
|
||||
Run-Prepare
|
||||
Run-BeforeSend
|
||||
Read-Host "Close DebugBob/TDesktop if needed, send photo/video/document from Android, then press Enter"
|
||||
Run-AfterSend
|
||||
Read-Host "Start DebugBob/TDesktop, open the channel, confirm media renders, then press Enter"
|
||||
Run-AfterBobOpen
|
||||
}
|
||||
}
|
||||
|
||||
Finish-Run
|
||||
586
scripts/validate-android-video-upload.ps1
Normal file
586
scripts/validate-android-video-upload.ps1
Normal file
|
|
@ -0,0 +1,586 @@
|
|||
<#
|
||||
.SYNOPSIS
|
||||
Semi-automates Android video upload validation against local telesrv.
|
||||
|
||||
.DESCRIPTION
|
||||
The script handles the stable parts of the Android upload regression loop:
|
||||
preflight checks, optional video fixture generation/push, baseline snapshots,
|
||||
server log scanning, PostgreSQL media/message assertions, upload_parts cleanup,
|
||||
and local blob existence checks.
|
||||
|
||||
It intentionally does not drive the Android media picker. Send the prepared
|
||||
video manually from Android/Alice, then run -Phase AfterSend.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet("Preflight", "Prepare", "BeforeSend", "AfterSend", "All")]
|
||||
[string]$Phase = "Preflight",
|
||||
|
||||
[long]$SenderUserId = 1780269504,
|
||||
[long]$RecipientUserId = 1780269505,
|
||||
|
||||
[string]$AndroidPackage = "org.telegram.messenger.beta",
|
||||
[string]$DeviceSerial,
|
||||
|
||||
[string]$PostgresContainer = "telesrv-postgres",
|
||||
[string]$Database = "telesrv",
|
||||
[string]$DbUser = "telesrv",
|
||||
|
||||
[string]$ServerLogPath,
|
||||
[string]$StatePath,
|
||||
[string]$FixtureDir,
|
||||
[string]$VideoFixture,
|
||||
[string]$BlobDir,
|
||||
[string]$RemoteMovieDir = "/sdcard/Movies/telesrv",
|
||||
|
||||
[switch]$SkipAdb,
|
||||
[switch]$AllowMissingThumb
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
|
||||
if (-not $ServerLogPath) {
|
||||
$latestLog = Get-ChildItem (Join-Path $RepoRoot "logs") -Filter "telesrv-*.err.log" -ErrorAction SilentlyContinue |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
if ($latestLog) {
|
||||
$ServerLogPath = $latestLog.FullName
|
||||
} else {
|
||||
$ServerLogPath = Join-Path $RepoRoot "logs\telesrv.err.log"
|
||||
}
|
||||
}
|
||||
if (-not $StatePath) {
|
||||
$StatePath = Join-Path $RepoRoot "logs\android-video-upload-state.json"
|
||||
}
|
||||
if (-not $FixtureDir) {
|
||||
$FixtureDir = Join-Path $RepoRoot "logs\media-fixtures"
|
||||
}
|
||||
if (-not $BlobDir) {
|
||||
$BlobDir = Join-Path $RepoRoot "data\blobs"
|
||||
}
|
||||
|
||||
$Failures = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
function Write-Step {
|
||||
param([string]$Message)
|
||||
Write-Host ""
|
||||
Write-Host "== $Message =="
|
||||
}
|
||||
|
||||
function Write-Ok {
|
||||
param([string]$Message)
|
||||
Write-Host "[ok] $Message"
|
||||
}
|
||||
|
||||
function Write-Warn {
|
||||
param([string]$Message)
|
||||
Write-Host "[warn] $Message"
|
||||
}
|
||||
|
||||
function Add-Failure {
|
||||
param([string]$Message)
|
||||
$script:Failures.Add($Message) | Out-Null
|
||||
Write-Host "[fail] $Message"
|
||||
}
|
||||
|
||||
function Assert-Check {
|
||||
param(
|
||||
[bool]$Condition,
|
||||
[string]$Message
|
||||
)
|
||||
if ($Condition) {
|
||||
Write-Ok $Message
|
||||
} else {
|
||||
Add-Failure $Message
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-External {
|
||||
param(
|
||||
[string]$FilePath,
|
||||
[string[]]$Arguments,
|
||||
[switch]$AllowFailure
|
||||
)
|
||||
$oldErrorActionPreference = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
try {
|
||||
$output = & $FilePath @Arguments 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
} finally {
|
||||
$ErrorActionPreference = $oldErrorActionPreference
|
||||
}
|
||||
$text = ($output | ForEach-Object { $_.ToString() }) -join "`n"
|
||||
if ($exitCode -ne 0 -and -not $AllowFailure) {
|
||||
throw "$FilePath $($Arguments -join ' ') failed with exit code ${exitCode}:`n$text"
|
||||
}
|
||||
[pscustomobject]@{
|
||||
ExitCode = $exitCode
|
||||
Output = $text
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-Command {
|
||||
param([string]$Name)
|
||||
$cmd = Get-Command $Name -ErrorAction SilentlyContinue
|
||||
Assert-Check ([bool]$cmd) "$Name is available"
|
||||
return [bool]$cmd
|
||||
}
|
||||
|
||||
function Get-AdbArgs {
|
||||
param([string[]]$Arguments)
|
||||
if ($DeviceSerial) {
|
||||
return @("-s", $DeviceSerial) + $Arguments
|
||||
}
|
||||
return $Arguments
|
||||
}
|
||||
|
||||
function Invoke-Adb {
|
||||
param([string[]]$Arguments, [switch]$AllowFailure)
|
||||
Invoke-External "adb" (Get-AdbArgs $Arguments) -AllowFailure:$AllowFailure
|
||||
}
|
||||
|
||||
function Invoke-PsqlRows {
|
||||
param([string]$Sql)
|
||||
$args = @(
|
||||
"exec", $PostgresContainer,
|
||||
"psql", "-U", $DbUser, "-d", $Database,
|
||||
"-v", "ON_ERROR_STOP=1",
|
||||
"-At", "-F", "|",
|
||||
"-c", $Sql
|
||||
)
|
||||
$result = Invoke-External "docker" $args
|
||||
if ([string]::IsNullOrWhiteSpace($result.Output)) {
|
||||
return @()
|
||||
}
|
||||
return @($result.Output -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
|
||||
}
|
||||
|
||||
function Get-LogLineCount {
|
||||
if (-not (Test-Path -LiteralPath $ServerLogPath)) {
|
||||
return 0
|
||||
}
|
||||
return @(Read-SharedLogLines).Count
|
||||
}
|
||||
|
||||
function Get-LogLinesSince {
|
||||
param([int]$Skip)
|
||||
if (-not (Test-Path -LiteralPath $ServerLogPath)) {
|
||||
Add-Failure "server log exists at $ServerLogPath"
|
||||
return @()
|
||||
}
|
||||
return @(Read-SharedLogLines | Select-Object -Skip $Skip)
|
||||
}
|
||||
|
||||
function Read-SharedLogLines {
|
||||
$stream = [System.IO.File]::Open($ServerLogPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
|
||||
try {
|
||||
$reader = New-Object System.IO.StreamReader($stream)
|
||||
try {
|
||||
$lines = New-Object System.Collections.Generic.List[string]
|
||||
while (-not $reader.EndOfStream) {
|
||||
$lines.Add($reader.ReadLine()) | Out-Null
|
||||
}
|
||||
return $lines
|
||||
} finally {
|
||||
$reader.Dispose()
|
||||
}
|
||||
} finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PrivateMessageMaxId {
|
||||
$rows = @(Invoke-PsqlRows @"
|
||||
SELECT COALESCE(MAX(id), 0)
|
||||
FROM private_messages
|
||||
WHERE (sender_user_id = $SenderUserId AND recipient_user_id = $RecipientUserId)
|
||||
OR (sender_user_id = $RecipientUserId AND recipient_user_id = $SenderUserId);
|
||||
"@)
|
||||
if ($rows.Count -eq 0) {
|
||||
return 0
|
||||
}
|
||||
return [long]$rows[0]
|
||||
}
|
||||
|
||||
function Get-UploadPartUsage {
|
||||
$rows = @(Invoke-PsqlRows @"
|
||||
SELECT COUNT(*), COALESCE(SUM(size), 0)
|
||||
FROM upload_parts
|
||||
WHERE owner_user_id = $SenderUserId;
|
||||
"@)
|
||||
if ($rows.Count -eq 0) {
|
||||
return [pscustomobject]@{ Parts = 0; Bytes = 0L }
|
||||
}
|
||||
$parts = $rows[0] -split "\|"
|
||||
return [pscustomobject]@{
|
||||
Parts = [int]$parts[0]
|
||||
Bytes = [long]$parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
function Get-NewVideoMessages {
|
||||
param([long]$AfterMessageId)
|
||||
$rows = @(Invoke-PsqlRows @"
|
||||
SELECT
|
||||
id,
|
||||
sender_user_id,
|
||||
recipient_user_id,
|
||||
COALESCE(media->>'kind', ''),
|
||||
COALESCE(media->>'video', 'false'),
|
||||
COALESCE(media->'document'->>'id', '0'),
|
||||
COALESCE(media->'document'->>'mime_type', ''),
|
||||
COALESCE(media->'document'->>'size', '0'),
|
||||
COALESCE(jsonb_array_length(COALESCE(media->'document'->'thumbs', '[]'::jsonb)), 0)
|
||||
FROM private_messages
|
||||
WHERE id > $AfterMessageId
|
||||
AND sender_user_id = $SenderUserId
|
||||
AND recipient_user_id = $RecipientUserId
|
||||
ORDER BY id;
|
||||
"@)
|
||||
$items = @()
|
||||
foreach ($row in $rows) {
|
||||
$parts = $row -split "\|", 9
|
||||
$items += [pscustomobject]@{
|
||||
MessageId = [long]$parts[0]
|
||||
SenderUserId = [long]$parts[1]
|
||||
RecipientUserId = [long]$parts[2]
|
||||
Kind = $parts[3]
|
||||
Video = $parts[4]
|
||||
DocumentId = [long]$parts[5]
|
||||
MimeType = $parts[6]
|
||||
Size = [long]$parts[7]
|
||||
ThumbCount = [int]$parts[8]
|
||||
}
|
||||
}
|
||||
return $items
|
||||
}
|
||||
|
||||
function Get-DocumentRows {
|
||||
param([long[]]$DocumentIds)
|
||||
if ($DocumentIds.Count -eq 0) {
|
||||
return @()
|
||||
}
|
||||
$ids = ($DocumentIds | ForEach-Object { $_.ToString() }) -join ","
|
||||
$rows = @(Invoke-PsqlRows @"
|
||||
SELECT id, mime_type, size, jsonb_array_length(COALESCE(thumbs, '[]'::jsonb))
|
||||
FROM documents
|
||||
WHERE id IN ($ids)
|
||||
ORDER BY id;
|
||||
"@)
|
||||
$items = @()
|
||||
foreach ($row in $rows) {
|
||||
$parts = $row -split "\|", 4
|
||||
$items += [pscustomobject]@{
|
||||
DocumentId = [long]$parts[0]
|
||||
MimeType = $parts[1]
|
||||
Size = [long]$parts[2]
|
||||
ThumbCount = [int]$parts[3]
|
||||
}
|
||||
}
|
||||
return $items
|
||||
}
|
||||
|
||||
function Get-FileBlobRows {
|
||||
param([long[]]$DocumentIds)
|
||||
if ($DocumentIds.Count -eq 0) {
|
||||
return @()
|
||||
}
|
||||
$keys = @()
|
||||
foreach ($docId in $DocumentIds) {
|
||||
$keys += "doc:$docId"
|
||||
$keys += "doc:${docId}:m"
|
||||
}
|
||||
$quoted = ($keys | ForEach-Object { "'" + $_.Replace("'", "''") + "'" }) -join ","
|
||||
$rows = @(Invoke-PsqlRows @"
|
||||
SELECT location_key, backend, object_key, size, mime_type
|
||||
FROM file_blobs
|
||||
WHERE location_key IN ($quoted)
|
||||
ORDER BY location_key;
|
||||
"@)
|
||||
$items = @()
|
||||
foreach ($row in $rows) {
|
||||
$parts = $row -split "\|", 5
|
||||
$items += [pscustomobject]@{
|
||||
LocationKey = $parts[0]
|
||||
Backend = $parts[1]
|
||||
ObjectKey = $parts[2]
|
||||
Size = [long]$parts[3]
|
||||
MimeType = $parts[4]
|
||||
}
|
||||
}
|
||||
return $items
|
||||
}
|
||||
|
||||
function Wait-FileBlobRows {
|
||||
param(
|
||||
[long[]]$DocumentIds,
|
||||
[int]$TimeoutSeconds = 10
|
||||
)
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||||
$last = @()
|
||||
while ($true) {
|
||||
$last = @(Get-FileBlobRows $DocumentIds)
|
||||
$hasBody = @($last | Where-Object { $_.LocationKey -like "doc:*" -and $_.LocationKey -notlike "*:m" -and $_.Size -gt 0 }).Count -ge 1
|
||||
$hasThumb = $AllowMissingThumb -or (@($last | Where-Object { $_.LocationKey -like "doc:*:m" -and $_.Size -gt 0 }).Count -ge 1)
|
||||
if ($hasBody -and $hasThumb) {
|
||||
return $last
|
||||
}
|
||||
if ((Get-Date) -ge $deadline) {
|
||||
return $last
|
||||
}
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
}
|
||||
|
||||
function Get-EffectiveLogSkip {
|
||||
param([pscustomobject]$State)
|
||||
$stateLog = ""
|
||||
if ($State.PSObject.Properties.Name -contains "ServerLogPath") {
|
||||
$stateLog = [string]$State.ServerLogPath
|
||||
}
|
||||
if ($stateLog) {
|
||||
$stateFull = [System.IO.Path]::GetFullPath($stateLog)
|
||||
$currentFull = [System.IO.Path]::GetFullPath($ServerLogPath)
|
||||
if ($stateFull.Equals($currentFull, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
return [int]$State.BaselineLogLineCount
|
||||
}
|
||||
Write-Warn "server log changed since baseline; scanning current log from the beginning"
|
||||
return 0
|
||||
}
|
||||
return [int]$State.BaselineLogLineCount
|
||||
}
|
||||
|
||||
function Get-BlobFilePath {
|
||||
param([string]$ObjectKey)
|
||||
if ($ObjectKey.Length -lt 4) {
|
||||
return Join-Path $BlobDir $ObjectKey
|
||||
}
|
||||
return Join-Path (Join-Path (Join-Path $BlobDir $ObjectKey.Substring(0, 2)) $ObjectKey.Substring(2, 2)) $ObjectKey
|
||||
}
|
||||
|
||||
function Save-State {
|
||||
param([pscustomobject]$State)
|
||||
$dir = Split-Path -Parent $StatePath
|
||||
if ($dir) {
|
||||
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
||||
}
|
||||
$State | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $StatePath -Encoding UTF8
|
||||
Write-Ok "state saved to $StatePath"
|
||||
}
|
||||
|
||||
function Load-State {
|
||||
if (-not (Test-Path -LiteralPath $StatePath)) {
|
||||
throw "state file not found: $StatePath. Run -Phase BeforeSend first."
|
||||
}
|
||||
Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function New-VideoFixture {
|
||||
param([string]$Path)
|
||||
$ffmpeg = Get-Command ffmpeg -ErrorAction SilentlyContinue
|
||||
if (-not $ffmpeg) {
|
||||
Add-Failure "ffmpeg is available or -VideoFixture is supplied"
|
||||
return
|
||||
}
|
||||
Invoke-External "ffmpeg" @(
|
||||
"-y",
|
||||
"-f", "lavfi",
|
||||
"-i", "testsrc=size=568x1280:rate=30",
|
||||
"-f", "lavfi",
|
||||
"-i", "sine=frequency=880:sample_rate=44100",
|
||||
"-t", "3",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-c:v", "libx264",
|
||||
"-c:a", "aac",
|
||||
"-shortest",
|
||||
$Path
|
||||
) | Out-Null
|
||||
}
|
||||
|
||||
function Ensure-VideoFixture {
|
||||
New-Item -ItemType Directory -Force -Path $FixtureDir | Out-Null
|
||||
if (-not $VideoFixture) {
|
||||
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||
$script:VideoFixture = Join-Path $FixtureDir "telesrv-android-video-upload-$stamp.mp4"
|
||||
New-VideoFixture $script:VideoFixture
|
||||
}
|
||||
Assert-Check ($VideoFixture -and (Test-Path -LiteralPath $VideoFixture)) "video fixture exists: $VideoFixture"
|
||||
}
|
||||
|
||||
function Push-VideoFixture {
|
||||
if ($SkipAdb) {
|
||||
Write-Warn "adb push skipped"
|
||||
return
|
||||
}
|
||||
Invoke-Adb @("shell", "mkdir", "-p", $RemoteMovieDir) | Out-Null
|
||||
Invoke-Adb @("push", $VideoFixture, "$RemoteMovieDir/") | Out-Null
|
||||
$leaf = Split-Path -Leaf $VideoFixture
|
||||
Invoke-Adb @("shell", "am", "broadcast", "-a", "android.intent.action.MEDIA_SCANNER_SCAN_FILE", "-d", "file://$RemoteMovieDir/$leaf") | Out-Null
|
||||
Write-Ok "video pushed to Android: $RemoteMovieDir/$leaf"
|
||||
}
|
||||
|
||||
function Run-Preflight {
|
||||
Write-Step "Preflight"
|
||||
if (-not $SkipAdb) {
|
||||
if (Assert-Command "adb") {
|
||||
$devices = Invoke-Adb @("devices")
|
||||
$deviceLines = @($devices.Output -split "`r?`n" | Where-Object { $_ -match "\tdevice$" })
|
||||
Assert-Check ($deviceLines.Count -ge 1) "adb has at least one connected device"
|
||||
Assert-Check (($deviceLines.Count -eq 1) -or [bool]$DeviceSerial) "adb selects a single device or -DeviceSerial is set"
|
||||
if (($deviceLines.Count -eq 1) -or [bool]$DeviceSerial) {
|
||||
$pkg = Invoke-Adb @("shell", "dumpsys", "package", $AndroidPackage)
|
||||
Assert-Check ($pkg.Output -match "versionName=") "Android package $AndroidPackage is installed"
|
||||
$model = (Invoke-Adb @("shell", "getprop", "ro.product.model")).Output.Trim()
|
||||
$sdk = (Invoke-Adb @("shell", "getprop", "ro.build.version.sdk")).Output.Trim()
|
||||
Write-Host "Android device: model=$model sdk=$sdk"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Write-Warn "adb checks skipped"
|
||||
}
|
||||
if (Assert-Command "docker") {
|
||||
Invoke-PsqlRows "SELECT 1;" | Out-Null
|
||||
Write-Ok "PostgreSQL is reachable through docker container $PostgresContainer"
|
||||
}
|
||||
Assert-Check (Test-Path -LiteralPath $ServerLogPath) "server log exists: $ServerLogPath"
|
||||
Write-Host "server log: $ServerLogPath"
|
||||
}
|
||||
|
||||
function Run-Prepare {
|
||||
Write-Step "Prepare video fixture"
|
||||
Run-Preflight
|
||||
Ensure-VideoFixture
|
||||
Push-VideoFixture
|
||||
Write-Host "Manual step: send this video from Android/Alice to Bob:"
|
||||
Write-Host " $VideoFixture"
|
||||
}
|
||||
|
||||
function Run-BeforeSend {
|
||||
Write-Step "BeforeSend snapshot"
|
||||
$maxMessageId = Get-PrivateMessageMaxId
|
||||
$usage = Get-UploadPartUsage
|
||||
$lineCount = Get-LogLineCount
|
||||
Write-Host "private_messages max id before send: $maxMessageId"
|
||||
Write-Host "upload_parts before send: parts=$($usage.Parts) bytes=$($usage.Bytes)"
|
||||
$state = [pscustomobject]@{
|
||||
SenderUserId = $SenderUserId
|
||||
RecipientUserId = $RecipientUserId
|
||||
BaselinePrivateMessageId = $maxMessageId
|
||||
BaselineUploadParts = $usage.Parts
|
||||
BaselineUploadPartBytes = $usage.Bytes
|
||||
BaselineLogLineCount = $lineCount
|
||||
ServerLogPath = $ServerLogPath
|
||||
BlobDir = $BlobDir
|
||||
CreatedAt = (Get-Date -Format o)
|
||||
VideoFixture = $VideoFixture
|
||||
}
|
||||
Save-State $state
|
||||
}
|
||||
|
||||
function Run-AfterSend {
|
||||
Write-Step "AfterSend assertions"
|
||||
$state = Load-State
|
||||
$messages = @(Get-NewVideoMessages ([long]$state.BaselinePrivateMessageId))
|
||||
foreach ($message in $messages) {
|
||||
Write-Host ("new private message id={0} kind={1} video={2} doc={3} mime={4} size={5} thumbs={6}" -f $message.MessageId, $message.Kind, $message.Video, $message.DocumentId, $message.MimeType, $message.Size, $message.ThumbCount)
|
||||
}
|
||||
$videos = @($messages | Where-Object {
|
||||
$_.Kind -eq "document" -and $_.Video -eq "true" -and $_.MimeType -eq "video/mp4" -and $_.DocumentId -gt 0
|
||||
})
|
||||
Assert-Check ($videos.Count -ge 1) "new private message includes uploaded video/mp4 document"
|
||||
|
||||
$docIds = @($videos | Select-Object -ExpandProperty DocumentId -Unique)
|
||||
$documents = @(Get-DocumentRows $docIds)
|
||||
$blobs = @(Wait-FileBlobRows $docIds)
|
||||
foreach ($doc in $documents) {
|
||||
Write-Host ("document id={0} mime={1} size={2} thumbs={3}" -f $doc.DocumentId, $doc.MimeType, $doc.Size, $doc.ThumbCount)
|
||||
}
|
||||
foreach ($blob in $blobs) {
|
||||
Write-Host ("blob key={0} backend={1} object={2} size={3} mime={4}" -f $blob.LocationKey, $blob.Backend, $blob.ObjectKey, $blob.Size, $blob.MimeType)
|
||||
}
|
||||
|
||||
Assert-Check ($documents.Count -ge $docIds.Count) "documents rows exist for uploaded video"
|
||||
Assert-Check (@($documents | Where-Object { $_.MimeType -eq "video/mp4" -and $_.Size -gt 0 }).Count -ge 1) "uploaded video document metadata is persisted"
|
||||
if (-not $AllowMissingThumb) {
|
||||
Assert-Check (@($documents | Where-Object { $_.ThumbCount -gt 0 }).Count -ge 1) "uploaded video document has thumbnail metadata"
|
||||
}
|
||||
Assert-Check (@($blobs | Where-Object { $_.LocationKey -like "doc:*" -and $_.LocationKey -notlike "*:m" -and $_.MimeType -eq "video/mp4" -and $_.Size -gt 0 }).Count -ge 1) "video body file_blobs row exists"
|
||||
if (-not $AllowMissingThumb) {
|
||||
Assert-Check (@($blobs | Where-Object { $_.LocationKey -like "doc:*:m" -and $_.Size -gt 0 }).Count -ge 1) "video thumbnail file_blobs row exists"
|
||||
}
|
||||
foreach ($blob in $blobs) {
|
||||
if ($blob.Backend -eq "localfs" -and $blob.ObjectKey) {
|
||||
$path = Get-BlobFilePath $blob.ObjectKey
|
||||
Assert-Check (Test-Path -LiteralPath $path) "localfs blob exists: $($blob.LocationKey)"
|
||||
}
|
||||
}
|
||||
|
||||
$usage = Get-UploadPartUsage
|
||||
Write-Host "upload_parts after send: parts=$($usage.Parts) bytes=$($usage.Bytes)"
|
||||
if ([int]$state.BaselineUploadParts -eq 0) {
|
||||
Assert-Check ($usage.Parts -eq 0) "upload_parts cleaned after successful upload"
|
||||
} else {
|
||||
Assert-Check ($usage.Parts -le [int]$state.BaselineUploadParts) "upload_parts did not grow after successful upload"
|
||||
}
|
||||
|
||||
$lines = @(Get-LogLinesSince (Get-EffectiveLogSkip $state))
|
||||
$savePartHits = @($lines | Where-Object {
|
||||
($_ -like "*upload.saveFilePart*" -or $_ -like "*upload.saveBigFilePart*") -and $_ -like '*client_type": "android"*'
|
||||
})
|
||||
$sendMediaHits = @($lines | Where-Object {
|
||||
$_ -like "*messages.sendMedia*" -and $_ -like '*client_type": "android"*'
|
||||
})
|
||||
$bad = @($lines | Where-Object {
|
||||
$_ -cmatch "INTERNAL_SERVER_ERROR|rpc error|Unhandled RPC|NOT_IMPLEMENTED|bad_msg|panic|\tERROR\t"
|
||||
})
|
||||
Assert-Check ($savePartHits.Count -ge 1) "server log has Android upload.saveFilePart/saveBigFilePart"
|
||||
Assert-Check ($sendMediaHits.Count -ge 1) "server log has Android messages.sendMedia"
|
||||
Assert-Check ($bad.Count -eq 0) "server log has no upload-era internal errors or unhandled RPCs"
|
||||
|
||||
if (-not $SkipAdb) {
|
||||
$logcat = Invoke-Adb @("logcat", "-d", "-t", "1200") -AllowFailure
|
||||
if ($logcat.ExitCode -eq 0) {
|
||||
$androidErrors = @($logcat.Output -split "`r?`n" | Where-Object {
|
||||
$_ -match "INTERNAL_SERVER_ERROR|rpc error 500|saveFilePart|saveBigFilePart|FileUploadOperation"
|
||||
})
|
||||
if ($androidErrors.Count -gt 0) {
|
||||
Write-Host "Recent Android upload log lines:"
|
||||
$androidErrors | Select-Object -Last 40 | ForEach-Object { Write-Host $_ }
|
||||
}
|
||||
$fatalAndroidErrors = @($androidErrors | Where-Object { $_ -match "INTERNAL_SERVER_ERROR|rpc error 500" })
|
||||
Assert-Check ($fatalAndroidErrors.Count -eq 0) "recent Android logcat has no upload 500"
|
||||
} else {
|
||||
Write-Warn "adb logcat scan failed: $($logcat.Output)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Finish-Run {
|
||||
if ($Failures.Count -gt 0) {
|
||||
Write-Host ""
|
||||
Write-Host "Validation failed:"
|
||||
foreach ($failure in $Failures) {
|
||||
Write-Host " - $failure"
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host "Validation passed."
|
||||
}
|
||||
|
||||
switch ($Phase) {
|
||||
"Preflight" { Run-Preflight }
|
||||
"Prepare" { Run-Prepare }
|
||||
"BeforeSend" { Run-BeforeSend }
|
||||
"AfterSend" { Run-AfterSend }
|
||||
"All" {
|
||||
Run-Prepare
|
||||
Run-BeforeSend
|
||||
Read-Host "Send the prepared video from Android/Alice to Bob, then press Enter"
|
||||
Run-AfterSend
|
||||
}
|
||||
}
|
||||
|
||||
Finish-Run
|
||||
Loading…
Add table
Add a link
Reference in a new issue