diff --git a/deploy/migrations/0037_conference_calls.down.sql b/deploy/migrations/0037_conference_calls.down.sql new file mode 100644 index 00000000..76b36a8e --- /dev/null +++ b/deploy/migrations/0037_conference_calls.down.sql @@ -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; diff --git a/deploy/migrations/0037_conference_calls.up.sql b/deploy/migrations/0037_conference_calls.up.sql new file mode 100644 index 00000000..7fbcc391 --- /dev/null +++ b/deploy/migrations/0037_conference_calls.up.sql @@ -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) +); diff --git a/deploy/migrations/0038_channel_manage_ranks_full_admin.down.sql b/deploy/migrations/0038_channel_manage_ranks_full_admin.down.sql new file mode 100644 index 00000000..2a9ff2b8 --- /dev/null +++ b/deploy/migrations/0038_channel_manage_ranks_full_admin.down.sql @@ -0,0 +1,2 @@ +-- Data repair only. Do not remove ManageRanks on rollback because later user edits +-- may have intentionally granted it. diff --git a/deploy/migrations/0038_channel_manage_ranks_full_admin.up.sql b/deploy/migrations/0038_channel_manage_ranks_full_admin.up.sql new file mode 100644 index 00000000..e0f7766e --- /dev/null +++ b/deploy/migrations/0038_channel_manage_ranks_full_admin.up.sql @@ -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) + ) + ); diff --git a/deploy/migrations/0039_group_call_chain_author.down.sql b/deploy/migrations/0039_group_call_chain_author.down.sql new file mode 100644 index 00000000..d09a2c8c --- /dev/null +++ b/deploy/migrations/0039_group_call_chain_author.down.sql @@ -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; diff --git a/deploy/migrations/0039_group_call_chain_author.up.sql b/deploy/migrations/0039_group_call_chain_author.up.sql new file mode 100644 index 00000000..17592fc7 --- /dev/null +++ b/deploy/migrations/0039_group_call_chain_author.up.sql @@ -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); diff --git a/deploy/migrations/0040_discard_empty_conferences.down.sql b/deploy/migrations/0040_discard_empty_conferences.down.sql new file mode 100644 index 00000000..eb348010 --- /dev/null +++ b/deploy/migrations/0040_discard_empty_conferences.down.sql @@ -0,0 +1 @@ +-- No-op: discarded empty conference calls are terminal cleanup records. diff --git a/deploy/migrations/0040_discard_empty_conferences.up.sql b/deploy/migrations/0040_discard_empty_conferences.up.sql new file mode 100644 index 00000000..03e89fdc --- /dev/null +++ b/deploy/migrations/0040_discard_empty_conferences.up.sql @@ -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; diff --git a/internal/app/channels/participants_cache.go b/internal/app/channels/participants_cache.go index 59074d78..6a7fc820 100644 --- a/internal/app/channels/participants_cache.go +++ b/internal/app/channels/participants_cache.go @@ -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 { diff --git a/internal/app/channels/service.go b/internal/app/channels/service.go index b0e7e6dc..247bfd36 100644 --- a/internal/app/channels/service.go +++ b/internal/app/channels/service.go @@ -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 } diff --git a/internal/app/channels/service_test.go b/internal/app/channels/service_test.go index 05b6fc5b..6c4baf4f 100644 --- a/internal/app/channels/service_test.go +++ b/internal/app/channels/service_test.go @@ -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 { diff --git a/internal/app/groupcalls/service.go b/internal/app/groupcalls/service.go index 3381c803..6de66e5e 100644 --- a/internal/app/groupcalls/service.go +++ b/internal/app/groupcalls/service.go @@ -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 +} diff --git a/internal/app/phone/registry.go b/internal/app/phone/registry.go index 4e72bca3..d464f2fd 100644 --- a/internal/app/phone/registry.go +++ b/internal/app/phone/registry.go @@ -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) diff --git a/internal/app/phone/service.go b/internal/app/phone/service.go index d23ef955..fb97c500 100644 --- a/internal/app/phone/service.go +++ b/internal/app/phone/service.go @@ -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 } diff --git a/internal/compat/layerwire/inbound.go b/internal/compat/layerwire/inbound.go index 1695fc0a..de413c91 100644 --- a/internal/compat/layerwire/inbound.go +++ b/internal/compat/layerwire/inbound.go @@ -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) diff --git a/internal/compat/layerwire/inbound_transform_test.go b/internal/compat/layerwire/inbound_transform_test.go index de9cfe65..136bc3b9 100644 --- a/internal/compat/layerwire/inbound_transform_test.go +++ b/internal/compat/layerwire/inbound_transform_test.go @@ -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 diff --git a/internal/compat/layerwire/schema/client-drift.tl b/internal/compat/layerwire/schema/client-drift.tl index 54d328c1..dc36008a 100644 --- a/internal/compat/layerwire/schema/client-drift.tl +++ b/internal/compat/layerwire/schema/client-drift.tl @@ -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 = Vector; langpack.getLanguages#800fd57d = Vector; +// 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; diff --git a/internal/domain/channel.go b/internal/domain/channel.go index 98fd7e8f..d397dab9 100644 --- a/internal/domain/channel.go +++ b/internal/domain/channel.go @@ -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 { diff --git a/internal/domain/contact.go b/internal/domain/contact.go index 052acba7..38403299 100644 --- a/internal/domain/contact.go +++ b/internal/domain/contact.go @@ -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 diff --git a/internal/domain/groupcall.go b/internal/domain/groupcall.go index 82711d8a..1ed06216 100644 --- a/internal/domain/groupcall.go +++ b/internal/domain/groupcall.go @@ -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 +} diff --git a/internal/domain/media.go b/internal/domain/media.go index 38993a01..aebf430b 100644 --- a/internal/domain/media.go +++ b/internal/domain/media.go @@ -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 的协议中立载荷:内嵌礼物快照(贴纸/星价) diff --git a/internal/domain/phone.go b/internal/domain/phone.go index ce4c2915..54f3be70 100644 --- a/internal/domain/phone.go +++ b/internal/domain/phone.go @@ -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 diff --git a/internal/rpc/channels_leave_rpc_test.go b/internal/rpc/channels_leave_rpc_test.go index 6d984ddf..91631f50 100644 --- a/internal/rpc/channels_leave_rpc_test.go +++ b/internal/rpc/channels_leave_rpc_test.go @@ -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() diff --git a/internal/rpc/channels_legacy_chat.go b/internal/rpc/channels_legacy_chat.go index 09416e64..a5a98ed9 100644 --- a/internal/rpc/channels_legacy_chat.go +++ b/internal/rpc/channels_legacy_chat.go @@ -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) { diff --git a/internal/rpc/channels_members.go b/internal/rpc/channels_members.go index 738c8037..1bceff03 100644 --- a/internal/rpc/channels_members.go +++ b/internal/rpc/channels_members.go @@ -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) + } +} diff --git a/internal/rpc/channels_public_preview_rpc_test.go b/internal/rpc/channels_public_preview_rpc_test.go index d78c0429..70c5ddab 100644 --- a/internal/rpc/channels_public_preview_rpc_test.go +++ b/internal/rpc/channels_public_preview_rpc_test.go @@ -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]) + } } diff --git a/internal/rpc/contacts.go b/internal/rpc/contacts.go index 78983d22..10794bd8 100644 --- a/internal/rpc/contacts.go +++ b/internal/rpc/contacts.go @@ -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 diff --git a/internal/rpc/contacts_users_rpc_test.go b/internal/rpc/contacts_users_rpc_test.go index 9d949578..0e663d9a 100644 --- a/internal/rpc/contacts_users_rpc_test.go +++ b/internal/rpc/contacts_users_rpc_test.go @@ -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 diff --git a/internal/rpc/convert_channels_core.go b/internal/rpc/convert_channels_core.go index 308a72a8..e0fbc6fd 100644 --- a/internal/rpc/convert_channels_core.go +++ b/internal/rpc/convert_channels_core.go @@ -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, diff --git a/internal/rpc/convert_dialogs.go b/internal/rpc/convert_dialogs.go index 848c59ff..31d36ea1 100644 --- a/internal/rpc/convert_dialogs.go +++ b/internal/rpc/convert_dialogs.go @@ -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)) } diff --git a/internal/rpc/convert_messages.go b/internal/rpc/convert_messages.go index 26ec5af5..e8c7633d 100644 --- a/internal/rpc/convert_messages.go +++ b/internal/rpc/convert_messages.go @@ -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 { diff --git a/internal/rpc/convert_phone.go b/internal/rpc/convert_phone.go index 1616f0e4..c4b5e5bc 100644 --- a/internal/rpc/convert_phone.go +++ b/internal/rpc/convert_phone.go @@ -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 "" +} diff --git a/internal/rpc/convert_phone_group.go b/internal/rpc/convert_phone_group.go index 188f4dc7..33f347d1 100644 --- a/internal/rpc/convert_phone_group.go +++ b/internal/rpc/convert_phone_group.go @@ -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 } diff --git a/internal/rpc/convert_users.go b/internal/rpc/convert_users.go index 68571d95..3e18707f 100644 --- a/internal/rpc/convert_users.go +++ b/internal/rpc/convert_users.go @@ -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}) diff --git a/internal/rpc/deps.go b/internal/rpc/deps.go index efa6f7d2..18bb4a3e 100644 --- a/internal/rpc/deps.go +++ b/internal/rpc/deps.go @@ -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)。 diff --git a/internal/rpc/errors.go b/internal/rpc/errors.go index 5e530ef5..1a21ca01 100644 --- a/internal/rpc/errors.go +++ b/internal/rpc/errors.go @@ -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 错误映射。 diff --git a/internal/rpc/groupcall_sweep_dispatcher.go b/internal/rpc/groupcall_sweep_dispatcher.go index c4dd34fa..c0f086ce 100644 --- a/internal/rpc/groupcall_sweep_dispatcher.go +++ b/internal/rpc/groupcall_sweep_dispatcher.go @@ -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 diff --git a/internal/rpc/messages_forward.go b/internal/rpc/messages_forward.go index 4dc86b22..7c38b98d 100644 --- a/internal/rpc/messages_forward.go +++ b/internal/rpc/messages_forward.go @@ -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 diff --git a/internal/rpc/messages_forward_rpc_test.go b/internal/rpc/messages_forward_rpc_test.go index 22770106..6e0429b3 100644 --- a/internal/rpc/messages_forward_rpc_test.go +++ b/internal/rpc/messages_forward_rpc_test.go @@ -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() diff --git a/internal/rpc/messages_scheduled.go b/internal/rpc/messages_scheduled.go index 0b580b13..78f44904 100644 --- a/internal/rpc/messages_scheduled.go +++ b/internal/rpc/messages_scheduled.go @@ -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) } diff --git a/internal/rpc/phone_calls.go b/internal/rpc/phone_calls.go index 2e93cb51..742b0776 100644 --- a/internal/rpc/phone_calls.go +++ b/internal/rpc/phone_calls.go @@ -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 diff --git a/internal/rpc/phone_conference.go b/internal/rpc/phone_conference.go new file mode 100644 index 00000000..fc583aa6 --- /dev/null +++ b/internal/rpc/phone_conference.go @@ -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)) + } +} diff --git a/internal/rpc/phone_conference_rpc_test.go b/internal/rpc/phone_conference_rpc_test.go new file mode 100644 index 00000000..4da56b89 --- /dev/null +++ b/internal/rpc/phone_conference_rpc_test.go @@ -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 +} diff --git a/internal/rpc/phone_group_call.go b/internal/rpc/phone_group_call.go index f457c907..5685455f 100644 --- a/internal/rpc/phone_group_call.go +++ b/internal/rpc/phone_group_call.go @@ -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 } diff --git a/internal/rpc/phone_group_call_edit.go b/internal/rpc/phone_group_call_edit.go index af1c3886..f3287914 100644 --- a/internal/rpc/phone_group_call_edit.go +++ b/internal/rpc/phone_group_call_edit.go @@ -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() } diff --git a/internal/rpc/phone_group_call_rpc_test.go b/internal/rpc/phone_group_call_rpc_test.go index bb955d72..e07ea8c3 100644 --- a/internal/rpc/phone_group_call_rpc_test.go +++ b/internal/rpc/phone_group_call_rpc_test.go @@ -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) {} diff --git a/internal/rpc/phone_group_call_updates.go b/internal/rpc/phone_group_call_updates.go index c58a2479..db3a60ef 100644 --- a/internal/rpc/phone_group_call_updates.go +++ b/internal/rpc/phone_group_call_updates.go @@ -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 +} diff --git a/internal/rpc/phone_register.go b/internal/rpc/phone_register.go index 78f837bd..49dc5a56 100644 --- a/internal/rpc/phone_register.go +++ b/internal/rpc/phone_register.go @@ -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) diff --git a/internal/store/channel.go b/internal/store/channel.go index 06d2adb2..48d205c4 100644 --- a/internal/store/channel.go +++ b/internal/store/channel.go @@ -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) diff --git a/internal/store/groupcall.go b/internal/store/groupcall.go index 955695bb..9dc312d2 100644 --- a/internal/store/groupcall.go +++ b/internal/store/groupcall.go @@ -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) } diff --git a/internal/store/memory/channel_core.go b/internal/store/memory/channel_core.go index 9907b700..d06352ab 100644 --- a/internal/store/memory/channel_core.go +++ b/internal/store/memory/channel_core.go @@ -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{ diff --git a/internal/store/memory/channel_helpers.go b/internal/store/memory/channel_helpers.go index 5c702094..37a6788a 100644 --- a/internal/store/memory/channel_helpers.go +++ b/internal/store/memory/channel_helpers.go @@ -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 } diff --git a/internal/store/memory/channel_members.go b/internal/store/memory/channel_members.go index 0bc22c3a..7a8a86fd 100644 --- a/internal/store/memory/channel_members.go +++ b/internal/store/memory/channel_members.go @@ -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 { diff --git a/internal/store/memory/groupcall.go b/internal/store/memory/groupcall.go index f200518d..1c076818 100644 --- a/internal/store/memory/groupcall.go +++ b/internal/store/memory/groupcall.go @@ -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 diff --git a/internal/store/postgres/channel_helpers.go b/internal/store/postgres/channel_helpers.go index c3a3369b..e078f58d 100644 --- a/internal/store/postgres/channel_helpers.go +++ b/internal/store/postgres/channel_helpers.go @@ -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 diff --git a/internal/store/postgres/channel_member_admin.go b/internal/store/postgres/channel_member_admin.go index 666f9ce8..9d1420f9 100644 --- a/internal/store/postgres/channel_member_admin.go +++ b/internal/store/postgres/channel_member_admin.go @@ -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 diff --git a/internal/store/postgres/channel_member_helpers.go b/internal/store/postgres/channel_member_helpers.go index 022ab1a9..c3e82c9e 100644 --- a/internal/store/postgres/channel_member_helpers.go +++ b/internal/store/postgres/channel_member_helpers.go @@ -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(), } } diff --git a/internal/store/postgres/groupcall.go b/internal/store/postgres/groupcall.go index 0f1f93e0..33719be0 100644 --- a/internal/store/postgres/groupcall.go +++ b/internal/store/postgres/groupcall.go @@ -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 +} diff --git a/internal/store/storetest/groupcall.go b/internal/store/storetest/groupcall.go index cd295dce..38edf207 100644 --- a/internal/store/storetest/groupcall.go +++ b/internal/store/storetest/groupcall.go @@ -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)。 diff --git a/scripts/analyze-media-download-log.ps1 b/scripts/analyze-media-download-log.ps1 new file mode 100644 index 00000000..e566aba6 --- /dev/null +++ b/scripts/analyze-media-download-log.ps1 @@ -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 } +} diff --git a/scripts/check-local-runtime.ps1 b/scripts/check-local-runtime.ps1 new file mode 100644 index 00000000..762fd762 --- /dev/null +++ b/scripts/check-local-runtime.ps1 @@ -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." diff --git a/scripts/restart-local-server.ps1 b/scripts/restart-local-server.ps1 new file mode 100644 index 00000000..9b307f36 --- /dev/null +++ b/scripts/restart-local-server.ps1 @@ -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 +} diff --git a/scripts/validate-android-big-video-upload.ps1 b/scripts/validate-android-big-video-upload.ps1 new file mode 100644 index 00000000..857620e6 --- /dev/null +++ b/scripts/validate-android-big-video-upload.ps1 @@ -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 diff --git a/scripts/validate-android-offline-media-recovery.ps1 b/scripts/validate-android-offline-media-recovery.ps1 new file mode 100644 index 00000000..b8ed02ef --- /dev/null +++ b/scripts/validate-android-offline-media-recovery.ps1 @@ -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 = @" + + + Android to TDesktop offline media recovery validation + +"@ + 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 diff --git a/scripts/validate-android-video-upload.ps1 b/scripts/validate-android-video-upload.ps1 new file mode 100644 index 00000000..b8e67c55 --- /dev/null +++ b/scripts/validate-android-video-upload.ps1 @@ -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