feat: sync recent call and channel fixes

This commit is contained in:
A 2026-07-01 14:34:59 +08:00
parent e5e0080216
commit 866a87583e
65 changed files with 6680 additions and 229 deletions

View file

@ -0,0 +1,24 @@
DROP TABLE IF EXISTS public.group_call_chain_blocks;
DROP TABLE IF EXISTS public.group_call_invites;
DROP INDEX IF EXISTS public.group_calls_conference_random_uniq;
DROP INDEX IF EXISTS public.group_calls_invite_slug_uniq;
DROP INDEX IF EXISTS public.group_calls_active_channel_uniq;
CREATE UNIQUE INDEX IF NOT EXISTS group_calls_active_channel_uniq
ON public.group_calls USING btree (channel_id)
WHERE (state = 'active'::text);
ALTER TABLE public.group_call_participants
DROP COLUMN IF EXISTS join_block,
DROP COLUMN IF EXISTS public_key;
ALTER TABLE public.group_calls
DROP CONSTRAINT IF EXISTS group_calls_kind_check;
ALTER TABLE public.group_calls
DROP COLUMN IF EXISTS migrated_from_phone_call_id,
DROP COLUMN IF EXISTS random_id,
DROP COLUMN IF EXISTS invite_link,
DROP COLUMN IF EXISTS invite_slug,
DROP COLUMN IF EXISTS kind;

View file

@ -0,0 +1,58 @@
ALTER TABLE public.group_calls
ADD COLUMN IF NOT EXISTS kind text DEFAULT 'channel'::text NOT NULL,
ADD COLUMN IF NOT EXISTS invite_slug text DEFAULT ''::text NOT NULL,
ADD COLUMN IF NOT EXISTS invite_link text DEFAULT ''::text NOT NULL,
ADD COLUMN IF NOT EXISTS random_id bigint DEFAULT 0 NOT NULL,
ADD COLUMN IF NOT EXISTS migrated_from_phone_call_id bigint DEFAULT 0 NOT NULL;
ALTER TABLE public.group_calls
DROP CONSTRAINT IF EXISTS group_calls_kind_check;
ALTER TABLE public.group_calls
ADD CONSTRAINT group_calls_kind_check CHECK (kind = ANY (ARRAY['channel'::text, 'conference'::text]));
ALTER TABLE public.group_call_participants
ADD COLUMN IF NOT EXISTS public_key bytea,
ADD COLUMN IF NOT EXISTS join_block bytea;
DROP INDEX IF EXISTS public.group_calls_active_channel_uniq;
CREATE UNIQUE INDEX IF NOT EXISTS group_calls_active_channel_uniq
ON public.group_calls USING btree (channel_id)
WHERE ((state = 'active'::text) AND (channel_id <> 0));
CREATE UNIQUE INDEX IF NOT EXISTS group_calls_invite_slug_uniq
ON public.group_calls USING btree (invite_slug)
WHERE (invite_slug <> ''::text);
CREATE UNIQUE INDEX IF NOT EXISTS group_calls_conference_random_uniq
ON public.group_calls USING btree (creator_user_id, random_id)
WHERE ((kind = 'conference'::text) AND (random_id <> 0));
CREATE TABLE IF NOT EXISTS public.group_call_invites (
call_id bigint NOT NULL REFERENCES public.group_calls(call_id) ON DELETE CASCADE,
inviter_user_id bigint NOT NULL,
invitee_user_id bigint NOT NULL,
message_id integer NOT NULL,
status text DEFAULT 'pending'::text NOT NULL,
video boolean DEFAULT false NOT NULL,
created_at integer NOT NULL,
updated_at integer DEFAULT 0 NOT NULL,
CONSTRAINT group_call_invites_status_check CHECK (status = ANY (ARRAY['pending'::text, 'accepted'::text, 'declined'::text, 'missed'::text, 'revoked'::text])),
CONSTRAINT group_call_invites_pkey PRIMARY KEY (call_id, invitee_user_id, message_id)
);
CREATE UNIQUE INDEX IF NOT EXISTS group_call_invites_msg_uniq
ON public.group_call_invites USING btree (invitee_user_id, message_id);
CREATE INDEX IF NOT EXISTS group_call_invites_call_idx
ON public.group_call_invites USING btree (call_id, invitee_user_id);
CREATE TABLE IF NOT EXISTS public.group_call_chain_blocks (
call_id bigint NOT NULL REFERENCES public.group_calls(call_id) ON DELETE CASCADE,
sub_chain_id integer DEFAULT 0 NOT NULL,
block_offset integer NOT NULL,
block bytea NOT NULL,
created_at integer NOT NULL,
CONSTRAINT group_call_chain_blocks_pkey PRIMARY KEY (call_id, sub_chain_id, block_offset)
);

View file

@ -0,0 +1,2 @@
-- Data repair only. Do not remove ManageRanks on rollback because later user edits
-- may have intentionally granted it.

View file

@ -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)
)
);

View file

@ -0,0 +1,4 @@
DROP INDEX IF EXISTS public.group_call_chain_blocks_author_idx;
ALTER TABLE public.group_call_chain_blocks
DROP COLUMN IF EXISTS author_user_id;

View file

@ -0,0 +1,5 @@
ALTER TABLE public.group_call_chain_blocks
ADD COLUMN IF NOT EXISTS author_user_id bigint DEFAULT 0 NOT NULL;
CREATE INDEX IF NOT EXISTS group_call_chain_blocks_author_idx
ON public.group_call_chain_blocks USING btree (call_id, sub_chain_id, author_user_id, block_offset);

View file

@ -0,0 +1 @@
-- No-op: discarded empty conference calls are terminal cleanup records.

View file

@ -0,0 +1,31 @@
WITH stale AS (
SELECT c.call_id
FROM public.group_calls c
WHERE c.kind = 'conference'::text
AND c.state = 'active'::text
AND EXISTS (
SELECT 1
FROM public.group_call_participants p
WHERE p.call_id = c.call_id
)
AND NOT EXISTS (
SELECT 1
FROM public.group_call_participants p
WHERE p.call_id = c.call_id
AND NOT p.left_call
)
)
UPDATE public.group_calls c
SET state = 'discarded'::text,
discarded_at = CASE
WHEN c.discarded_at = 0 THEN EXTRACT(EPOCH FROM now())::integer
ELSE c.discarded_at
END,
duration = CASE
WHEN c.duration = 0 THEN GREATEST(0, EXTRACT(EPOCH FROM now())::integer - c.created_at)
ELSE c.duration
END,
participants_count = 0,
version = c.version + 1
FROM stale
WHERE c.call_id = stale.call_id;

View file

@ -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 {

View file

@ -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
}

View file

@ -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 {

View file

@ -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
}

View file

@ -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)

View file

@ -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
}

View file

@ -12,6 +12,12 @@ import (
const (
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
)
@ -35,6 +41,7 @@ func mustLoadDrift() *schemaModel {
// transform). It is the only thing a structural rename needs.
var driftFieldRenames = map[string]string{
"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)

View file

@ -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

View file

@ -26,3 +26,7 @@ contacts.search#11f812d8 q:string limit:int = contacts.Found;
langpack.getLangPack#9ab5c58e lang_code:string = LangPackDifference;
langpack.getStrings#2e1ee318 lang_code:string keys:Vector<string> = Vector<LangPackString>;
langpack.getLanguages#800fd57d = Vector<LangPackLanguage>;
// DrKLO still emits old channels.editCreator#8f38cd1f; canonical 227 replaced
// that flow with messages.editChatCreator(peer:InputPeer,...). Keep the old
// constructor id here but target the canonical method name for generic upgrade.
messages.editChatCreator#8f38cd1f channel:InputChannel user_id:InputUser password:InputCheckPasswordSRP = Updates;

View file

@ -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 {

View file

@ -66,7 +66,7 @@ type ImportContactsResult struct {
// UserSearchResult 是 contacts.search 的业务结果。
// MyResults 放当前账号通讯录内命中的用户Results 放其他全局命中用户。
// MyChannelResults 放当前账号已加入的公开 channel/supergroupChannelResults 放其他公开命中
// ChannelResults 放未加入的公开 channel/supergroup 命中;已加入频道由 dialogs/resolve 路径呈现
type UserSearchResult struct {
MyResults []User
Results []User

View file

@ -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) 的频道消息 iddiscard 时
// 客户端用它定位起始服务消息,当前仅记录)。
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,6 +102,10 @@ type GroupCallParticipant struct {
// 快照M3/M4 启用M0/M1 仅透明保存 self-edit不转发
VideoJSON []byte
PresentationJSON []byte
// PublicKey/JoinBlock 仅 conference 使用。服务端不解析 E2E 内容,只持久化
// opaque bytes 并供 chain block 补拉/转发。
PublicKey []byte
JoinBlock []byte
Left bool
// LastCheckDate 是 checkGroupCall 保活水位。注意:客户端只在 Connecting 态
// 发 checkGroupCall媒体连通后心跳停止掉线判定必须与 SFU 媒体面活性
@ -90,6 +129,8 @@ type JoinGroupCallRequest struct {
SSRC int64
Muted bool
IsAdmin bool
PublicKey []byte
JoinBlock []byte
// VideoJSON 是本次 join 铸造的视频内部状态endpoint+源组+activerejoin
// 整体替换并**清空旧 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
}

View file

@ -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"`
@ -453,6 +466,7 @@ type MessageServiceAction struct {
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"`

View file

@ -105,6 +105,9 @@ type PhoneCall struct {
P2PAllowed bool
DiscardReason PhoneCallDiscardReason
// 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 原样保留(见其注释)。

View file

@ -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()

View file

@ -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) {

View file

@ -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)
}
}

View file

@ -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])
}
}

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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))
}

View file

@ -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 {

View file

@ -68,6 +68,7 @@ func tgPhoneCallForViewer(call domain.PhoneCall, viewerID int64) tg.PhoneCallCla
out := &tg.PhoneCall{
P2PAllowed: call.P2PAllowed,
Video: call.Video,
ConferenceSupported: true,
ID: call.ID,
AccessHash: call.AccessHash,
Date: call.Date,
@ -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 ""
}

View file

@ -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_videoTDesktop 不读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
}

View file

@ -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})

View file

@ -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 在该通话的信令顺序锁内执行 forwarddrop=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

View file

@ -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 错误映射。

View file

@ -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

View file

@ -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 {
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

View file

@ -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()

View file

@ -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)
}

View file

@ -210,16 +210,29 @@ 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)
if reason != domain.PhoneCallDiscardReasonMigrateConference {
// 落 messageActionPhoneCall 历史(带 pts 走 outbox双方全部设备可靠收到
r.sendPhoneCallServiceMessage(ctx, call)
}
}
// 双方同时挂断的竞态:先到者定 reason后到者拿终态快照幂等成功
return r.phoneCallUpdates(ctx, call, userID), nil
}

View file

@ -0,0 +1,468 @@
package rpc
import (
"context"
"encoding/binary"
"github.com/gotd/td/tg"
"go.uber.org/zap"
"telesrv/internal/domain"
"telesrv/internal/sfu"
)
const (
maxConferenceChainBlockBytes = 64 * 1024
maxConferenceChainBlocks = 100
conferenceChainBlockConstructor = 0x639a3db6
conferenceChainBlockServerConstructor = 0x639a3db7
conferenceBroadcastCommitConstructor = 0xd1512ae7
conferenceBroadcastCommitServerConstructor = 0xd1512ae8
conferenceBroadcastRevealConstructor = 0x83f4f9d8
conferenceBroadcastRevealServerConstructor = 0x83f4f9d9
)
func (r *Router) onPhoneCreateConferenceCall(ctx context.Context, req *tg.PhoneCreateConferenceCallRequest) (tg.UpdatesClass, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
if r.deps.GroupCalls == nil {
return nil, notImplementedErr()
}
userID, err := r.phoneRequireUser(ctx)
if err != nil {
return nil, err
}
now := int(r.clock.Now().Unix())
call, err := r.deps.GroupCalls.CreateConference(ctx, userID, int64(req.RandomID), 0, now)
if err != nil {
return nil, groupCallErr(err)
}
out := r.groupCallUpdateContainer(ctx, userID, domain.Channel{},
&tg.UpdateGroupCall{Call: tgGroupCall(call, userID, true)}, []int64{userID})
if !req.Join {
return out, nil
}
params, ok := req.GetParams()
if !ok {
return nil, groupCallInvalidErr()
}
joinReq := &tg.PhoneJoinGroupCallRequest{
Muted: req.Muted,
VideoStopped: req.VideoStopped,
Call: &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash},
JoinAs: &tg.InputPeerSelf{},
Params: params,
}
if pk, ok := req.GetPublicKey(); ok {
joinReq.SetPublicKey(pk)
}
if block, ok := req.GetBlock(); ok {
joinReq.SetBlock(block)
}
joinUpdates, err := r.onPhoneJoinGroupCall(ctx, joinReq)
if err != nil {
return nil, err
}
appendUpdates(out, joinUpdates)
return out, nil
}
func (r *Router) onPhoneExportGroupCallInvite(ctx context.Context, req *tg.PhoneExportGroupCallInviteRequest) (*tg.PhoneExportedGroupCallInvite, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
scope, err := r.groupCallScopeFrom(ctx, req.Call)
if err != nil {
return nil, err
}
if !scope.call.Active() {
return nil, groupCallInvalidErr()
}
if scope.call.Conference() {
link := conferenceExportInviteLink(scope.call)
if link == "" {
return nil, groupCallInvalidErr()
}
return &tg.PhoneExportedGroupCallInvite{Link: link}, nil
}
if scope.call.InviteLink != "" {
return &tg.PhoneExportedGroupCallInvite{Link: scope.call.InviteLink}, nil
}
if scope.channel.Username == "" {
return nil, publicChannelMissingErr()
}
return &tg.PhoneExportedGroupCallInvite{Link: "https://telesrv.net/" + scope.channel.Username}, nil
}
func conferenceExportInviteLink(call domain.GroupCall) string {
if link := conferenceCanonicalInviteLink(call.InviteSlug); link != "" {
return link
}
return call.InviteLink
}
func conferenceCanonicalInviteLink(slug string) string {
if slug == "" {
return ""
}
return "https://telesrv.net/call/" + slug + "?slug=" + slug
}
func (r *Router) onPhoneInviteConferenceCallParticipant(ctx context.Context, req *tg.PhoneInviteConferenceCallParticipantRequest) (tg.UpdatesClass, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
if r.deps.GroupCalls == nil || r.deps.Messages == nil {
return nil, notImplementedErr()
}
scope, err := r.groupCallScopeFrom(ctx, req.Call)
if err != nil {
return nil, err
}
if !scope.call.Conference() || !scope.call.Active() {
return nil, groupCallInvalidErr()
}
target, found, err := r.userFromInput(ctx, scope.userID, req.UserID)
if err != nil {
return nil, internalErr()
}
if !found || target.ID == 0 || target.Bot || target.ID == scope.userID {
return nil, userIDInvalidErr()
}
if p, found, err := r.deps.GroupCalls.Participant(ctx, scope.call.ID, target.ID); err != nil {
return nil, internalErr()
} else if found && !p.Left {
return nil, tgerr400("USER_ALREADY_PARTICIPANT")
}
recipientBlocked, err := r.peerBlocksUser(ctx, scope.userID, target.ID)
if err != nil {
return nil, err
}
now := int(r.clock.Now().Unix())
res, err := r.deps.Messages.SendPrivateText(ctx, scope.userID, domain.SendPrivateTextRequest{
SenderUserID: scope.userID,
RecipientUserID: target.ID,
RandomID: conferenceInviteRandomID(scope.call.ID, target.ID, now),
Media: &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionConferenceCall,
ConferenceCall: &domain.MessageConferenceCallAction{
CallID: scope.call.ID,
Video: req.Video,
OtherParticipants: []domain.Peer{
{Type: domain.PeerTypeUser, ID: scope.userID},
},
},
},
},
Date: now,
OriginAuthKeyID: authKeyIDFromCtx(ctx),
OriginSessionID: sessionIDFromCtx(ctx),
RecipientBlocked: recipientBlocked,
})
if err != nil {
return nil, messageSendErr(err)
}
invite, err := r.deps.GroupCalls.CreateConferenceInvite(ctx, domain.GroupCallInvite{
CallID: scope.call.ID,
InviterUserID: scope.userID,
InviteeUserID: target.ID,
MessageID: res.RecipientMessage.ID,
Status: domain.GroupCallInvitePending,
Video: req.Video,
CreatedAt: now,
})
if err != nil {
return nil, groupCallErr(err)
}
_ = invite
users := r.tgUsersForIDs(ctx, scope.userID, []int64{scope.userID, target.ID})
out := tgPrivateMessageUpdates(res.SenderEvent, res.SenderMessage, 0, false, users, nil)
recipientUsers := r.tgUsersForIDs(ctx, target.ID, []int64{scope.userID, target.ID})
r.pushUserMessage(ctx, target.ID, "conference invite",
tgPrivateMessageUpdates(res.RecipientEvent, res.RecipientMessage, 0, false, recipientUsers, nil))
return out, nil
}
func (r *Router) onPhoneDeclineConferenceCallInvite(ctx context.Context, msgID int) (tg.UpdatesClass, error) {
if r.deps.GroupCalls == nil {
return nil, notImplementedErr()
}
userID, err := r.phoneRequireUser(ctx)
if err != nil {
return nil, err
}
call, inv, found, err := r.deps.GroupCalls.GetByInviteMessage(ctx, userID, msgID)
if err != nil {
return nil, internalErr()
}
if !found || !call.Conference() {
return nil, msgIDInvalidErr()
}
now := int(r.clock.Now().Unix())
if _, _, err := r.deps.GroupCalls.SetConferenceInviteStatus(ctx, call.ID, userID, msgID, domain.GroupCallInviteDeclined, now); err != nil {
return nil, internalErr()
}
r.pushConferenceGroupCallUpdate(ctx, call)
return r.groupCallUpdateContainer(ctx, userID, domain.Channel{},
&tg.UpdateGroupCall{Call: tgGroupCall(call, userID, userID == call.CreatorUserID)}, []int64{inv.InviterUserID, inv.InviteeUserID}), nil
}
func (r *Router) onPhoneDeleteConferenceCallParticipants(ctx context.Context, req *tg.PhoneDeleteConferenceCallParticipantsRequest) (tg.UpdatesClass, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
if len(req.IDs) == 0 || len(req.IDs) > 100 {
return nil, limitInvalidErr()
}
if len(req.Block) > maxConferenceChainBlockBytes {
return nil, limitInvalidErr()
}
scope, err := r.groupCallScopeFrom(ctx, req.Call)
if err != nil {
return nil, err
}
if !scope.call.Conference() {
return nil, groupCallInvalidErr()
}
if req.OnlyLeft == req.Kick {
return nil, inputRequestInvalidErr()
}
now := int(r.clock.Now().Unix())
if req.OnlyLeft && !scope.canManage() {
self, found, err := r.deps.GroupCalls.Participant(ctx, scope.call.ID, scope.userID)
if err != nil {
return nil, internalErr()
}
if !found || self.Left {
return nil, groupCallForbiddenErr()
}
}
for _, targetID := range req.IDs {
if targetID <= 0 {
return nil, userIDInvalidErr()
}
if req.Kick && targetID != scope.userID && !scope.canManage() {
return nil, groupCallForbiddenErr()
}
}
result, err := r.deps.GroupCalls.RemoveConferenceParticipants(ctx, domain.RemoveConferenceCallParticipantsRequest{
CallID: scope.call.ID,
AuthorUserID: scope.userID,
TargetUserIDs: req.IDs,
OnlyLeft: req.OnlyLeft,
Kick: req.Kick,
Block: req.Block,
Now: now,
})
if err != nil {
return nil, groupCallErr(err)
}
if result.ChainBlockAppended {
block := result.ChainBlock
r.pushConferenceChainBlocks(ctx, result.Call, block.SubChainID, [][]byte{block.Block}, block.Offset+1)
}
if len(result.ParticipantsChanged) > 0 {
r.pushConferenceGroupCallParticipantsUpdate(ctx, result.Call, result.ParticipantsChanged)
r.pushConferenceGroupCallUpdate(ctx, result.Call)
}
for _, p := range result.ParticipantsChanged {
if r.deps.SFU != nil {
_ = r.deps.SFU.Leave(ctx, scope.call.ID, p.UserID, sfu.EndpointMain)
}
}
out := r.groupCallUpdateContainer(ctx, scope.userID, domain.Channel{},
&tg.UpdateGroupCallParticipants{
Call: &tg.InputGroupCall{ID: result.Call.ID, AccessHash: result.Call.AccessHash},
Participants: tgGroupCallParticipants(result.ParticipantsChanged, scope.userID),
Version: result.Call.Version,
}, req.IDs)
if result.ChainBlockAppended {
block := result.ChainBlock
out.Updates = append(out.Updates, conferenceChainBlocksUpdate(result.Call, block.SubChainID, [][]byte{block.Block}, block.Offset+1))
}
return out, nil
}
func (r *Router) onPhoneSendConferenceCallBroadcast(ctx context.Context, req *tg.PhoneSendConferenceCallBroadcastRequest) (tg.UpdatesClass, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
if len(req.Block) == 0 || len(req.Block) > maxConferenceChainBlockBytes {
return nil, limitInvalidErr()
}
scope, err := r.groupCallScopeFrom(ctx, req.Call)
if err != nil {
return nil, err
}
if !scope.call.Conference() {
return nil, groupCallInvalidErr()
}
if err := r.requireActiveConferenceParticipant(ctx, scope.call.ID, scope.userID); err != nil {
return nil, err
}
now := int(r.clock.Now().Unix())
block, err := r.deps.GroupCalls.AppendChainBlock(ctx, domain.GroupCallChainBlock{
CallID: scope.call.ID,
SubChainID: 1,
Offset: -1,
AuthorUserID: scope.userID,
Block: req.Block,
CreatedAt: now,
})
if err != nil {
return nil, groupCallErr(err)
}
nextOffset := block.Offset + 1
r.pushConferenceChainBlocks(ctx, scope.call, block.SubChainID, [][]byte{block.Block}, nextOffset)
return r.conferenceChainBlocksUpdates(ctx, scope.userID, scope.call, block.SubChainID, [][]byte{block.Block}, nextOffset), nil
}
func (r *Router) requireActiveConferenceParticipant(ctx context.Context, callID, userID int64) error {
if r.deps.GroupCalls == nil {
return notImplementedErr()
}
p, found, err := r.deps.GroupCalls.Participant(ctx, callID, userID)
if err != nil {
return internalErr()
}
if !found || p.Left {
return groupCallJoinMissingErr()
}
return nil
}
func (r *Router) onPhoneGetGroupCallChainBlocks(ctx context.Context, req *tg.PhoneGetGroupCallChainBlocksRequest) (tg.UpdatesClass, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
if req.Offset < domain.GroupCallChainBlockLatestOffset {
return nil, inputRequestInvalidErr()
}
limit := req.Limit
if limit <= 0 || limit > maxConferenceChainBlocks {
limit = maxConferenceChainBlocks
}
scope, err := r.groupCallScopeFrom(ctx, req.Call)
if err != nil {
return nil, err
}
if !scope.call.Conference() {
return nil, groupCallInvalidErr()
}
page, err := r.deps.GroupCalls.ChainBlocks(ctx, scope.call.ID, req.SubChainID, req.Offset, limit)
if err != nil {
return nil, groupCallErr(err)
}
blocks := make([][]byte, 0, len(page.Blocks))
for _, row := range page.Blocks {
blocks = append(blocks, row.Block)
}
return r.conferenceChainBlocksUpdates(ctx, scope.userID, scope.call, req.SubChainID, blocks, page.NextOffset), nil
}
func (r *Router) pushConferenceChainBlocks(ctx context.Context, call domain.GroupCall, subChainID int, blocks [][]byte, nextOffset int) {
recipients := r.conferenceCallRecipients(ctx, call.ID)
for _, viewerID := range recipients {
r.pushUserMessage(ctx, viewerID, "conference chain blocks",
r.conferenceChainBlocksUpdates(ctx, viewerID, call, subChainID, blocks, nextOffset))
}
}
func (r *Router) conferenceChainBlocksUpdates(ctx context.Context, viewerID int64, call domain.GroupCall, subChainID int, blocks [][]byte, nextOffset int) *tg.Updates {
return &tg.Updates{
Updates: []tg.UpdateClass{conferenceChainBlocksUpdate(call, subChainID, blocks, nextOffset)},
Users: r.tgUsersForIDs(ctx, viewerID, []int64{viewerID}),
Date: int(r.clock.Now().Unix()),
Seq: 0,
}
}
func conferenceChainBlocksUpdate(call domain.GroupCall, subChainID int, blocks [][]byte, nextOffset int) *tg.UpdateGroupCallChainBlocks {
return &tg.UpdateGroupCallChainBlocks{
Call: &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash},
SubChainID: subChainID,
Blocks: conferenceServerBlocks(subChainID, blocks),
NextOffset: nextOffset,
}
}
func conferenceServerBlocks(subChainID int, blocks [][]byte) [][]byte {
out := make([][]byte, 0, len(blocks))
for _, block := range blocks {
out = append(out, conferenceServerBlock(subChainID, block))
}
return out
}
func conferenceServerBlock(subChainID int, block []byte) []byte {
out := append([]byte(nil), block...)
if len(out) < 4 {
return out
}
constructor := binary.LittleEndian.Uint32(out[:4])
switch subChainID {
case 0:
if constructor == conferenceChainBlockConstructor {
binary.LittleEndian.PutUint32(out[:4], conferenceChainBlockServerConstructor)
}
case 1:
switch constructor {
case conferenceBroadcastCommitConstructor:
binary.LittleEndian.PutUint32(out[:4], conferenceBroadcastCommitServerConstructor)
case conferenceBroadcastRevealConstructor:
binary.LittleEndian.PutUint32(out[:4], conferenceBroadcastRevealServerConstructor)
}
}
return out
}
func appendUpdates(dst *tg.Updates, src tg.UpdatesClass) {
if dst == nil || src == nil {
return
}
switch v := src.(type) {
case *tg.Updates:
dst.Updates = append(dst.Updates, v.Updates...)
dst.Users = append(dst.Users, v.Users...)
dst.Chats = append(dst.Chats, v.Chats...)
case *tg.UpdatesCombined:
dst.Updates = append(dst.Updates, v.Updates...)
dst.Users = append(dst.Users, v.Users...)
dst.Chats = append(dst.Chats, v.Chats...)
}
}
func conferenceInviteRandomID(callID, targetID int64, date int) int64 {
id := int64(0x636f6e6663616c) // "confcal"
id ^= callID << 11
id ^= targetID << 3
id ^= int64(date) << 29
if id == 0 {
return 0x636f6e66
}
return id
}
func authKeyIDFromCtx(ctx context.Context) [8]byte {
if authKeyID, ok := RawAuthKeyIDFrom(ctx); ok {
return authKeyID
}
return [8]byte{}
}
func sessionIDFromCtx(ctx context.Context) int64 {
if sessionID, ok := SessionIDFrom(ctx); ok {
return sessionID
}
return 0
}
func (r *Router) logConferenceMessageFailure(callID int64, err error) {
if err != nil {
r.log.Warn("conference message", zap.Int64("call_id", callID), zap.Error(err))
}
}

View file

@ -0,0 +1,890 @@
package rpc
import (
"context"
"encoding/binary"
"net/url"
"strings"
"testing"
"time"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"go.uber.org/zap/zaptest"
appgroupcalls "telesrv/internal/app/groupcalls"
appmessages "telesrv/internal/app/messages"
appphone "telesrv/internal/app/phone"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
type conferenceFixture struct {
ctx context.Context
router *Router
group *appgroupcalls.Service
messages *appmessages.Service
sessions *groupCallSessions
alice domain.User
bob domain.User
carol domain.User
clock *phoneTestClock
}
func newConferenceFixture(t *testing.T) *conferenceFixture {
t.Helper()
ctx := context.Background()
users := memory.NewUserStore()
dialogs := memory.NewDialogStore()
messageStore := memory.NewMessageStore(dialogs)
groupStore := memory.NewGroupCallStore()
sessions := &groupCallSessions{}
clk := &phoneTestClock{now: time.Unix(1_700_000_000, 0)}
groupSvc := appgroupcalls.NewService(groupStore)
messageSvc := appmessages.NewService(messageStore, dialogs)
router := New(Config{GroupCallMaxParticipants: 8}, Deps{
Users: appusers.NewService(users),
Messages: messageSvc,
GroupCalls: groupSvc,
Phone: appphone.NewService(appphone.Config{}, appphone.WithClock(clk)),
Sessions: sessions,
}, zaptest.NewLogger(t), clk)
mk := func(hash int64, phone, name string) domain.User {
u, err := users.Create(ctx, domain.User{AccessHash: hash, Phone: phone, FirstName: name})
if err != nil {
t.Fatalf("create user %s: %v", name, err)
}
return u
}
f := &conferenceFixture{ctx: ctx, router: router, group: groupSvc, messages: messageSvc, sessions: sessions, clock: clk}
f.alice = mk(3001, "13700000001", "Alice")
f.bob = mk(3002, "13700000002", "Bob")
f.carol = mk(3003, "13700000003", "Carol")
f.sessions.online = []int64{f.alice.ID, f.bob.ID, f.carol.ID}
return f
}
func (f *conferenceFixture) userCtx(u domain.User, session int64) context.Context {
return WithSessionID(WithUserID(f.ctx, u.ID), session)
}
func joinConferenceForTest(t *testing.T, f *conferenceFixture, ctx context.Context, call tg.InputGroupCallClass, blockSuffix string, ssrc int32) {
t.Helper()
block := conferenceTestBlock(conferenceChainBlockConstructor, blockSuffix)
req := &tg.PhoneJoinGroupCallRequest{
Call: call,
JoinAs: &tg.InputPeerSelf{},
Params: groupCallJoinParams(t, ssrc),
}
req.SetBlock(block)
if _, err := f.router.onPhoneJoinGroupCall(ctx, req); err != nil {
t.Fatalf("join conference %s: %v", blockSuffix, err)
}
}
func optionalUpdate[T tg.UpdateClass](updates tg.UpdatesClass) (T, bool) {
box, ok := updates.(*tg.Updates)
if !ok {
var zero T
return zero, false
}
for _, u := range box.Updates {
if v, ok := u.(T); ok {
return v, true
}
}
var zero T
return zero, false
}
func pushedDiscardedGroupCallUsers(records []phonePushRecord, callID int64) map[int64]bool {
seen := map[int64]bool{}
for _, rec := range records {
box, ok := rec.msg.(*tg.Updates)
if !ok {
continue
}
for _, raw := range box.Updates {
update, ok := raw.(*tg.UpdateGroupCall)
if !ok {
continue
}
discarded, ok := update.Call.(*tg.GroupCallDiscarded)
if ok && discarded.ID == callID {
seen[rec.userID] = true
}
}
}
return seen
}
func TestConferenceCreateLinkAndGetBySlug(t *testing.T) {
f := newConferenceFixture(t)
ctx := f.userCtx(f.alice, 11)
res, err := f.router.onPhoneCreateConferenceCall(ctx, &tg.PhoneCreateConferenceCallRequest{RandomID: 42})
if err != nil {
t.Fatalf("create conference: %v", err)
}
update := findUpdate[*tg.UpdateGroupCall](t, res)
call, ok := update.Call.(*tg.GroupCall)
if !ok || !call.Conference || call.InviteLink == "" || !strings.Contains(call.InviteLink, "slug=") || !strings.HasPrefix(call.InviteLink, "https://telesrv.net/call/") {
t.Fatalf("created call = %#v", update.Call)
}
slug := conferenceSlugFromLink(t, call.InviteLink)
got, err := f.router.onPhoneGetGroupCall(ctx, &tg.PhoneGetGroupCallRequest{
Call: &tg.InputGroupCallSlug{Slug: slug},
Limit: 10,
})
if err != nil {
t.Fatalf("get by slug: %v", err)
}
if got.Call.(*tg.GroupCall).ID != call.ID {
t.Fatalf("get by slug id = %d, want %d", got.Call.(*tg.GroupCall).ID, call.ID)
}
again, err := f.router.onPhoneCreateConferenceCall(ctx, &tg.PhoneCreateConferenceCallRequest{RandomID: 42})
if err != nil {
t.Fatalf("repeat create conference: %v", err)
}
if findUpdate[*tg.UpdateGroupCall](t, again).Call.(*tg.GroupCall).ID != call.ID {
t.Fatalf("random_id must be idempotent")
}
}
func TestConferenceExportGroupCallInviteReturnsConferenceLink(t *testing.T) {
f := newConferenceFixture(t)
ctx := f.userCtx(f.alice, 11)
create, err := f.router.onPhoneCreateConferenceCall(ctx, &tg.PhoneCreateConferenceCallRequest{RandomID: 43})
if err != nil {
t.Fatalf("create conference: %v", err)
}
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
exported, err := f.router.onPhoneExportGroupCallInvite(ctx, &tg.PhoneExportGroupCallInviteRequest{
Call: &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash},
})
if err != nil {
t.Fatalf("export invite: %v", err)
}
if exported.Link != call.InviteLink || !strings.Contains(exported.Link, "slug=") {
t.Fatalf("exported link = %q, want conference invite link %q", exported.Link, call.InviteLink)
}
}
func TestConferenceExportGroupCallInviteReturnsPathSlug(t *testing.T) {
f := newConferenceFixture(t)
ctx := WithClientInfo(f.userCtx(f.alice, 11), ClientInfo{Type: ClientTypeAndroid, AppVersion: "12.8.1"})
create, err := f.router.onPhoneCreateConferenceCall(ctx, &tg.PhoneCreateConferenceCallRequest{RandomID: 44})
if err != nil {
t.Fatalf("create conference: %v", err)
}
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
slug := conferenceSlugFromLink(t, call.InviteLink)
exported, err := f.router.onPhoneExportGroupCallInvite(ctx, &tg.PhoneExportGroupCallInviteRequest{
Call: &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash},
})
if err != nil {
t.Fatalf("export invite: %v", err)
}
if exported.Link != call.InviteLink {
t.Fatalf("exported link = %q, want stored canonical invite link %q", exported.Link, call.InviteLink)
}
if got := lastPathSegmentFromLink(t, exported.Link); got != slug {
t.Fatalf("export path slug = %q, want %q (link %q)", got, slug, exported.Link)
}
if got := conferenceSlugFromLink(t, exported.Link); got != slug {
t.Fatalf("export query slug = %q, want %q (link %q)", got, slug, exported.Link)
}
}
func TestConferenceLinksNormalizeLegacyTMeLink(t *testing.T) {
const slug = "legacy_slug-1"
legacy := domain.GroupCall{
ID: 1,
AccessHash: 2,
Kind: domain.GroupCallKindConference,
State: domain.GroupCallStateActive,
Version: 1,
InviteSlug: slug,
InviteLink: "https://t.me/call?slug=" + slug,
}
want := "https://telesrv.net/call/" + slug + "?slug=" + slug
if got := conferenceExportInviteLink(legacy); got != want {
t.Fatalf("export link = %q, want %q", got, want)
}
call := tgGroupCall(legacy, 0, false).(*tg.GroupCall)
if call.InviteLink != want {
t.Fatalf("tg group call invite link = %q, want %q", call.InviteLink, want)
}
}
func TestConferenceJoinBroadcastAndGetChainBlocks(t *testing.T) {
f := newConferenceFixture(t)
ctx := f.userCtx(f.alice, 11)
create, err := f.router.onPhoneCreateConferenceCall(ctx, &tg.PhoneCreateConferenceCallRequest{RandomID: 77})
if err != nil {
t.Fatalf("create conference: %v", err)
}
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
join, err := f.router.onPhoneJoinGroupCall(ctx, &tg.PhoneJoinGroupCallRequest{
Call: input,
JoinAs: &tg.InputPeerSelf{},
Params: groupCallJoinParams(t, 9001),
})
if err != nil {
t.Fatalf("join conference: %v", err)
}
findUpdate[*tg.UpdateGroupCallConnection](t, join)
participants := findUpdate[*tg.UpdateGroupCallParticipants](t, join)
if len(participants.Participants) != 1 || !participants.Participants[0].Self {
t.Fatalf("participants update = %+v", participants.Participants)
}
block := conferenceTestBlock(conferenceBroadcastCommitConstructor, "opaque-chain-block")
broadcast, err := f.router.onPhoneSendConferenceCallBroadcast(ctx, &tg.PhoneSendConferenceCallBroadcastRequest{
Call: input,
Block: block,
})
if err != nil {
t.Fatalf("broadcast: %v", err)
}
chain := findUpdate[*tg.UpdateGroupCallChainBlocks](t, broadcast)
if chain.SubChainID != 1 || chain.NextOffset != 1 || len(chain.Blocks) != 1 || conferenceTestConstructor(chain.Blocks[0]) != conferenceBroadcastCommitServerConstructor {
t.Fatalf("chain update = %+v", chain)
}
list, err := f.router.onPhoneGetGroupCallChainBlocks(ctx, &tg.PhoneGetGroupCallChainBlocksRequest{
Call: input, SubChainID: 1, Offset: 0, Limit: 10,
})
if err != nil {
t.Fatalf("get chain blocks: %v", err)
}
got := findUpdate[*tg.UpdateGroupCallChainBlocks](t, list)
if got.SubChainID != 1 || len(got.Blocks) != 1 || conferenceTestConstructor(got.Blocks[0]) != conferenceBroadcastCommitServerConstructor {
t.Fatalf("get chain blocks = %+v", got)
}
nextBlock := conferenceTestBlock(conferenceBroadcastRevealConstructor, "opaque-chain-block-2")
nextBroadcast, err := f.router.onPhoneSendConferenceCallBroadcast(ctx, &tg.PhoneSendConferenceCallBroadcastRequest{
Call: input,
Block: nextBlock,
})
if err != nil {
t.Fatalf("second broadcast: %v", err)
}
nextChain := findUpdate[*tg.UpdateGroupCallChainBlocks](t, nextBroadcast)
if nextChain.SubChainID != 1 || nextChain.NextOffset != 2 || len(nextChain.Blocks) != 1 || conferenceTestConstructor(nextChain.Blocks[0]) != conferenceBroadcastRevealServerConstructor {
t.Fatalf("second chain update = %+v", nextChain)
}
latest, err := f.router.onPhoneGetGroupCallChainBlocks(ctx, &tg.PhoneGetGroupCallChainBlocksRequest{
Call: input, SubChainID: 1, Offset: domain.GroupCallChainBlockLatestOffset, Limit: 1,
})
if err != nil {
t.Fatalf("get latest chain block: %v", err)
}
gotLatest := findUpdate[*tg.UpdateGroupCallChainBlocks](t, latest)
if gotLatest.SubChainID != 1 || gotLatest.NextOffset != 2 || len(gotLatest.Blocks) != 1 || conferenceTestConstructor(gotLatest.Blocks[0]) != conferenceBroadcastRevealServerConstructor {
t.Fatalf("latest chain block = %+v", gotLatest)
}
}
func TestConferenceBroadcastRequiresActiveParticipant(t *testing.T) {
f := newConferenceFixture(t)
aliceCtx := f.userCtx(f.alice, 11)
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 770})
if err != nil {
t.Fatalf("create conference: %v", err)
}
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
f.sessions.reset()
block := conferenceTestBlock(conferenceBroadcastCommitConstructor, "creator-not-joined-broadcast")
res, err := f.router.onPhoneSendConferenceCallBroadcast(aliceCtx, &tg.PhoneSendConferenceCallBroadcastRequest{
Call: input,
Block: block,
})
if res != nil || !tgerr.Is(err, "GROUPCALL_JOIN_MISSING") {
t.Fatalf("broadcast before join = %+v err=%v, want GROUPCALL_JOIN_MISSING", res, err)
}
if got := f.sessions.records(); len(got) != 0 {
t.Fatalf("broadcast before join must not push updates, got %+v", got)
}
list, err := f.router.onPhoneGetGroupCallChainBlocks(aliceCtx, &tg.PhoneGetGroupCallChainBlocksRequest{
Call: input, SubChainID: 1, Offset: 0, Limit: 10,
})
if err != nil {
t.Fatalf("get chain blocks: %v", err)
}
chain := findUpdate[*tg.UpdateGroupCallChainBlocks](t, list)
if chain.NextOffset != 0 || len(chain.Blocks) != 0 {
t.Fatalf("chain after forbidden broadcast = %+v, want empty", chain)
}
}
func TestConferenceJoinBlockSeedsChainAndDuplicateJoinIsRejected(t *testing.T) {
f := newConferenceFixture(t)
aliceCtx := f.userCtx(f.alice, 11)
bobCtx := f.userCtx(f.bob, 22)
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 78})
if err != nil {
t.Fatalf("create conference: %v", err)
}
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
joinBlock := conferenceTestBlock(conferenceChainBlockConstructor, "join-chain-block")
joinReq := &tg.PhoneJoinGroupCallRequest{
Call: input,
JoinAs: &tg.InputPeerSelf{},
Params: groupCallJoinParams(t, 9002),
}
joinReq.SetBlock(joinBlock)
join, err := f.router.onPhoneJoinGroupCall(aliceCtx, joinReq)
if err != nil {
t.Fatalf("join conference: %v", err)
}
findUpdate[*tg.UpdateGroupCallConnection](t, join)
joinChain := findUpdate[*tg.UpdateGroupCallChainBlocks](t, join)
if joinChain.SubChainID != 0 || joinChain.NextOffset != 1 || len(joinChain.Blocks) != 1 || conferenceTestConstructor(joinChain.Blocks[0]) != conferenceChainBlockServerConstructor {
t.Fatalf("join chain update = %+v", joinChain)
}
dupJoinReq := &tg.PhoneJoinGroupCallRequest{
Call: &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)},
JoinAs: &tg.InputPeerSelf{},
Params: groupCallJoinParams(t, 9003),
}
dupJoinReq.SetBlock(append([]byte(nil), joinBlock...))
dupJoin, err := f.router.onPhoneJoinGroupCall(bobCtx, dupJoinReq)
if dupJoin != nil || !tgerr.Is(err, "CONF_WRITE_CHAIN_INVALID") {
t.Fatalf("duplicate join = %+v err=%v, want CONF_WRITE_CHAIN_INVALID", dupJoin, err)
}
list, err := f.router.onPhoneGetGroupCallChainBlocks(aliceCtx, &tg.PhoneGetGroupCallChainBlocksRequest{
Call: input, Offset: 0, Limit: 10,
})
if err != nil {
t.Fatalf("get chain blocks: %v", err)
}
got := findUpdate[*tg.UpdateGroupCallChainBlocks](t, list)
if got.NextOffset != 1 || len(got.Blocks) != 1 || conferenceTestConstructor(got.Blocks[0]) != conferenceChainBlockServerConstructor {
t.Fatalf("get chain blocks after duplicate = %+v", got)
}
}
func TestConferenceJoinReturnsSubmittedBlockOnly(t *testing.T) {
f := newConferenceFixture(t)
aliceCtx := f.userCtx(f.alice, 11)
bobCtx := f.userCtx(f.bob, 22)
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 790})
if err != nil {
t.Fatalf("create conference: %v", err)
}
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
aliceBlock := conferenceTestBlock(conferenceChainBlockConstructor, "alice-join-chain-block")
aliceJoin := &tg.PhoneJoinGroupCallRequest{
Call: input,
JoinAs: &tg.InputPeerSelf{},
Params: groupCallJoinParams(t, 9031),
}
aliceJoin.SetBlock(aliceBlock)
if _, err := f.router.onPhoneJoinGroupCall(aliceCtx, aliceJoin); err != nil {
t.Fatalf("alice join: %v", err)
}
bobBlock := conferenceTestBlock(conferenceChainBlockConstructor, "bob-join-chain-block")
bobJoin := &tg.PhoneJoinGroupCallRequest{
Call: &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)},
JoinAs: &tg.InputPeerSelf{},
Params: groupCallJoinParams(t, 9032),
}
bobJoin.SetBlock(bobBlock)
join, err := f.router.onPhoneJoinGroupCall(bobCtx, bobJoin)
if err != nil {
t.Fatalf("bob join: %v", err)
}
joinChain := findUpdate[*tg.UpdateGroupCallChainBlocks](t, join)
if joinChain.SubChainID != 0 || joinChain.NextOffset != 2 || len(joinChain.Blocks) != 1 {
t.Fatalf("bob join chain update = %+v, want only submitted block at next_offset=2", joinChain)
}
if conferenceTestConstructor(joinChain.Blocks[0]) != conferenceChainBlockServerConstructor || string(joinChain.Blocks[0][4:]) != string(bobBlock[4:]) {
t.Fatalf("bob join block = %x, want server-form submitted block %x", joinChain.Blocks[0], bobBlock)
}
list, err := f.router.onPhoneGetGroupCallChainBlocks(aliceCtx, &tg.PhoneGetGroupCallChainBlocksRequest{
Call: input, Offset: 0, Limit: 10,
})
if err != nil {
t.Fatalf("get chain blocks: %v", err)
}
got := findUpdate[*tg.UpdateGroupCallChainBlocks](t, list)
if got.NextOffset != 2 || len(got.Blocks) != 2 {
t.Fatalf("persisted chain blocks = %+v, want full history through getGroupCallChainBlocks", got)
}
}
func TestConferenceDuplicateJoinBlockDoesNotLeaveParticipantActive(t *testing.T) {
f := newConferenceFixture(t)
aliceCtx := f.userCtx(f.alice, 11)
bobCtx := f.userCtx(f.bob, 22)
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 79})
if err != nil {
t.Fatalf("create conference: %v", err)
}
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
joinBlock := conferenceTestBlock(conferenceChainBlockConstructor, "alice-join-chain-block")
aliceJoin := &tg.PhoneJoinGroupCallRequest{
Call: input,
JoinAs: &tg.InputPeerSelf{},
Params: groupCallJoinParams(t, 9011),
}
aliceJoin.SetBlock(joinBlock)
if _, err := f.router.onPhoneJoinGroupCall(aliceCtx, aliceJoin); err != nil {
t.Fatalf("alice join: %v", err)
}
bobJoin := &tg.PhoneJoinGroupCallRequest{
Call: &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)},
JoinAs: &tg.InputPeerSelf{},
Params: groupCallJoinParams(t, 9022),
}
bobJoin.SetBlock(append([]byte(nil), joinBlock...))
if res, err := f.router.onPhoneJoinGroupCall(bobCtx, bobJoin); res != nil || !tgerr.Is(err, "CONF_WRITE_CHAIN_INVALID") {
t.Fatalf("bob duplicate join = %+v err=%v, want CONF_WRITE_CHAIN_INVALID", res, err)
}
if p, found, err := f.group.Participant(f.ctx, call.ID, f.bob.ID); err != nil || (found && !p.Left) {
t.Fatalf("bob participant after duplicate join = %+v found=%v err=%v, want absent or left", p, found, err)
}
}
func TestConferenceDeleteParticipantsReturnsSubmittedBlock(t *testing.T) {
f := newConferenceFixture(t)
aliceCtx := f.userCtx(f.alice, 11)
bobCtx := f.userCtx(f.bob, 22)
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 791})
if err != nil {
t.Fatalf("create conference: %v", err)
}
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
aliceBlock := conferenceTestBlock(conferenceChainBlockConstructor, "alice-delete-seed-block")
aliceJoin := &tg.PhoneJoinGroupCallRequest{Call: input, JoinAs: &tg.InputPeerSelf{}, Params: groupCallJoinParams(t, 9041)}
aliceJoin.SetBlock(aliceBlock)
if _, err := f.router.onPhoneJoinGroupCall(aliceCtx, aliceJoin); err != nil {
t.Fatalf("alice join: %v", err)
}
bobBlock := conferenceTestBlock(conferenceChainBlockConstructor, "bob-delete-seed-block")
bobJoin := &tg.PhoneJoinGroupCallRequest{Call: &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, JoinAs: &tg.InputPeerSelf{}, Params: groupCallJoinParams(t, 9042)}
bobJoin.SetBlock(bobBlock)
if _, err := f.router.onPhoneJoinGroupCall(bobCtx, bobJoin); err != nil {
t.Fatalf("bob join: %v", err)
}
removeBlock := conferenceTestBlock(conferenceChainBlockConstructor, "alice-remove-bob-block")
res, err := f.router.onPhoneDeleteConferenceCallParticipants(aliceCtx, &tg.PhoneDeleteConferenceCallParticipantsRequest{
Call: input,
IDs: []int64{f.bob.ID},
Kick: true,
Block: removeBlock,
})
if err != nil {
t.Fatalf("delete conference participants: %v", err)
}
chain := findUpdate[*tg.UpdateGroupCallChainBlocks](t, res)
if chain.SubChainID != 0 || chain.NextOffset != 3 || len(chain.Blocks) != 1 || conferenceTestConstructor(chain.Blocks[0]) != conferenceChainBlockServerConstructor {
t.Fatalf("delete participants chain update = %+v", chain)
}
}
func TestConferenceOnlyLeftRemoveIsIdempotentAndAllowedForParticipant(t *testing.T) {
f := newConferenceFixture(t)
aliceCtx := f.userCtx(f.alice, 11)
bobCtx := f.userCtx(f.bob, 22)
carolCtx := f.userCtx(f.carol, 33)
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 792})
if err != nil {
t.Fatalf("create conference: %v", err)
}
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
joinConferenceForTest(t, f, aliceCtx, input, "alice-only-left-join", 9051)
joinConferenceForTest(t, f, bobCtx, &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, "bob-only-left-join", 9052)
joinConferenceForTest(t, f, carolCtx, &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, "carol-only-left-join", 9053)
if _, err := f.router.onPhoneLeaveGroupCall(carolCtx, &tg.PhoneLeaveGroupCallRequest{Call: input}); err != nil {
t.Fatalf("carol leave: %v", err)
}
aliceRemove := conferenceTestBlock(conferenceChainBlockConstructor, "alice-remove-left-carol")
first, err := f.router.onPhoneDeleteConferenceCallParticipants(aliceCtx, &tg.PhoneDeleteConferenceCallParticipantsRequest{
Call: input,
IDs: []int64{f.carol.ID},
OnlyLeft: true,
Block: aliceRemove,
})
if err != nil {
t.Fatalf("alice only_left remove: %v", err)
}
firstChain := findUpdate[*tg.UpdateGroupCallChainBlocks](t, first)
if firstChain.SubChainID != 0 || firstChain.NextOffset != 4 || len(firstChain.Blocks) != 1 {
t.Fatalf("first only_left chain update = %+v", firstChain)
}
bobRemove := conferenceTestBlock(conferenceChainBlockConstructor, "bob-stale-remove-left-carol")
second, err := f.router.onPhoneDeleteConferenceCallParticipants(bobCtx, &tg.PhoneDeleteConferenceCallParticipantsRequest{
Call: input,
IDs: []int64{f.carol.ID},
OnlyLeft: true,
Block: bobRemove,
})
if err != nil {
t.Fatalf("bob duplicate only_left remove: %v", err)
}
if chain, ok := optionalUpdate[*tg.UpdateGroupCallChainBlocks](second); ok {
t.Fatalf("duplicate only_left must not append stale chain block, got %+v", chain)
}
list, err := f.router.onPhoneGetGroupCallChainBlocks(aliceCtx, &tg.PhoneGetGroupCallChainBlocksRequest{
Call: input, Offset: 0, Limit: 10,
})
if err != nil {
t.Fatalf("get chain blocks: %v", err)
}
got := findUpdate[*tg.UpdateGroupCallChainBlocks](t, list)
if got.NextOffset != 4 || len(got.Blocks) != 4 {
t.Fatalf("chain after duplicate only_left = %+v, want exactly 3 joins + 1 remove", got)
}
}
func TestConferenceForbiddenKickDoesNotAppendChainBlock(t *testing.T) {
f := newConferenceFixture(t)
aliceCtx := f.userCtx(f.alice, 11)
bobCtx := f.userCtx(f.bob, 22)
carolCtx := f.userCtx(f.carol, 33)
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 793})
if err != nil {
t.Fatalf("create conference: %v", err)
}
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
joinConferenceForTest(t, f, aliceCtx, input, "alice-forbidden-kick-join", 9061)
joinConferenceForTest(t, f, bobCtx, &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, "bob-forbidden-kick-join", 9062)
joinConferenceForTest(t, f, carolCtx, &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, "carol-forbidden-kick-join", 9063)
staleBlock := conferenceTestBlock(conferenceChainBlockConstructor, "bob-forbidden-kick-carol")
res, err := f.router.onPhoneDeleteConferenceCallParticipants(bobCtx, &tg.PhoneDeleteConferenceCallParticipantsRequest{
Call: input,
IDs: []int64{f.carol.ID},
Kick: true,
Block: staleBlock,
})
if res != nil || !tgerr.Is(err, "GROUPCALL_FORBIDDEN") {
t.Fatalf("bob forbidden kick = %+v err=%v, want GROUPCALL_FORBIDDEN", res, err)
}
list, err := f.router.onPhoneGetGroupCallChainBlocks(aliceCtx, &tg.PhoneGetGroupCallChainBlocksRequest{
Call: input, Offset: 0, Limit: 10,
})
if err != nil {
t.Fatalf("get chain blocks: %v", err)
}
got := findUpdate[*tg.UpdateGroupCallChainBlocks](t, list)
if got.NextOffset != 3 || len(got.Blocks) != 3 {
t.Fatalf("chain after forbidden kick = %+v, want only join blocks", got)
}
}
func TestConferenceLastLeaveDiscardsCall(t *testing.T) {
f := newConferenceFixture(t)
aliceCtx := f.userCtx(f.alice, 11)
bobCtx := f.userCtx(f.bob, 22)
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 794})
if err != nil {
t.Fatalf("create conference: %v", err)
}
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
joinConferenceForTest(t, f, aliceCtx, input, "alice-last-leave-join", 9071)
joinConferenceForTest(t, f, bobCtx, &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, "bob-last-leave-join", 9072)
bobLeave, err := f.router.onPhoneLeaveGroupCall(bobCtx, &tg.PhoneLeaveGroupCallRequest{Call: input})
if err != nil {
t.Fatalf("bob leave: %v", err)
}
if update, ok := optionalUpdate[*tg.UpdateGroupCall](bobLeave); ok {
if _, discarded := update.Call.(*tg.GroupCallDiscarded); discarded {
t.Fatalf("first leave must keep conference active, got %+v", update.Call)
}
}
aliceLeave, err := f.router.onPhoneLeaveGroupCall(aliceCtx, &tg.PhoneLeaveGroupCallRequest{Call: input})
if err != nil {
t.Fatalf("alice last leave: %v", err)
}
discardUpdate := findUpdate[*tg.UpdateGroupCall](t, aliceLeave)
if discarded, ok := discardUpdate.Call.(*tg.GroupCallDiscarded); !ok || discarded.ID != call.ID {
t.Fatalf("last leave update = %+v, want groupCallDiscarded %d", discardUpdate.Call, call.ID)
}
_, err = f.router.onPhoneJoinGroupCall(bobCtx, &tg.PhoneJoinGroupCallRequest{
Call: input,
JoinAs: &tg.InputPeerSelf{},
Params: groupCallJoinParams(t, 9073),
})
if !tgerr.Is(err, "GROUPCALL_ALREADY_DISCARDED") {
t.Fatalf("join after last leave err = %v, want GROUPCALL_ALREADY_DISCARDED", err)
}
}
func TestConferenceDiscardRequiresCreator(t *testing.T) {
f := newConferenceFixture(t)
aliceCtx := f.userCtx(f.alice, 11)
bobCtx := f.userCtx(f.bob, 22)
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 794})
if err != nil {
t.Fatalf("create conference: %v", err)
}
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
joinConferenceForTest(t, f, aliceCtx, input, "alice-discard-permission-join", 9071)
joinConferenceForTest(t, f, bobCtx, &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, "bob-discard-permission-join", 9072)
f.sessions.reset()
res, err := f.router.onPhoneDiscardGroupCall(bobCtx, input)
if res != nil || !tgerr.Is(err, "CHAT_ADMIN_REQUIRED") {
t.Fatalf("bob discard = %+v err=%v, want CHAT_ADMIN_REQUIRED", res, err)
}
if got := f.sessions.records(); len(got) != 0 {
t.Fatalf("forbidden discard must not push updates, got %+v", got)
}
got, err := f.router.onPhoneGetGroupCall(aliceCtx, &tg.PhoneGetGroupCallRequest{Call: input, Limit: 10})
if err != nil {
t.Fatalf("get group call after forbidden discard: %v", err)
}
if _, ok := got.Call.(*tg.GroupCall); !ok {
t.Fatalf("call after forbidden discard = %T, want active GroupCall", got.Call)
}
}
func TestConferenceDiscardFanoutIncludesSlugJoinedParticipants(t *testing.T) {
f := newConferenceFixture(t)
aliceCtx := f.userCtx(f.alice, 11)
bobCtx := f.userCtx(f.bob, 22)
carolCtx := f.userCtx(f.carol, 33)
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 795})
if err != nil {
t.Fatalf("create conference: %v", err)
}
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
joinConferenceForTest(t, f, aliceCtx, input, "alice-discard-fanout-join", 9081)
joinConferenceForTest(t, f, bobCtx, &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, "bob-discard-fanout-join", 9082)
joinConferenceForTest(t, f, carolCtx, &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, "carol-discard-fanout-join", 9083)
f.sessions.reset()
discard, err := f.router.onPhoneDiscardGroupCall(aliceCtx, input)
if err != nil {
t.Fatalf("discard conference: %v", err)
}
if _, ok := findUpdate[*tg.UpdateGroupCall](t, discard).Call.(*tg.GroupCallDiscarded); !ok {
t.Fatalf("discard response missing groupCallDiscarded")
}
seen := pushedDiscardedGroupCallUsers(f.sessions.records(), call.ID)
for _, user := range []domain.User{f.alice, f.bob, f.carol} {
if !seen[user.ID] {
t.Fatalf("discard fanout missing user %d, seen=%v records=%+v", user.ID, seen, f.sessions.records())
}
}
}
func TestConferenceDiscardAllowsHistoricalParticipantCleanupRPCs(t *testing.T) {
f := newConferenceFixture(t)
aliceCtx := f.userCtx(f.alice, 11)
bobCtx := f.userCtx(f.bob, 22)
carolCtx := f.userCtx(f.carol, 33)
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 796})
if err != nil {
t.Fatalf("create conference: %v", err)
}
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
slugCall := &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}
joinConferenceForTest(t, f, aliceCtx, input, "alice-discard-cleanup-join", 9091)
joinConferenceForTest(t, f, bobCtx, slugCall, "bob-discard-cleanup-join", 9092)
joinConferenceForTest(t, f, carolCtx, slugCall, "carol-discard-cleanup-join", 9093)
if _, err := f.router.onPhoneDiscardGroupCall(aliceCtx, input); err != nil {
t.Fatalf("discard conference: %v", err)
}
got, err := f.router.onPhoneGetGroupCall(bobCtx, &tg.PhoneGetGroupCallRequest{Call: input, Limit: 10})
if err != nil {
t.Fatalf("bob get discarded group call: %v", err)
}
if _, ok := got.Call.(*tg.GroupCallDiscarded); !ok {
t.Fatalf("bob call after discard = %T, want GroupCallDiscarded", got.Call)
}
ssrcs, err := f.router.onPhoneCheckGroupCall(bobCtx, &tg.PhoneCheckGroupCallRequest{
Call: input,
Sources: []int{9092},
})
if err != nil || len(ssrcs) != 0 {
t.Fatalf("bob check discarded group call = %v err=%v, want empty no error", ssrcs, err)
}
chain, err := f.router.onPhoneGetGroupCallChainBlocks(bobCtx, &tg.PhoneGetGroupCallChainBlocksRequest{
Call: input,
SubChainID: 0,
Offset: 0,
Limit: 10,
})
if err != nil {
t.Fatalf("bob get chain blocks after discard: %v", err)
}
if blocks := findUpdate[*tg.UpdateGroupCallChainBlocks](t, chain).Blocks; len(blocks) != 3 {
t.Fatalf("bob cleanup chain blocks len=%d, want 3", len(blocks))
}
}
func TestConferenceInviteMessageResolvesInputGroupCallInviteMessage(t *testing.T) {
f := newConferenceFixture(t)
aliceCtx := f.userCtx(f.alice, 11)
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 88})
if err != nil {
t.Fatalf("create conference: %v", err)
}
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
invite, err := f.router.onPhoneInviteConferenceCallParticipant(aliceCtx, &tg.PhoneInviteConferenceCallParticipantRequest{
Call: input,
UserID: &tg.InputUser{UserID: f.bob.ID, AccessHash: f.bob.AccessHash},
})
if err != nil {
t.Fatalf("invite conference: %v", err)
}
msg := findUpdate[*tg.UpdateNewMessage](t, invite).Message.(*tg.MessageService)
if _, ok := msg.Action.(*tg.MessageActionConferenceCall); !ok {
t.Fatalf("invite action = %T", msg.Action)
}
history, err := f.messages.GetHistory(f.ctx, f.bob.ID, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: f.alice.ID},
Limit: 10,
})
if err != nil || len(history.Messages) != 1 {
t.Fatalf("bob history len=%d err=%v", len(history.Messages), err)
}
bobMsgID := history.Messages[0].ID
joinConferenceForTest(t, f, aliceCtx, input, "alice-invite-message-broadcast-join", 9091)
got, err := f.router.onPhoneGetGroupCall(f.userCtx(f.bob, 22), &tg.PhoneGetGroupCallRequest{
Call: &tg.InputGroupCallInviteMessage{MsgID: bobMsgID},
Limit: 10,
})
if err != nil {
t.Fatalf("get by invite message: %v", err)
}
if got.Call.(*tg.GroupCall).ID != call.ID {
t.Fatalf("invite message call id = %d, want %d", got.Call.(*tg.GroupCall).ID, call.ID)
}
block := conferenceTestBlock(conferenceBroadcastCommitConstructor, "invite-message-chain-block")
if _, err := f.router.onPhoneSendConferenceCallBroadcast(aliceCtx, &tg.PhoneSendConferenceCallBroadcastRequest{
Call: input,
Block: block,
}); err != nil {
t.Fatalf("broadcast invite message chain block: %v", err)
}
chain, err := f.router.onPhoneGetGroupCallChainBlocks(f.userCtx(f.bob, 22), &tg.PhoneGetGroupCallChainBlocksRequest{
Call: &tg.InputGroupCallInviteMessage{MsgID: bobMsgID},
SubChainID: 1,
Offset: domain.GroupCallChainBlockLatestOffset,
Limit: 1,
})
if err != nil {
t.Fatalf("get latest by invite message: %v", err)
}
latest := findUpdate[*tg.UpdateGroupCallChainBlocks](t, chain)
if latest.SubChainID != 1 || latest.NextOffset != 1 || len(latest.Blocks) != 1 || conferenceTestConstructor(latest.Blocks[0]) != conferenceBroadcastCommitServerConstructor {
t.Fatalf("invite message latest block = %+v", latest)
}
}
func TestPhoneDiscardMigrateConferenceCarriesSlug(t *testing.T) {
f := newConferenceFixture(t)
aliceCtx := f.userCtx(f.alice, 11)
bobCtx := f.userCtx(f.bob, 22)
ga, gaHash, gb := phoneTestKeys()
requested, err := f.router.onPhoneRequestCall(aliceCtx, &tg.PhoneRequestCallRequest{
UserID: &tg.InputUser{UserID: f.bob.ID, AccessHash: f.bob.AccessHash},
RandomID: 9,
GAHash: gaHash,
Protocol: phoneTestProtocol(),
})
if err != nil {
t.Fatalf("request call: %v", err)
}
peer := tg.InputPhoneCall{ID: requested.PhoneCall.(*tg.PhoneCallWaiting).ID, AccessHash: requested.PhoneCall.(*tg.PhoneCallWaiting).AccessHash}
if _, err := f.router.onPhoneAcceptCall(bobCtx, &tg.PhoneAcceptCallRequest{Peer: peer, GB: gb, Protocol: phoneTestProtocol()}); err != nil {
t.Fatalf("accept call: %v", err)
}
if _, err := f.router.onPhoneConfirmCall(aliceCtx, &tg.PhoneConfirmCallRequest{Peer: peer, GA: ga, KeyFingerprint: 123, Protocol: phoneTestProtocol()}); err != nil {
t.Fatalf("confirm call: %v", err)
}
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 99})
if err != nil {
t.Fatalf("create conference: %v", err)
}
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
slug := conferenceSlugFromLink(t, call.InviteLink)
updates, err := f.router.onPhoneDiscardCall(aliceCtx, &tg.PhoneDiscardCallRequest{
Peer: peer,
Duration: 1,
Reason: &tg.PhoneCallDiscardReasonMigrateConferenceCall{Slug: slug},
})
if err != nil {
t.Fatalf("discard migrate: %v", err)
}
discarded := findUpdate[*tg.UpdatePhoneCall](t, updates).PhoneCall.(*tg.PhoneCallDiscarded)
reason, ok := discarded.Reason.(*tg.PhoneCallDiscardReasonMigrateConferenceCall)
if !ok || reason.Slug != slug {
t.Fatalf("discard reason = %#v, want slug %q", discarded.Reason, slug)
}
}
var _ clock.Clock = (*phoneTestClock)(nil)
func conferenceSlugFromLink(t *testing.T, link string) string {
t.Helper()
u, err := url.Parse(link)
if err == nil {
if slug := u.Query().Get("slug"); slug != "" {
return slug
}
}
idx := strings.LastIndex(link, "slug=")
if idx < 0 {
t.Fatalf("link %q does not contain slug", link)
}
return link[idx+len("slug="):]
}
func conferenceTestBlock(constructor uint32, suffix string) []byte {
block := make([]byte, 4+len(suffix))
binary.LittleEndian.PutUint32(block[:4], constructor)
copy(block[4:], suffix)
return block
}
func conferenceTestConstructor(block []byte) uint32 {
if len(block) < 4 {
return 0
}
return binary.LittleEndian.Uint32(block[:4])
}
func lastPathSegmentFromLink(t *testing.T, link string) string {
t.Helper()
u, err := url.Parse(link)
if err != nil {
t.Fatalf("parse link %q: %v", link, err)
}
segments := strings.Split(strings.Trim(u.EscapedPath(), "/"), "/")
if len(segments) == 0 || segments[len(segments)-1] == "" {
t.Fatalf("link %q has no path segment", link)
}
segment, err := url.PathUnescape(segments[len(segments)-1])
if err != nil {
t.Fatalf("decode path segment in %q: %v", link, err)
}
return segment
}

View file

@ -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)
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 != accessHash {
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=falsejoin 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())}
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)}
if channel.ID != 0 {
update.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
}
return update
}

View file

@ -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()
}

View file

@ -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) {}

View file

@ -42,6 +42,10 @@ func (r *Router) groupCallUpdateContainer(ctx context.Context, viewerUserID int6
// pushGroupCallUpdate 把 updateGroupCallcall 行变化)推给在线群成员。
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
}

View file

@ -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 callP2P 通话升级/拉人路径)。
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)

View file

@ -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)

View file

@ -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 换新 ssrcssrc 与他人撞活跃唯一
// 约束返回 domain.ErrGroupCallSSRCDuplicateversion++。
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)
}

View file

@ -53,20 +53,7 @@ func (s *ChannelStore) CreateChannel(_ context.Context, req domain.CreateChannel
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,
},
AdminRights: domain.CreatorChannelAdminRights(),
}
s.channels[channelID] = channel
s.invites[inviteHash] = domain.ChannelInvite{

View file

@ -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,15 +120,11 @@ 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)
}
}
return out, nil
}

View file

@ -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 {

View file

@ -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
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
@ -33,7 +54,12 @@ func NewGroupCallStore() *GroupCallStore {
return &GroupCallStore{
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

View file

@ -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 `+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'
) AS viewer_member
FROM channels c
WHERE NOT c.deleted
AND (c.broadcast OR c.megagroup)
AND COALESCE(c.username, '') <> ''
)
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,20 +185,15 @@ LIMIT $5`, viewerUserID, queryLower, queryPrefix, queryLike, limit)
}
defer rows.Close()
out := domain.PublicChannelSearchResult{
MyResults: make([]domain.Channel, 0),
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)
}
}
if err := rows.Err(); err != nil {
return domain.PublicChannelSearchResult{}, err
}

View file

@ -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

View file

@ -356,20 +356,7 @@ func creatorChannelMember(channelID, userID int64, date int) domain.ChannelMembe
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,
},
AdminRights: domain.CreatorChannelAdminRights(),
}
}

View file

@ -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
}

View file

@ -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) {
}
// contractJoinVideoStatejoin 携带 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

View file

@ -0,0 +1,146 @@
<#
.SYNOPSIS
Summarizes upload.getFile requests in telesrv logs.
.DESCRIPTION
Use this after opening media history in TDesktop or Android. It groups
upload.getFile RPCs by client_type/app_version and reports request count and
duration percentiles. Pass -SinceLine from a previous baseline if desired.
#>
[CmdletBinding()]
param(
[string]$ServerLogPath,
[int]$SinceLine = 0,
[int]$Tail = 0,
[switch]$ShowSamples
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
if (-not $ServerLogPath) {
$latestLog = Get-ChildItem (Join-Path $RepoRoot "logs") -Filter "telesrv-*.err.log" -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if ($latestLog) {
$ServerLogPath = $latestLog.FullName
} else {
$ServerLogPath = Join-Path $RepoRoot "logs\telesrv.err.log"
}
}
function Read-SharedLogLines {
if (-not (Test-Path -LiteralPath $ServerLogPath)) {
return @()
}
$stream = [System.IO.File]::Open($ServerLogPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
try {
$reader = New-Object System.IO.StreamReader($stream)
try {
$lines = New-Object System.Collections.Generic.List[string]
while (-not $reader.EndOfStream) {
$lines.Add($reader.ReadLine()) | Out-Null
}
return $lines
} finally {
$reader.Dispose()
}
} finally {
$stream.Dispose()
}
}
function Get-Field {
param([string]$Line, [string]$Name, [string]$Default = "")
if ($Line -match ('"' + [regex]::Escape($Name) + '"\s*:\s*"([^"]*)"')) {
return $Matches[1]
}
if ($Line -match ('"' + [regex]::Escape($Name) + '"\s*:\s*([^,}]+)')) {
return $Matches[1]
}
return $Default
}
function Convert-DurationMs([string]$Text) {
if (-not $Text) { return 0.0 }
if ($Text -match '^([0-9.]+)ms$') { return [double]$Matches[1] }
if ($Text -match '^([0-9.]+)s$') { return [double]$Matches[1] * 1000.0 }
if ($Text -match '^([0-9.]+)µs$') { return [double]$Matches[1] / 1000.0 }
if ($Text -match '^([0-9.]+)us$') { return [double]$Matches[1] / 1000.0 }
if ($Text -match '^([0-9.]+)ns$') { return [double]$Matches[1] / 1000000.0 }
return 0.0
}
function Percentile {
param([double[]]$Values, [double]$P)
if ($Values.Count -eq 0) { return 0.0 }
$sorted = @($Values | Sort-Object)
$idx = [int][Math]::Ceiling($P * $sorted.Count) - 1
if ($idx -lt 0) { $idx = 0 }
if ($idx -ge $sorted.Count) { $idx = $sorted.Count - 1 }
return [double]$sorted[$idx]
}
$allLines = @(Read-SharedLogLines)
$lineCount = $allLines.Count
$lines = $allLines
if ($SinceLine -gt 0) {
$lines = @($lines | Select-Object -Skip $SinceLine)
}
if ($Tail -gt 0) {
$lines = @($lines | Select-Object -Last $Tail)
}
$items = New-Object System.Collections.Generic.List[object]
foreach ($line in $lines) {
if ($line -notlike "*upload.getFile*") {
continue
}
$method = Get-Field $line "method"
if ($method -notlike "upload.getFile*") {
continue
}
$client = Get-Field $line "client_type" "unknown"
$app = Get-Field $line "app_version" ""
$dur = Convert-DurationMs (Get-Field $line "dur" "0ms")
$items.Add([pscustomobject]@{
Client = $client
AppVersion = $app
DurationMs = $dur
Line = $line
}) | Out-Null
}
Write-Host "log=$ServerLogPath"
Write-Host "total_lines=$lineCount analyzed_lines=$($lines.Count) since_line=$SinceLine upload_get_file=$($items.Count)"
Write-Host ""
if ($items.Count -eq 0) {
Write-Host "No upload.getFile entries found."
exit 0
}
$groups = $items | Group-Object Client, AppVersion
foreach ($group in $groups) {
$values = @($group.Group | ForEach-Object { [double]$_.DurationMs })
$sum = 0.0
foreach ($v in $values) { $sum += $v }
$avg = $sum / [Math]::Max(1, $values.Count)
[pscustomobject]@{
Client = ($group.Group[0].Client)
AppVersion = ($group.Group[0].AppVersion)
Count = $values.Count
AvgMs = [Math]::Round($avg, 3)
P50Ms = [Math]::Round((Percentile $values 0.50), 3)
P95Ms = [Math]::Round((Percentile $values 0.95), 3)
P99Ms = [Math]::Round((Percentile $values 0.99), 3)
MaxMs = [Math]::Round((($values | Measure-Object -Maximum).Maximum), 3)
}
}
if ($ShowSamples) {
Write-Host ""
Write-Host "Samples:"
$items | Select-Object -Last 20 | ForEach-Object { Write-Host $_.Line }
}

View file

@ -0,0 +1,257 @@
<#
.SYNOPSIS
Checks the local telesrv runtime state.
.DESCRIPTION
Reports the listening PID/process, git commit, schema version, MTProto port,
Android connection/package status, and recent server log errors. The script is
read-only and is intended to run before/after Android and TDesktop validation.
#>
[CmdletBinding()]
param(
[int]$Port = 2398,
[string]$ServerLogPath,
[string]$AndroidPackage = "org.telegram.messenger.beta",
[string]$DeviceSerial,
[string]$PostgresContainer = "telesrv-postgres",
[string]$Database = "telesrv",
[string]$DbUser = "telesrv",
[int]$RecentLogLines = 1200,
[switch]$SkipAdb
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
if (-not $ServerLogPath) {
$latestLog = Get-ChildItem (Join-Path $RepoRoot "logs") -Filter "telesrv-*.err.log" -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if ($latestLog) {
$ServerLogPath = $latestLog.FullName
} else {
$ServerLogPath = Join-Path $RepoRoot "logs\telesrv.err.log"
}
}
$Failures = New-Object System.Collections.Generic.List[string]
function Write-Step([string]$Message) {
Write-Host ""
Write-Host "== $Message =="
}
function Add-Failure([string]$Message) {
$script:Failures.Add($Message) | Out-Null
Write-Host "[fail] $Message"
}
function Write-Ok([string]$Message) {
Write-Host "[ok] $Message"
}
function Invoke-External {
param(
[string]$FilePath,
[string[]]$Arguments,
[switch]$AllowFailure
)
$oldErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
$output = & $FilePath @Arguments 2>&1
$exitCode = $LASTEXITCODE
} finally {
$ErrorActionPreference = $oldErrorActionPreference
}
$text = ($output | ForEach-Object { $_.ToString() }) -join "`n"
if ($exitCode -ne 0 -and -not $AllowFailure) {
throw "$FilePath $($Arguments -join ' ') failed with exit code ${exitCode}:`n$text"
}
[pscustomobject]@{ ExitCode = $exitCode; Output = $text }
}
function Invoke-PsqlScalar([string]$Sql) {
$result = Invoke-External "docker" @(
"exec", $PostgresContainer,
"psql", "-U", $DbUser, "-d", $Database,
"-v", "ON_ERROR_STOP=1",
"-At", "-c", $Sql
) -AllowFailure
if ($result.ExitCode -ne 0) {
Add-Failure "PostgreSQL query failed: $($result.Output)"
return ""
}
return $result.Output.Trim()
}
function Read-SharedLogLines {
if (-not (Test-Path -LiteralPath $ServerLogPath)) {
return @()
}
$stream = [System.IO.File]::Open($ServerLogPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
try {
$reader = New-Object System.IO.StreamReader($stream)
try {
$lines = New-Object System.Collections.Generic.List[string]
while (-not $reader.EndOfStream) {
$lines.Add($reader.ReadLine()) | Out-Null
}
return $lines
} finally {
$reader.Dispose()
}
} finally {
$stream.Dispose()
}
}
function Get-AdbArgs([string[]]$Arguments) {
if ($DeviceSerial) {
return @("-s", $DeviceSerial) + $Arguments
}
return $Arguments
}
function Invoke-Adb([string[]]$Arguments, [switch]$AllowFailure) {
Invoke-External "adb" (Get-AdbArgs $Arguments) -AllowFailure:$AllowFailure
}
function Get-JsonFieldFromLog {
param([string[]]$Lines, [string]$Field)
for ($i = $Lines.Count - 1; $i -ge 0; $i--) {
if ($Lines[$i] -match ('"' + [regex]::Escape($Field) + '"\s*:\s*"?([^",}]+)"?')) {
return $Matches[1]
}
}
return ""
}
Write-Step "Git"
Push-Location $RepoRoot
try {
$head = (Invoke-External "git" @("rev-parse", "HEAD") -AllowFailure).Output.Trim()
$branch = (Invoke-External "git" @("branch", "--show-current") -AllowFailure).Output.Trim()
$dirty = (Invoke-External "git" @("status", "--porcelain", "--untracked-files=no") -AllowFailure).Output.Trim()
Write-Host "branch=$branch"
Write-Host "head=$head"
if ($dirty) {
Write-Host "tree_state=dirty"
} else {
Write-Host "tree_state=clean"
}
} finally {
Pop-Location
}
Write-Step "Process and Port"
$listeners = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue)
if ($listeners.Count -eq 0) {
Add-Failure "no process is listening on port $Port"
} else {
foreach ($ownerPid in @($listeners | Select-Object -ExpandProperty OwningProcess -Unique)) {
$proc = Get-Process -Id $ownerPid -ErrorAction SilentlyContinue
if ($proc) {
$path = $null
try { $path = $proc.Path } catch { $path = "" }
Write-Host ("pid={0} name={1} start={2} path={3}" -f $proc.Id, $proc.ProcessName, $proc.StartTime, $path)
} else {
Write-Host "pid=$ownerPid"
}
}
Write-Ok "port $Port is listening"
}
$established = @(Get-NetTCPConnection -LocalPort $Port -State Established -ErrorAction SilentlyContinue)
Write-Host "established_connections=$($established.Count)"
foreach ($conn in $established | Select-Object -First 12) {
Write-Host (" {0}:{1} -> {2}:{3} pid={4}" -f $conn.LocalAddress, $conn.LocalPort, $conn.RemoteAddress, $conn.RemotePort, $conn.OwningProcess)
}
Write-Step "PostgreSQL Schema"
$schema = Invoke-PsqlScalar "SELECT version::text || '|' || dirty::text FROM schema_migrations ORDER BY version DESC LIMIT 1;"
if ($schema) {
$parts = $schema -split "\|", 2
Write-Host "schema_version=$($parts[0])"
Write-Host "schema_dirty=$($parts[1])"
if ($parts.Count -gt 1 -and $parts[1] -eq "f") {
Write-Ok "schema is clean"
} else {
Add-Failure "schema_migrations is dirty"
}
}
Write-Step "Server Log"
Write-Host "log=$ServerLogPath"
$lines = @(Read-SharedLogLines)
if ($lines.Count -eq 0) {
Add-Failure "server log is missing or empty"
} else {
$readyLines = @($lines | Where-Object { $_ -like "*telesrv 服务就绪*" })
if ($readyLines.Count -gt 0) {
$ready = @($readyLines)[-1]
Write-Host $ready
$runtimeCommit = Get-JsonFieldFromLog @($ready) "git_commit"
$runtimeSchema = Get-JsonFieldFromLog @($ready) "schema_version"
$runtimePID = Get-JsonFieldFromLog @($ready) "pid"
Write-Host "runtime_git_commit=$runtimeCommit"
Write-Host "runtime_schema_version=$runtimeSchema"
Write-Host "runtime_pid=$runtimePID"
if ($head -and $runtimeCommit -and $runtimeCommit -ne $head) {
Add-Failure "runtime git_commit $runtimeCommit != HEAD $head"
} elseif ($runtimeCommit) {
Write-Ok "runtime commit matches HEAD"
}
} else {
Add-Failure "server log has no 'telesrv 服务就绪' line"
}
$recent = @($lines | Select-Object -Last $RecentLogLines)
$bad = @($recent | Where-Object {
$_ -cmatch "INTERNAL_SERVER_ERROR|Unhandled RPC|NOT_IMPLEMENTED|bad_msg|panic|\tERROR\t"
})
if ($bad.Count -eq 0) {
Write-Ok "recent log has no internal/unhandled/bad_msg errors"
} else {
Add-Failure "recent log has $($bad.Count) suspicious error lines"
$bad | Select-Object -Last 40 | ForEach-Object { Write-Host $_ }
}
}
Write-Step "Android"
if ($SkipAdb) {
Write-Host "adb checks skipped"
} else {
$adb = Get-Command adb -ErrorAction SilentlyContinue
if (-not $adb) {
Add-Failure "adb is not available"
} else {
$devices = Invoke-Adb @("devices") -AllowFailure
$deviceLines = @($devices.Output -split "`r?`n" | Where-Object { $_ -match "\tdevice$" })
Write-Host "adb_devices=$($deviceLines.Count)"
if ($deviceLines.Count -lt 1) {
Add-Failure "no adb device connected"
} elseif ($deviceLines.Count -gt 1 -and -not $DeviceSerial) {
Add-Failure "multiple adb devices; pass -DeviceSerial"
} else {
$model = (Invoke-Adb @("shell", "getprop", "ro.product.model") -AllowFailure).Output.Trim()
$sdk = (Invoke-Adb @("shell", "getprop", "ro.build.version.sdk") -AllowFailure).Output.Trim()
$pkg = Invoke-Adb @("shell", "dumpsys", "package", $AndroidPackage) -AllowFailure
Write-Host "device_model=$model sdk=$sdk"
if ($pkg.Output -match "versionName=([^\r\n]+)") {
Write-Ok "Android package $AndroidPackage installed version=$($Matches[1])"
} else {
Add-Failure "Android package $AndroidPackage not found"
}
}
}
}
Write-Host ""
if ($Failures.Count -gt 0) {
Write-Host "Runtime check failed:"
foreach ($failure in $Failures) {
Write-Host " - $failure"
}
exit 1
}
Write-Host "Runtime check passed."

View file

@ -0,0 +1,347 @@
<#
.SYNOPSIS
Builds and restarts the local telesrv process with explicit runtime logs.
.DESCRIPTION
This helper is meant for Windows development loops. It builds the current
workspace into a staging executable, stops the repo-local process currently
listening on the configured MTProto port, promotes the new executable, starts it
hidden, and verifies that the port is listening again.
#>
[CmdletBinding()]
param(
[string]$Listen = "0.0.0.0:2398",
[string]$AdvertiseIP,
[string]$ExePath,
[string]$LogDir,
[int]$HealthTimeoutSeconds = 20,
[int]$Tail = 80,
[switch]$SkipBuild,
[switch]$NoStart
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
if (-not $ExePath) {
$ExePath = Join-Path $RepoRoot "bin\telesrv.exe"
}
if (-not $LogDir) {
$LogDir = Join-Path $RepoRoot "logs"
}
$ExePath = [System.IO.Path]::GetFullPath($ExePath)
$LogDir = [System.IO.Path]::GetFullPath($LogDir)
$BinDir = Split-Path -Parent $ExePath
$NextExePath = Join-Path $BinDir "telesrv.next.exe"
function Write-Step {
param([string]$Message)
Write-Host ""
Write-Host "== $Message =="
}
function Invoke-External {
param(
[string]$FilePath,
[string[]]$Arguments,
[switch]$AllowFailure
)
$oldErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
$output = & $FilePath @Arguments 2>&1
$exitCode = $LASTEXITCODE
} finally {
$ErrorActionPreference = $oldErrorActionPreference
}
$text = ($output | ForEach-Object { $_.ToString() }) -join "`n"
if ($exitCode -ne 0 -and -not $AllowFailure) {
throw "$FilePath $($Arguments -join ' ') failed with exit code ${exitCode}:`n$text"
}
[pscustomobject]@{
ExitCode = $exitCode
Output = $text
}
}
function Get-GitOutput {
param([string[]]$Arguments, [string]$Default = "unknown")
$res = Invoke-External "git" $Arguments -AllowFailure
if ($res.ExitCode -ne 0) {
return $Default
}
$text = $res.Output.Trim()
if ($text.Length -eq 0) {
return $Default
}
return $text
}
function Get-ListenPort {
param([string]$Address)
if ($Address -match '^\[.+\]:(\d+)$') {
return [int]$Matches[1]
}
if ($Address -match ':(\d+)$') {
return [int]$Matches[1]
}
throw "Cannot parse listen port from '$Address'"
}
function Test-PathUnderRepo {
param([string]$Path)
if (-not $Path) {
return $false
}
$full = [System.IO.Path]::GetFullPath($Path)
return $full.StartsWith($RepoRoot, [System.StringComparison]::OrdinalIgnoreCase)
}
function Test-RepoTelesrvProcess {
param(
[object]$Process,
[string]$ExePath
)
if (-not $Process) {
return $false
}
$path = $null
try {
$path = $Process.Path
} catch {
$path = $null
}
if ($path) {
$fullPath = [System.IO.Path]::GetFullPath($path)
$fullExePath = [System.IO.Path]::GetFullPath($ExePath)
$binDir = [System.IO.Path]::GetFullPath((Split-Path -Parent $fullExePath))
$fileName = [System.IO.Path]::GetFileName($fullPath)
if ($fullPath.Equals($fullExePath, [System.StringComparison]::OrdinalIgnoreCase)) {
return $true
}
if ($fullPath.StartsWith($binDir, [System.StringComparison]::OrdinalIgnoreCase) -and ($fileName -like "telesrv*.exe*")) {
return $true
}
return $false
}
# Path can be unavailable for protected or already-exiting processes. Only
# take ownership of telesrv-looking processes in that ambiguous state.
return (($Process.ProcessName -eq "telesrv") -or ($Process.ProcessName -like "telesrv*"))
}
function Get-RepoTelesrvProcesses {
param([int]$Port, [string]$ExePath)
# 按 PID 去重,合并两条发现路径:
# 1) 端口监听者——主路径,但 Get-NetTCPConnection 偶发返回空(曾漏判成 "no listener"
# 导致旧进程没被停、promote 复制撞文件锁)。
# 2) 按进程名/路径 + 仓库 bin 下 telesrv* 可执行文件——兜底覆盖端口漏报,并能抓到
# “持有 telesrv.exe / telesrv.exe~ 文件锁但端口尚未就绪”的实例promote 复制前必须停掉)。
$foundByPid = @{}
$listenerPids = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique)
foreach ($ownerPid in $listenerPids) {
if (-not $ownerPid) {
continue
}
$proc = Get-Process -Id $ownerPid -ErrorAction SilentlyContinue
if (-not $proc) {
continue
}
$path = $null
try {
$path = $proc.Path
} catch {
$path = $null
}
$procId = [int]$proc.Id
$isRepoProcess = Test-RepoTelesrvProcess -Process $proc -ExePath $ExePath
if ($isRepoProcess) {
$foundByPid[$procId] = $proc
} else {
throw "Port $Port is held by non-repo process PID $($proc.Id) ($($proc.ProcessName)) at '$path'"
}
}
$candidateProcesses = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.ProcessName -eq "telesrv" -or $_.ProcessName -like "telesrv*" })
foreach ($proc in $candidateProcesses) {
$procId = [int]$proc.Id
if ($foundByPid.ContainsKey($procId)) {
continue
}
# 只接管仓库内的实例:路径在 repo/bin 下,或路径不可读但进程名看起来就是 telesrv兜底
# 仓库外的同名进程(用户在别处跑的)一律不动。
$isRepoProcess = Test-RepoTelesrvProcess -Process $proc -ExePath $ExePath
if ($isRepoProcess) {
$foundByPid[$procId] = $proc
}
}
return @($foundByPid.Values)
}
function Wait-ProcessesExited {
param([int[]]$Pids)
if (-not $Pids -or $Pids.Count -eq 0) {
return
}
$deadline = (Get-Date).AddSeconds(10)
while ((Get-Date) -lt $deadline) {
$alive = @($Pids | Where-Object { Get-Process -Id $_ -ErrorAction SilentlyContinue })
if ($alive.Count -eq 0) {
return
}
Start-Sleep -Milliseconds 250
}
throw "Timed out waiting for old telesrv process(es) to exit: $($Pids -join ', ')"
}
function Wait-PortFree {
param([int]$Port, [int[]]$Pids)
$deadline = (Get-Date).AddSeconds(10)
while ((Get-Date) -lt $deadline) {
$stillListening = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue |
Where-Object { $Pids -contains $_.OwningProcess })
if ($stillListening.Count -eq 0) {
return
}
Start-Sleep -Milliseconds 250
}
throw "Timed out waiting for old telesrv listener on port $Port to stop"
}
$ListenPort = Get-ListenPort $Listen
New-Item -ItemType Directory -Force -Path $BinDir | Out-Null
New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
Push-Location $RepoRoot
try {
if (-not $SkipBuild) {
Write-Step "Build telesrv"
$commit = Get-GitOutput @("rev-parse", "HEAD")
$branch = Get-GitOutput @("branch", "--show-current")
$dirty = Get-GitOutput @("status", "--porcelain", "--untracked-files=no") -Default ""
$treeState = "clean"
if ($dirty.Length -gt 0) {
$treeState = "dirty"
}
$buildTime = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
$ldflags = "-X main.gitCommit=$commit -X main.gitBranch=$branch -X main.gitTreeState=$treeState -X main.buildTime=$buildTime"
Remove-Item -LiteralPath $NextExePath -ErrorAction SilentlyContinue
Invoke-External "go" @("build", "-ldflags", $ldflags, "-o", $NextExePath, ".\cmd\telesrv") | Out-Null
Write-Host "[ok] built $NextExePath"
Write-Host "[ok] commit=$commit branch=$branch tree=$treeState build_time=$buildTime"
}
Write-Step "Stop old telesrv processes"
$oldProcesses = @(Get-RepoTelesrvProcesses $ListenPort $ExePath)
if ($oldProcesses.Count -eq 0) {
Write-Host "[ok] no existing repo-local listener on port $ListenPort"
} else {
$oldPids = @($oldProcesses | Select-Object -ExpandProperty Id)
foreach ($proc in $oldProcesses) {
Write-Host "[stop] PID $($proc.Id) $($proc.ProcessName) $($proc.Path)"
Stop-Process -Id $proc.Id -Force
}
Wait-ProcessesExited $oldPids
Wait-PortFree $ListenPort $oldPids
Write-Host "[ok] stopped old listener(s): $($oldPids -join ', ')"
}
if (-not $SkipBuild) {
Write-Step "Promote executable"
# telesrv.exe 可能被外部 watcher/watchdog 抢先重生的实例占用文件锁;停掉持有者后短暂重试,
# 避免直接撞 "being used by another process" 复制失败(曾因此 promote 失败)。
$promoted = $false
for ($attempt = 1; $attempt -le 10; $attempt++) {
try {
Copy-Item -LiteralPath $NextExePath -Destination $ExePath -Force -ErrorAction Stop
$promoted = $true
break
} catch {
$holders = @(Get-RepoTelesrvProcesses $ListenPort $ExePath)
foreach ($holder in $holders) {
Write-Host "[stop] PID $($holder.Id) holding $ExePath; retry $attempt/10"
Stop-Process -Id $holder.Id -Force -ErrorAction SilentlyContinue
}
Start-Sleep -Milliseconds 300
}
}
if (-not $promoted) {
throw "Failed to promote $ExePath after retries (file kept locked; an external watcher may be respawning telesrv)"
}
Remove-Item -LiteralPath $NextExePath -ErrorAction SilentlyContinue
Write-Host "[ok] promoted $ExePath"
} elseif (-not (Test-Path -LiteralPath $ExePath)) {
throw "Executable not found: $ExePath"
}
if ($NoStart) {
Write-Host "[ok] NoStart requested; executable is ready but not running"
return
}
Write-Step "Start telesrv"
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
$stdoutPath = Join-Path $LogDir "telesrv-$stamp.out.log"
$stderrPath = Join-Path $LogDir "telesrv-$stamp.err.log"
$env:TELESRV_LISTEN = $Listen
if ($AdvertiseIP) {
$env:TELESRV_ADVERTISE_IP = $AdvertiseIP
}
$proc = Start-Process -FilePath $ExePath `
-WorkingDirectory $RepoRoot `
-RedirectStandardOutput $stdoutPath `
-RedirectStandardError $stderrPath `
-PassThru `
-WindowStyle Hidden
$deadline = (Get-Date).AddSeconds($HealthTimeoutSeconds)
$listening = $false
while ((Get-Date) -lt $deadline) {
$proc.Refresh()
if ($proc.HasExited) {
$errTail = ""
if (Test-Path -LiteralPath $stderrPath) {
$errTail = (Get-Content -LiteralPath $stderrPath -Tail $Tail -ErrorAction SilentlyContinue) -join "`n"
}
throw "telesrv exited during startup with code $($proc.ExitCode):`n$errTail"
}
$conn = @(Get-NetTCPConnection -LocalPort $ListenPort -State Listen -ErrorAction SilentlyContinue |
Where-Object { $_.OwningProcess -eq $proc.Id })
if ($conn.Count -gt 0) {
$listening = $true
break
}
Start-Sleep -Milliseconds 250
}
if (-not $listening) {
throw "telesrv PID $($proc.Id) did not listen on port $ListenPort within ${HealthTimeoutSeconds}s"
}
Write-Host "[ok] started PID $($proc.Id), listening on $Listen"
Write-Host "[ok] stdout: $stdoutPath"
Write-Host "[ok] stderr: $stderrPath"
if (Test-Path -LiteralPath $stderrPath) {
Get-Content -LiteralPath $stderrPath -Tail $Tail
}
[pscustomobject]@{
Pid = $proc.Id
Listen = $Listen
AdvertiseIP = $env:TELESRV_ADVERTISE_IP
Exe = $ExePath
Stdout = $stdoutPath
Stderr = $stderrPath
}
} finally {
Pop-Location
}

View file

@ -0,0 +1,515 @@
<#
.SYNOPSIS
Validates Android big video upload and interruption recovery.
.DESCRIPTION
This helper covers the stable parts of the big-upload loop:
fixture generation/push, baseline snapshots, optional server restart when
upload.saveBigFilePart appears, post-send media assertions, and upload temp
cleanup checks. It intentionally does not automate Android media picker taps.
#>
[CmdletBinding()]
param(
[ValidateSet("Preflight", "Prepare", "BeforeSend", "WatchRestart", "AfterSend", "All")]
[string]$Phase = "Preflight",
[long]$SenderUserId = 1780269504,
[long]$RecipientUserId = 1780269505,
[string]$AndroidPackage = "org.telegram.messenger.beta",
[string]$DeviceSerial,
[string]$PostgresContainer = "telesrv-postgres",
[string]$Database = "telesrv",
[string]$DbUser = "telesrv",
[string]$ServerLogPath,
[string]$StatePath,
[string]$FixtureDir,
[string]$VideoFixture,
[string]$BlobDir,
[string]$RemoteMovieDir = "/sdcard/Movies/telesrv",
[int64]$MinBigFileBytes = 12MB,
[int]$RestartAfterParts = 2,
[int]$WatchTimeoutSeconds = 90,
[string]$RestartScript,
[switch]$SkipAdb,
[switch]$AllowMissingThumb,
[switch]$BuildOnRestart
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
if (-not $ServerLogPath) {
$latestLog = Get-ChildItem (Join-Path $RepoRoot "logs") -Filter "telesrv-*.err.log" -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if ($latestLog) {
$ServerLogPath = $latestLog.FullName
} else {
$ServerLogPath = Join-Path $RepoRoot "logs\telesrv.err.log"
}
}
if (-not $StatePath) {
$StatePath = Join-Path $RepoRoot "logs\android-big-video-upload-state.json"
}
if (-not $FixtureDir) {
$FixtureDir = Join-Path $RepoRoot "logs\media-fixtures"
}
if (-not $BlobDir) {
$BlobDir = Join-Path $RepoRoot "data\blobs"
}
if (-not $RestartScript) {
$RestartScript = Join-Path $RepoRoot "scripts\restart-local-server.ps1"
}
$Failures = New-Object System.Collections.Generic.List[string]
function Write-Step([string]$Message) {
Write-Host ""
Write-Host "== $Message =="
}
function Write-Ok([string]$Message) {
Write-Host "[ok] $Message"
}
function Write-Warn([string]$Message) {
Write-Host "[warn] $Message"
}
function Add-Failure([string]$Message) {
$script:Failures.Add($Message) | Out-Null
Write-Host "[fail] $Message"
}
function Assert-Check([bool]$Condition, [string]$Message) {
if ($Condition) { Write-Ok $Message } else { Add-Failure $Message }
}
function Invoke-External {
param(
[string]$FilePath,
[string[]]$Arguments,
[switch]$AllowFailure
)
$oldErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
$output = & $FilePath @Arguments 2>&1
$exitCode = $LASTEXITCODE
} finally {
$ErrorActionPreference = $oldErrorActionPreference
}
$text = ($output | ForEach-Object { $_.ToString() }) -join "`n"
if ($exitCode -ne 0 -and -not $AllowFailure) {
throw "$FilePath $($Arguments -join ' ') failed with exit code ${exitCode}:`n$text"
}
[pscustomobject]@{ ExitCode = $exitCode; Output = $text }
}
function Invoke-PsqlRows([string]$Sql) {
$result = Invoke-External "docker" @(
"exec", $PostgresContainer,
"psql", "-U", $DbUser, "-d", $Database,
"-v", "ON_ERROR_STOP=1",
"-At", "-F", "|",
"-c", $Sql
)
if ([string]::IsNullOrWhiteSpace($result.Output)) {
return @()
}
return @($result.Output -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
}
function Read-SharedLogLines([string]$Path = $ServerLogPath) {
if (-not (Test-Path -LiteralPath $Path)) {
return @()
}
$stream = [System.IO.File]::Open($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
try {
$reader = New-Object System.IO.StreamReader($stream)
try {
$lines = New-Object System.Collections.Generic.List[string]
while (-not $reader.EndOfStream) {
$lines.Add($reader.ReadLine()) | Out-Null
}
return $lines
} finally {
$reader.Dispose()
}
} finally {
$stream.Dispose()
}
}
function Get-LogLineCount {
return @(Read-SharedLogLines).Count
}
function Get-LogLinesSince([int]$Skip, [string]$Path = $ServerLogPath) {
return @(Read-SharedLogLines $Path | Select-Object -Skip $Skip)
}
function Get-AdbArgs([string[]]$Arguments) {
if ($DeviceSerial) { return @("-s", $DeviceSerial) + $Arguments }
return $Arguments
}
function Invoke-Adb([string[]]$Arguments, [switch]$AllowFailure) {
Invoke-External "adb" (Get-AdbArgs $Arguments) -AllowFailure:$AllowFailure
}
function Save-State([pscustomobject]$State) {
$dir = Split-Path -Parent $StatePath
if ($dir) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
$State | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $StatePath -Encoding UTF8
Write-Ok "state saved to $StatePath"
}
function Load-State {
if (-not (Test-Path -LiteralPath $StatePath)) {
throw "state file not found: $StatePath. Run -Phase BeforeSend first."
}
Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json
}
function Get-PrivateMessageMaxId {
$rows = @(Invoke-PsqlRows @"
SELECT COALESCE(MAX(id), 0)
FROM private_messages
WHERE (sender_user_id = $SenderUserId AND recipient_user_id = $RecipientUserId)
OR (sender_user_id = $RecipientUserId AND recipient_user_id = $SenderUserId);
"@)
if ($rows.Count -eq 0) { return 0 }
return [long]$rows[0]
}
function Get-UploadPartUsage {
$rows = @(Invoke-PsqlRows @"
SELECT COUNT(*), COALESCE(SUM(size), 0)
FROM upload_parts
WHERE owner_user_id = $SenderUserId;
"@)
if ($rows.Count -eq 0) {
return [pscustomobject]@{ Parts = 0; Bytes = 0L }
}
$parts = $rows[0] -split "\|"
return [pscustomobject]@{ Parts = [int]$parts[0]; Bytes = [long]$parts[1] }
}
function Get-UploadTempStats {
$root = Join-Path (Join-Path $BlobDir "upload_parts") ([string]$SenderUserId)
if (-not (Test-Path -LiteralPath $root)) {
return [pscustomobject]@{ Files = 0; Bytes = 0L }
}
$files = @(Get-ChildItem -LiteralPath $root -Recurse -File -ErrorAction SilentlyContinue)
$bytes = 0L
foreach ($file in $files) { $bytes += [long]$file.Length }
return [pscustomobject]@{ Files = $files.Count; Bytes = $bytes }
}
function Get-NewVideoMessages([long]$AfterMessageId) {
$rows = @(Invoke-PsqlRows @"
SELECT
id,
COALESCE(media->>'kind', ''),
COALESCE(media->>'video', 'false'),
COALESCE(media->'document'->>'id', '0'),
COALESCE(media->'document'->>'mime_type', ''),
COALESCE(media->'document'->>'size', '0'),
COALESCE(jsonb_array_length(COALESCE(media->'document'->'thumbs', '[]'::jsonb)), 0)
FROM private_messages
WHERE id > $AfterMessageId
AND sender_user_id = $SenderUserId
AND recipient_user_id = $RecipientUserId
ORDER BY id;
"@)
$items = @()
foreach ($row in $rows) {
$parts = $row -split "\|", 7
$items += [pscustomobject]@{
MessageId = [long]$parts[0]
Kind = $parts[1]
Video = $parts[2]
DocumentId = [long]$parts[3]
MimeType = $parts[4]
Size = [long]$parts[5]
ThumbCount = [int]$parts[6]
}
}
return $items
}
function Get-DocumentRows([long[]]$DocumentIds) {
if ($DocumentIds.Count -eq 0) { return @() }
$ids = ($DocumentIds | ForEach-Object { $_.ToString() }) -join ","
$rows = @(Invoke-PsqlRows @"
SELECT id, mime_type, size, jsonb_array_length(COALESCE(thumbs, '[]'::jsonb))
FROM documents
WHERE id IN ($ids)
ORDER BY id;
"@)
$items = @()
foreach ($row in $rows) {
$parts = $row -split "\|", 4
$items += [pscustomobject]@{
DocumentId = [long]$parts[0]
MimeType = $parts[1]
Size = [long]$parts[2]
ThumbCount = [int]$parts[3]
}
}
return $items
}
function Get-FileBlobRows([long[]]$DocumentIds) {
if ($DocumentIds.Count -eq 0) { return @() }
$keys = @()
foreach ($docId in $DocumentIds) {
$keys += "doc:$docId"
$keys += "doc:${docId}:m"
}
$quoted = ($keys | ForEach-Object { "'" + $_.Replace("'", "''") + "'" }) -join ","
$rows = @(Invoke-PsqlRows @"
SELECT location_key, backend, object_key, size, mime_type
FROM file_blobs
WHERE location_key IN ($quoted)
ORDER BY location_key;
"@)
$items = @()
foreach ($row in $rows) {
$parts = $row -split "\|", 5
$items += [pscustomobject]@{
LocationKey = $parts[0]
Backend = $parts[1]
ObjectKey = $parts[2]
Size = [long]$parts[3]
MimeType = $parts[4]
}
}
return $items
}
function Get-BlobFilePath([string]$ObjectKey) {
if ($ObjectKey.Length -lt 4) {
return Join-Path $BlobDir $ObjectKey
}
return Join-Path (Join-Path (Join-Path $BlobDir $ObjectKey.Substring(0, 2)) $ObjectKey.Substring(2, 2)) $ObjectKey
}
function New-BigVideoFixture([string]$Path) {
$ffmpeg = Get-Command ffmpeg -ErrorAction SilentlyContinue
if (-not $ffmpeg) {
Add-Failure "ffmpeg is available or -VideoFixture points at an existing >10MB mp4"
return
}
Invoke-External "ffmpeg" @(
"-y",
"-f", "lavfi",
"-i", "testsrc2=size=1280x720:rate=30",
"-f", "lavfi",
"-i", "sine=frequency=660:sample_rate=44100",
"-t", "16",
"-pix_fmt", "yuv420p",
"-c:v", "libx264",
"-preset", "ultrafast",
"-b:v", "8M",
"-maxrate", "8M",
"-bufsize", "16M",
"-c:a", "aac",
"-shortest",
$Path
) | Out-Null
}
function Ensure-BigVideoFixture {
New-Item -ItemType Directory -Force -Path $FixtureDir | Out-Null
if (-not $VideoFixture) {
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
$script:VideoFixture = Join-Path $FixtureDir "telesrv-android-big-video-$stamp.mp4"
New-BigVideoFixture $script:VideoFixture
}
Assert-Check ($VideoFixture -and (Test-Path -LiteralPath $VideoFixture)) "video fixture exists: $VideoFixture"
if ($VideoFixture -and (Test-Path -LiteralPath $VideoFixture)) {
$size = (Get-Item -LiteralPath $VideoFixture).Length
Write-Host "fixture_size=$size"
Assert-Check ($size -ge $MinBigFileBytes) "fixture is large enough to trigger upload.saveBigFilePart"
}
}
function Push-BigVideoFixture {
if ($SkipAdb) {
Write-Warn "adb push skipped"
return
}
Invoke-Adb @("shell", "mkdir", "-p", $RemoteMovieDir) | Out-Null
Invoke-Adb @("push", $VideoFixture, "$RemoteMovieDir/") | Out-Null
$leaf = Split-Path -Leaf $VideoFixture
Invoke-Adb @("shell", "am", "broadcast", "-a", "android.intent.action.MEDIA_SCANNER_SCAN_FILE", "-d", "file://$RemoteMovieDir/$leaf") | Out-Null
Write-Ok "big video pushed to Android: $RemoteMovieDir/$leaf"
}
function Run-Preflight {
Write-Step "Preflight"
Assert-Check (Test-Path -LiteralPath $ServerLogPath) "server log exists: $ServerLogPath"
Invoke-PsqlRows "SELECT 1;" | Out-Null
Write-Ok "PostgreSQL is reachable"
if (-not $SkipAdb) {
Assert-Check ([bool](Get-Command adb -ErrorAction SilentlyContinue)) "adb is available"
$devices = Invoke-Adb @("devices")
$deviceLines = @($devices.Output -split "`r?`n" | Where-Object { $_ -match "\tdevice$" })
Assert-Check ($deviceLines.Count -ge 1) "adb has a connected device"
Assert-Check (($deviceLines.Count -eq 1) -or [bool]$DeviceSerial) "adb selects a single device or -DeviceSerial is set"
if (($deviceLines.Count -eq 1) -or [bool]$DeviceSerial) {
$pkg = Invoke-Adb @("shell", "dumpsys", "package", $AndroidPackage)
Assert-Check ($pkg.Output -match "versionName=") "Android package $AndroidPackage is installed"
}
} else {
Write-Warn "adb checks skipped"
}
}
function Run-Prepare {
Write-Step "Prepare big video"
Run-Preflight
Ensure-BigVideoFixture
Push-BigVideoFixture
Write-Host "Manual step: send the pushed >10MB video from Android/Alice to Bob."
}
function Run-BeforeSend {
Write-Step "BeforeSend snapshot"
$usage = Get-UploadPartUsage
$temp = Get-UploadTempStats
$state = [pscustomobject]@{
SenderUserId = $SenderUserId
RecipientUserId = $RecipientUserId
BaselinePrivateMessageId = Get-PrivateMessageMaxId
BaselineUploadParts = $usage.Parts
BaselineUploadPartBytes = $usage.Bytes
BaselineTempFiles = $temp.Files
BaselineTempBytes = $temp.Bytes
BaselineLogLineCount = Get-LogLineCount
ServerLogPath = $ServerLogPath
BlobDir = $BlobDir
RestartTriggered = $false
RestartedAt = ""
ObservedBigPart = $false
CreatedAt = (Get-Date -Format o)
VideoFixture = $VideoFixture
}
Write-Host "private_messages max id before send: $($state.BaselinePrivateMessageId)"
Write-Host "upload_parts before send: parts=$($usage.Parts) bytes=$($usage.Bytes)"
Write-Host "temp upload files before send: files=$($temp.Files) bytes=$($temp.Bytes)"
Save-State $state
}
function Run-WatchRestart {
Write-Step "Watch upload.saveBigFilePart and restart"
$state = Load-State
$deadline = (Get-Date).AddSeconds($WatchTimeoutSeconds)
$restartDone = $false
while ((Get-Date) -lt $deadline) {
$lines = @(Get-LogLinesSince ([int]$state.BaselineLogLineCount) ([string]$state.ServerLogPath))
$hits = @($lines | Where-Object {
$_ -like "*upload.saveBigFilePart*" -and $_ -like '*client_type": "android"*'
})
if ($hits.Count -ge $RestartAfterParts) {
Write-Host "observed upload.saveBigFilePart lines=$($hits.Count); restarting server"
$state.ObservedBigPart = $true
$state.RestartTriggered = $true
$state.RestartedAt = (Get-Date -Format o)
Save-State $state
$args = @()
if (-not $BuildOnRestart) {
$args += "-SkipBuild"
}
Invoke-External "powershell" (@("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $RestartScript) + $args) | Out-Null
$restartDone = $true
break
}
Start-Sleep -Milliseconds 500
}
Assert-Check $restartDone "server restarted after upload.saveBigFilePart was observed"
}
function Run-AfterSend {
Write-Step "AfterSend assertions"
$state = Load-State
$messages = @(Get-NewVideoMessages ([long]$state.BaselinePrivateMessageId))
foreach ($message in $messages) {
Write-Host ("new private message id={0} kind={1} video={2} doc={3} mime={4} size={5} thumbs={6}" -f $message.MessageId, $message.Kind, $message.Video, $message.DocumentId, $message.MimeType, $message.Size, $message.ThumbCount)
}
$videos = @($messages | Where-Object {
$_.Kind -eq "document" -and $_.Video -eq "true" -and $_.MimeType -eq "video/mp4" -and $_.DocumentId -gt 0 -and $_.Size -ge $MinBigFileBytes
})
Assert-Check ($videos.Count -ge 1) "new private message includes a >10MB uploaded video/mp4 document"
$docIds = @($videos | Select-Object -ExpandProperty DocumentId -Unique)
$documents = @(Get-DocumentRows $docIds)
$blobs = @(Get-FileBlobRows $docIds)
Assert-Check (@($documents | Where-Object { $_.MimeType -eq "video/mp4" -and $_.Size -ge $MinBigFileBytes }).Count -ge 1) "documents row persisted for big video"
if (-not $AllowMissingThumb) {
Assert-Check (@($documents | Where-Object { $_.ThumbCount -gt 0 }).Count -ge 1) "big video document has thumbnail metadata"
}
$bodyBlobs = @($blobs | Where-Object { $_.LocationKey -like "doc:*" -and $_.LocationKey -notlike "*:m" -and $_.Size -ge $MinBigFileBytes })
Assert-Check ($bodyBlobs.Count -ge 1) "big video body file_blobs row exists"
if (-not $AllowMissingThumb) {
Assert-Check (@($blobs | Where-Object { $_.LocationKey -like "doc:*:m" -and $_.Size -gt 0 }).Count -ge 1) "big video thumbnail file_blobs row exists"
}
foreach ($blob in $blobs) {
if ($blob.Backend -eq "localfs" -and $blob.ObjectKey) {
Assert-Check (Test-Path -LiteralPath (Get-BlobFilePath $blob.ObjectKey)) "localfs blob exists: $($blob.LocationKey)"
}
}
$usage = Get-UploadPartUsage
$temp = Get-UploadTempStats
Write-Host "upload_parts after send: parts=$($usage.Parts) bytes=$($usage.Bytes)"
Write-Host "temp upload files after send: files=$($temp.Files) bytes=$($temp.Bytes)"
Assert-Check ($usage.Parts -le [int]$state.BaselineUploadParts) "upload_parts metadata cleaned after successful big upload"
Assert-Check ($temp.Files -le [int]$state.BaselineTempFiles) "upload temp files cleaned after successful big upload"
$oldLines = @(Get-LogLinesSince ([int]$state.BaselineLogLineCount) ([string]$state.ServerLogPath))
$newLines = @(Read-SharedLogLines)
$combined = @($oldLines + $newLines)
$bigHits = @($combined | Where-Object { $_ -like "*upload.saveBigFilePart*" -and $_ -like '*client_type": "android"*' })
$sendMediaHits = @($combined | Where-Object { $_ -like "*messages.sendMedia*" -and $_ -like '*client_type": "android"*' })
$bad = @($combined | Where-Object { $_ -cmatch "INTERNAL_SERVER_ERROR|rpc error|Unhandled RPC|NOT_IMPLEMENTED|bad_msg|panic|\tERROR\t" })
Assert-Check (($bigHits.Count -ge 1) -or [bool]$state.ObservedBigPart) "server log has Android upload.saveBigFilePart"
Assert-Check ($sendMediaHits.Count -ge 1) "server log has Android messages.sendMedia"
Assert-Check ($bad.Count -eq 0) "server logs have no big-upload-era internal errors or unhandled RPCs"
}
function Finish-Run {
if ($Failures.Count -gt 0) {
Write-Host ""
Write-Host "Validation failed:"
foreach ($failure in $Failures) { Write-Host " - $failure" }
exit 1
}
Write-Host ""
Write-Host "Validation passed."
}
switch ($Phase) {
"Preflight" { Run-Preflight }
"Prepare" { Run-Prepare }
"BeforeSend" { Run-BeforeSend }
"WatchRestart" { Run-WatchRestart }
"AfterSend" { Run-AfterSend }
"All" {
Run-Prepare
Run-BeforeSend
Write-Host "Start sending the pushed video from Android/Alice now."
Run-WatchRestart
Read-Host "After Android finishes sending the video, press Enter"
Run-AfterSend
}
}
Finish-Run

View file

@ -0,0 +1,536 @@
<#
.SYNOPSIS
Semi-automates the Android -> TDesktop offline channel media recovery check.
.DESCRIPTION
This helper covers the stable parts of the local validation loop:
preflight checks, fixture generation/push, PostgreSQL state snapshots, server
log checks, and pass/fail assertions. It intentionally does not drive the
Android media picker or Telegram Desktop UI; those remain manual steps because
their coordinates and cached state are device/client dependent.
Typical flow:
1. Run -Phase Prepare to generate and push fixtures to Android.
2. Close DebugBob/TDesktop, then run -Phase BeforeSend.
3. Send photo, video, and document from Android/Alice.
4. Run -Phase AfterSend.
5. Start DebugBob/TDesktop, open the channel, confirm media is visible.
6. Run -Phase AfterBobOpen.
Use -Phase All for the same flow with interactive pauses.
#>
[CmdletBinding()]
param(
[ValidateSet("Preflight", "Prepare", "BeforeSend", "AfterSend", "AfterBobOpen", "All")]
[string]$Phase = "Preflight",
[long]$ChannelId = 24,
[long]$AndroidUserId = 1780269504,
[long]$BobUserId = 1780269505,
[string]$AndroidPackage = "org.telegram.messenger.beta",
[string]$DeviceSerial,
[string]$PostgresContainer = "telesrv-postgres",
[string]$Database = "telesrv",
[string]$DbUser = "telesrv",
[string]$ServerLogPath,
[string]$StatePath,
[string]$FixtureDir,
[string]$PhotoFixture,
[string]$VideoFixture,
[string]$DocumentFixture,
[string]$RemotePictureDir = "/sdcard/Pictures/telesrv",
[string]$RemoteMovieDir = "/sdcard/Movies/telesrv",
[string]$RemoteDocumentDir = "/sdcard/Download/telesrv",
[int]$ExpectedNewMessages = 3,
[switch]$AllowMissingVideo,
[switch]$AllowMissingGenericDocument,
[switch]$SkipAdb
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
if (-not $ServerLogPath) {
$ServerLogPath = Join-Path $RepoRoot "logs\app-version-observe-20260609-165537.err.log"
}
if (-not $StatePath) {
$StatePath = Join-Path $RepoRoot "logs\android-offline-media-recovery-state.json"
}
if (-not $FixtureDir) {
$FixtureDir = Join-Path $RepoRoot "logs\media-fixtures"
}
$Failures = New-Object System.Collections.Generic.List[string]
function Write-Step {
param([string]$Message)
Write-Host ""
Write-Host "== $Message =="
}
function Write-Ok {
param([string]$Message)
Write-Host "[ok] $Message"
}
function Write-Warn {
param([string]$Message)
Write-Host "[warn] $Message"
}
function Add-Failure {
param([string]$Message)
$script:Failures.Add($Message) | Out-Null
Write-Host "[fail] $Message"
}
function Assert-Check {
param(
[bool]$Condition,
[string]$Message
)
if ($Condition) {
Write-Ok $Message
} else {
Add-Failure $Message
}
}
function Invoke-External {
param(
[string]$FilePath,
[string[]]$Arguments,
[switch]$AllowFailure
)
$oldErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
$output = & $FilePath @Arguments 2>&1
$exitCode = $LASTEXITCODE
} finally {
$ErrorActionPreference = $oldErrorActionPreference
}
$text = ($output | ForEach-Object { $_.ToString() }) -join "`n"
if ($exitCode -ne 0 -and -not $AllowFailure) {
throw "$FilePath $($Arguments -join ' ') failed with exit code ${exitCode}:`n$text"
}
[pscustomobject]@{
ExitCode = $exitCode
Output = $text
}
}
function Assert-Command {
param([string]$Name)
$cmd = Get-Command $Name -ErrorAction SilentlyContinue
Assert-Check ([bool]$cmd) "$Name is available"
return [bool]$cmd
}
function Get-AdbArgs {
param([string[]]$Arguments)
if ($DeviceSerial) {
return @("-s", $DeviceSerial) + $Arguments
}
return $Arguments
}
function Invoke-Adb {
param([string[]]$Arguments)
Invoke-External "adb" (Get-AdbArgs $Arguments)
}
function Invoke-PsqlRows {
param([string]$Sql)
$args = @(
"exec", $PostgresContainer,
"psql", "-U", $DbUser, "-d", $Database,
"-v", "ON_ERROR_STOP=1",
"-At", "-F", "|",
"-c", $Sql
)
$result = Invoke-External "docker" $args
if ([string]::IsNullOrWhiteSpace($result.Output)) {
return @()
}
return @($result.Output -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
}
function Get-LogLineCount {
if (-not (Test-Path -LiteralPath $ServerLogPath)) {
return 0
}
return @((Get-Content -LiteralPath $ServerLogPath)).Count
}
function Get-LogLinesSince {
param([int]$Skip)
if (-not (Test-Path -LiteralPath $ServerLogPath)) {
Add-Failure "server log exists at $ServerLogPath"
return @()
}
return @(Get-Content -LiteralPath $ServerLogPath | Select-Object -Skip $Skip)
}
function Get-DialogState {
param([long]$UserId)
$rows = @(Invoke-PsqlRows @"
SELECT top_message_id, read_inbox_max_id, unread_count
FROM channel_dialogs
WHERE channel_id = $ChannelId AND user_id = $UserId;
"@)
if ($rows.Count -ne 1) {
Add-Failure "channel_dialogs has one row for channel_id=$ChannelId user_id=$UserId"
return $null
}
$parts = $rows[0] -split "\|"
[pscustomobject]@{
UserId = $UserId
TopMessageId = [int]$parts[0]
ReadInboxMaxId = [int]$parts[1]
UnreadCount = [int]$parts[2]
}
}
function Get-ChannelPts {
$rows = @(Invoke-PsqlRows "SELECT COALESCE(MAX(pts), 0) FROM channel_update_events WHERE channel_id = $ChannelId;")
if ($rows.Count -eq 0) {
return 0
}
return [int]$rows[0]
}
function Get-NewMessages {
param([int]$AfterMessageId)
$rows = @(Invoke-PsqlRows @"
SELECT
id,
sender_user_id,
media->>'kind',
COALESCE(media->'document'->>'mime_type', ''),
COALESCE((
SELECT attr->>'file_name'
FROM jsonb_array_elements(COALESCE(media->'document'->'attributes', '[]'::jsonb)) attr
WHERE attr->>'kind' = 'filename'
LIMIT 1
), '')
FROM channel_messages
WHERE channel_id = $ChannelId AND id > $AfterMessageId
ORDER BY id;
"@)
$items = @()
foreach ($row in $rows) {
$parts = $row -split "\|", 5
$items += [pscustomobject]@{
Id = [int]$parts[0]
SenderUserId = [long]$parts[1]
Kind = $parts[2]
MimeType = $parts[3]
FileName = $parts[4]
}
}
return $items
}
function Get-NewEventSummary {
param([int]$AfterPts)
$rows = @(Invoke-PsqlRows @"
SELECT COUNT(*), COALESCE(MAX(pts), 0)
FROM channel_update_events
WHERE channel_id = $ChannelId
AND pts > $AfterPts
AND event_type = 'new_channel_message';
"@)
if ($rows.Count -eq 0) {
return [pscustomobject]@{ Count = 0; MaxPts = 0 }
}
$parts = $rows[0] -split "\|"
[pscustomobject]@{
Count = [int]$parts[0]
MaxPts = [int]$parts[1]
}
}
function Save-State {
param([pscustomobject]$State)
$dir = Split-Path -Parent $StatePath
if ($dir) {
New-Item -ItemType Directory -Force -Path $dir | Out-Null
}
$State | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $StatePath -Encoding UTF8
Write-Ok "state saved to $StatePath"
}
function Load-State {
if (-not (Test-Path -LiteralPath $StatePath)) {
throw "state file not found: $StatePath. Run -Phase BeforeSend first."
}
Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json
}
function New-PhotoFixture {
param([string]$Path)
Add-Type -AssemblyName System.Drawing
$bitmap = New-Object System.Drawing.Bitmap 900, 520
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
$graphics.Clear([System.Drawing.Color]::FromArgb(34, 137, 116))
$fontLarge = New-Object System.Drawing.Font("Arial", 36, [System.Drawing.FontStyle]::Bold)
$fontSmall = New-Object System.Drawing.Font("Arial", 22, [System.Drawing.FontStyle]::Regular)
$brushWhite = [System.Drawing.Brushes]::White
$brushYellow = New-Object System.Drawing.SolidBrush([System.Drawing.Color]::FromArgb(245, 184, 60))
$graphics.DrawString("telesrv offline photo", $fontLarge, $brushWhite, 55, 85)
$graphics.DrawString((Get-Date -Format "yyyyMMdd-HHmmss"), $fontSmall, $brushWhite, 58, 150)
$graphics.FillRectangle($brushYellow, 58, 300, 780, 42)
$bitmap.Save($Path, [System.Drawing.Imaging.ImageFormat]::Jpeg)
$graphics.Dispose()
$bitmap.Dispose()
}
function New-DocumentFixture {
param([string]$Path)
$content = @"
<?xml version="1.0" encoding="utf-8"?>
<telesrv-offline-media-recovery generated_at="$(Get-Date -Format o)">
<purpose>Android to TDesktop offline media recovery validation</purpose>
</telesrv-offline-media-recovery>
"@
Set-Content -LiteralPath $Path -Value $content -Encoding UTF8
}
function New-VideoFixture {
param([string]$Path)
$ffmpeg = Get-Command ffmpeg -ErrorAction SilentlyContinue
if (-not $ffmpeg) {
Write-Warn "ffmpeg not found; video fixture was not generated"
return $false
}
Invoke-External "ffmpeg" @(
"-y",
"-f", "lavfi",
"-i", "testsrc=size=640x360:rate=30",
"-t", "1",
"-pix_fmt", "yuv420p",
$Path
) | Out-Null
return $true
}
function Ensure-Fixtures {
New-Item -ItemType Directory -Force -Path $FixtureDir | Out-Null
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
if (-not $PhotoFixture) {
$script:PhotoFixture = Join-Path $FixtureDir "telesrv-offline-photo-$stamp.jpg"
New-PhotoFixture $script:PhotoFixture
}
if (-not $DocumentFixture) {
$script:DocumentFixture = Join-Path $FixtureDir "telesrv-offline-doc-$stamp.xml"
New-DocumentFixture $script:DocumentFixture
}
if (-not $VideoFixture) {
$candidate = Join-Path $FixtureDir "telesrv-offline-video-$stamp.mp4"
if (New-VideoFixture $candidate) {
$script:VideoFixture = $candidate
}
}
Assert-Check (Test-Path -LiteralPath $PhotoFixture) "photo fixture exists: $PhotoFixture"
Assert-Check (Test-Path -LiteralPath $DocumentFixture) "document fixture exists: $DocumentFixture"
if (-not $AllowMissingVideo) {
Assert-Check ($VideoFixture -and (Test-Path -LiteralPath $VideoFixture)) "video fixture exists: $VideoFixture"
} elseif ($VideoFixture) {
Assert-Check (Test-Path -LiteralPath $VideoFixture) "video fixture exists: $VideoFixture"
}
}
function Push-Fixtures {
if ($SkipAdb) {
Write-Warn "adb push skipped"
return
}
Invoke-Adb @("shell", "mkdir", "-p", $RemotePictureDir, $RemoteMovieDir, $RemoteDocumentDir) | Out-Null
Invoke-Adb @("push", $PhotoFixture, "$RemotePictureDir/") | Out-Null
Invoke-Adb @("push", $DocumentFixture, "$RemoteDocumentDir/") | Out-Null
Invoke-Adb @("shell", "am", "broadcast", "-a", "android.intent.action.MEDIA_SCANNER_SCAN_FILE", "-d", "file://$RemotePictureDir/$(Split-Path -Leaf $PhotoFixture)") | Out-Null
Invoke-Adb @("shell", "am", "broadcast", "-a", "android.intent.action.MEDIA_SCANNER_SCAN_FILE", "-d", "file://$RemoteDocumentDir/$(Split-Path -Leaf $DocumentFixture)") | Out-Null
if ($VideoFixture -and (Test-Path -LiteralPath $VideoFixture)) {
Invoke-Adb @("push", $VideoFixture, "$RemoteMovieDir/") | Out-Null
Invoke-Adb @("shell", "am", "broadcast", "-a", "android.intent.action.MEDIA_SCANNER_SCAN_FILE", "-d", "file://$RemoteMovieDir/$(Split-Path -Leaf $VideoFixture)") | Out-Null
}
Write-Ok "fixtures pushed to Android media folders"
}
function Run-Preflight {
Write-Step "Preflight"
if (-not $SkipAdb) {
if (Assert-Command "adb") {
$devices = Invoke-Adb @("devices")
$deviceLines = @($devices.Output -split "`r?`n" | Where-Object { $_ -match "\tdevice$" })
Assert-Check ($deviceLines.Count -ge 1) "adb has at least one connected device"
Assert-Check (($deviceLines.Count -eq 1) -or [bool]$DeviceSerial) "adb selects a single device or -DeviceSerial is set"
if (($deviceLines.Count -eq 1) -or [bool]$DeviceSerial) {
$pkg = Invoke-Adb @("shell", "dumpsys", "package", $AndroidPackage)
Assert-Check ($pkg.Output -match "versionName=") "Android package $AndroidPackage is installed"
$model = (Invoke-Adb @("shell", "getprop", "ro.product.model")).Output.Trim()
$sdk = (Invoke-Adb @("shell", "getprop", "ro.build.version.sdk")).Output.Trim()
Write-Host "Android device: model=$model sdk=$sdk"
}
}
} else {
Write-Warn "adb checks skipped"
}
if (Assert-Command "docker") {
Invoke-PsqlRows "SELECT 1;" | Out-Null
Write-Ok "PostgreSQL is reachable through docker container $PostgresContainer"
}
Assert-Check (Test-Path -LiteralPath $ServerLogPath) "server log exists: $ServerLogPath"
}
function Run-Prepare {
Write-Step "Prepare fixtures"
Run-Preflight
Ensure-Fixtures
Push-Fixtures
Write-Host "Manual step: close DebugBob/TDesktop, then send these files from Android/Alice:"
Write-Host " Photo: $PhotoFixture"
if ($VideoFixture) {
Write-Host " Video: $VideoFixture"
}
Write-Host " Document: $DocumentFixture"
}
function Run-BeforeSend {
Write-Step "BeforeSend snapshot"
$bob = Get-DialogState $BobUserId
$alice = Get-DialogState $AndroidUserId
$pts = Get-ChannelPts
$lineCount = Get-LogLineCount
Assert-Check ($null -ne $bob) "Bob dialog state can be read"
Assert-Check ($null -ne $alice) "Android/Alice dialog state can be read"
if ($bob) {
Write-Host "Bob before send: top=$($bob.TopMessageId) read=$($bob.ReadInboxMaxId) unread=$($bob.UnreadCount)"
}
if ($alice) {
Write-Host "Alice before send: top=$($alice.TopMessageId) read=$($alice.ReadInboxMaxId) unread=$($alice.UnreadCount)"
}
Write-Host "Channel pts before send: $pts"
$state = [pscustomobject]@{
ChannelId = $ChannelId
AndroidUserId = $AndroidUserId
BobUserId = $BobUserId
BaselineTopMessageId = if ($bob) { $bob.TopMessageId } else { 0 }
BaselineBobReadInboxMaxId = if ($bob) { $bob.ReadInboxMaxId } else { 0 }
BaselineBobUnreadCount = if ($bob) { $bob.UnreadCount } else { 0 }
BaselineChannelPts = $pts
BaselineLogLineCount = $lineCount
ServerLogPath = $ServerLogPath
CreatedAt = (Get-Date -Format o)
PhotoFixture = $PhotoFixture
VideoFixture = $VideoFixture
DocumentFixture = $DocumentFixture
}
Save-State $state
}
function Run-AfterSend {
Write-Step "AfterSend assertions"
$state = Load-State
$bob = Get-DialogState $BobUserId
$messages = @(Get-NewMessages ([int]$state.BaselineTopMessageId))
$events = Get-NewEventSummary ([int]$state.BaselineChannelPts)
foreach ($message in $messages) {
Write-Host ("new message id={0} sender={1} kind={2} mime={3} file={4}" -f $message.Id, $message.SenderUserId, $message.Kind, $message.MimeType, $message.FileName)
}
if ($bob) {
Write-Host "Bob after send: top=$($bob.TopMessageId) read=$($bob.ReadInboxMaxId) unread=$($bob.UnreadCount)"
}
Write-Host "new channel events after baseline: count=$($events.Count) max_pts=$($events.MaxPts)"
Assert-Check ($messages.Count -ge $ExpectedNewMessages) "at least $ExpectedNewMessages new channel messages were written"
Assert-Check (@($messages | Where-Object { $_.SenderUserId -eq $AndroidUserId }).Count -ge $ExpectedNewMessages) "new channel messages are from Android/Alice"
Assert-Check (@($messages | Where-Object { $_.Kind -eq "photo" }).Count -ge 1) "new messages include uploaded photo"
Assert-Check (@($messages | Where-Object { $_.Kind -eq "document" }).Count -ge 1) "new messages include uploaded document"
if (-not $AllowMissingVideo) {
Assert-Check (@($messages | Where-Object { $_.MimeType -eq "video/mp4" }).Count -ge 1) "new messages include video/mp4 document"
}
if (-not $AllowMissingGenericDocument) {
Assert-Check (@($messages | Where-Object { $_.Kind -eq "document" -and $_.MimeType -ne "video/mp4" }).Count -ge 1) "new messages include a generic non-video document"
}
if ($bob) {
Assert-Check ($bob.TopMessageId -gt [int]$state.BaselineTopMessageId) "Bob top_message_id advanced while offline"
Assert-Check ($bob.ReadInboxMaxId -eq [int]$state.BaselineBobReadInboxMaxId) "Bob read_inbox_max_id did not advance before opening TDesktop"
Assert-Check ($bob.UnreadCount -ge $ExpectedNewMessages) "Bob unread_count reflects offline messages"
}
Assert-Check ($events.Count -ge $ExpectedNewMessages) "durable channel_update_events exist for new messages"
}
function Run-AfterBobOpen {
Write-Step "AfterBobOpen assertions"
$state = Load-State
$bob = Get-DialogState $BobUserId
if ($bob) {
Write-Host "Bob after open: top=$($bob.TopMessageId) read=$($bob.ReadInboxMaxId) unread=$($bob.UnreadCount)"
Assert-Check ($bob.ReadInboxMaxId -ge $bob.TopMessageId) "Bob read_inbox_max_id catches up to current top"
Assert-Check ($bob.UnreadCount -eq 0) "Bob unread_count is cleared after opening the channel"
}
$lines = @(Get-LogLinesSince ([int]$state.BaselineLogLineCount))
$bad = @($lines | Where-Object { $_ -match "Unhandled RPC|NOT_IMPLEMENTED|bad_msg" })
Assert-Check ($bad.Count -eq 0) "server log has no new Unhandled RPC / NOT_IMPLEMENTED / bad_msg entries"
$expectedTDesktop = @(
"messages.getHistory",
"messages.getPeerDialogs",
"upload.getFile",
"channels.readHistory",
"updates.getChannelDifference"
)
foreach ($method in $expectedTDesktop) {
$hits = @($lines | Where-Object { $_ -like "*$method*" -and $_ -like '*client_type": "tdesktop"*' })
Assert-Check ($hits.Count -ge 1) "TDesktop issued $method after Bob opened the channel"
}
}
function Finish-Run {
if ($Failures.Count -gt 0) {
Write-Host ""
Write-Host "Validation failed:"
foreach ($failure in $Failures) {
Write-Host " - $failure"
}
exit 1
}
Write-Host ""
Write-Host "Validation phase '$Phase' passed."
}
switch ($Phase) {
"Preflight" {
Run-Preflight
}
"Prepare" {
Run-Prepare
}
"BeforeSend" {
Run-BeforeSend
}
"AfterSend" {
Run-AfterSend
}
"AfterBobOpen" {
Run-AfterBobOpen
}
"All" {
Run-Prepare
Run-BeforeSend
Read-Host "Close DebugBob/TDesktop if needed, send photo/video/document from Android, then press Enter"
Run-AfterSend
Read-Host "Start DebugBob/TDesktop, open the channel, confirm media renders, then press Enter"
Run-AfterBobOpen
}
}
Finish-Run

View file

@ -0,0 +1,586 @@
<#
.SYNOPSIS
Semi-automates Android video upload validation against local telesrv.
.DESCRIPTION
The script handles the stable parts of the Android upload regression loop:
preflight checks, optional video fixture generation/push, baseline snapshots,
server log scanning, PostgreSQL media/message assertions, upload_parts cleanup,
and local blob existence checks.
It intentionally does not drive the Android media picker. Send the prepared
video manually from Android/Alice, then run -Phase AfterSend.
#>
[CmdletBinding()]
param(
[ValidateSet("Preflight", "Prepare", "BeforeSend", "AfterSend", "All")]
[string]$Phase = "Preflight",
[long]$SenderUserId = 1780269504,
[long]$RecipientUserId = 1780269505,
[string]$AndroidPackage = "org.telegram.messenger.beta",
[string]$DeviceSerial,
[string]$PostgresContainer = "telesrv-postgres",
[string]$Database = "telesrv",
[string]$DbUser = "telesrv",
[string]$ServerLogPath,
[string]$StatePath,
[string]$FixtureDir,
[string]$VideoFixture,
[string]$BlobDir,
[string]$RemoteMovieDir = "/sdcard/Movies/telesrv",
[switch]$SkipAdb,
[switch]$AllowMissingThumb
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
if (-not $ServerLogPath) {
$latestLog = Get-ChildItem (Join-Path $RepoRoot "logs") -Filter "telesrv-*.err.log" -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if ($latestLog) {
$ServerLogPath = $latestLog.FullName
} else {
$ServerLogPath = Join-Path $RepoRoot "logs\telesrv.err.log"
}
}
if (-not $StatePath) {
$StatePath = Join-Path $RepoRoot "logs\android-video-upload-state.json"
}
if (-not $FixtureDir) {
$FixtureDir = Join-Path $RepoRoot "logs\media-fixtures"
}
if (-not $BlobDir) {
$BlobDir = Join-Path $RepoRoot "data\blobs"
}
$Failures = New-Object System.Collections.Generic.List[string]
function Write-Step {
param([string]$Message)
Write-Host ""
Write-Host "== $Message =="
}
function Write-Ok {
param([string]$Message)
Write-Host "[ok] $Message"
}
function Write-Warn {
param([string]$Message)
Write-Host "[warn] $Message"
}
function Add-Failure {
param([string]$Message)
$script:Failures.Add($Message) | Out-Null
Write-Host "[fail] $Message"
}
function Assert-Check {
param(
[bool]$Condition,
[string]$Message
)
if ($Condition) {
Write-Ok $Message
} else {
Add-Failure $Message
}
}
function Invoke-External {
param(
[string]$FilePath,
[string[]]$Arguments,
[switch]$AllowFailure
)
$oldErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
$output = & $FilePath @Arguments 2>&1
$exitCode = $LASTEXITCODE
} finally {
$ErrorActionPreference = $oldErrorActionPreference
}
$text = ($output | ForEach-Object { $_.ToString() }) -join "`n"
if ($exitCode -ne 0 -and -not $AllowFailure) {
throw "$FilePath $($Arguments -join ' ') failed with exit code ${exitCode}:`n$text"
}
[pscustomobject]@{
ExitCode = $exitCode
Output = $text
}
}
function Assert-Command {
param([string]$Name)
$cmd = Get-Command $Name -ErrorAction SilentlyContinue
Assert-Check ([bool]$cmd) "$Name is available"
return [bool]$cmd
}
function Get-AdbArgs {
param([string[]]$Arguments)
if ($DeviceSerial) {
return @("-s", $DeviceSerial) + $Arguments
}
return $Arguments
}
function Invoke-Adb {
param([string[]]$Arguments, [switch]$AllowFailure)
Invoke-External "adb" (Get-AdbArgs $Arguments) -AllowFailure:$AllowFailure
}
function Invoke-PsqlRows {
param([string]$Sql)
$args = @(
"exec", $PostgresContainer,
"psql", "-U", $DbUser, "-d", $Database,
"-v", "ON_ERROR_STOP=1",
"-At", "-F", "|",
"-c", $Sql
)
$result = Invoke-External "docker" $args
if ([string]::IsNullOrWhiteSpace($result.Output)) {
return @()
}
return @($result.Output -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
}
function Get-LogLineCount {
if (-not (Test-Path -LiteralPath $ServerLogPath)) {
return 0
}
return @(Read-SharedLogLines).Count
}
function Get-LogLinesSince {
param([int]$Skip)
if (-not (Test-Path -LiteralPath $ServerLogPath)) {
Add-Failure "server log exists at $ServerLogPath"
return @()
}
return @(Read-SharedLogLines | Select-Object -Skip $Skip)
}
function Read-SharedLogLines {
$stream = [System.IO.File]::Open($ServerLogPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
try {
$reader = New-Object System.IO.StreamReader($stream)
try {
$lines = New-Object System.Collections.Generic.List[string]
while (-not $reader.EndOfStream) {
$lines.Add($reader.ReadLine()) | Out-Null
}
return $lines
} finally {
$reader.Dispose()
}
} finally {
$stream.Dispose()
}
}
function Get-PrivateMessageMaxId {
$rows = @(Invoke-PsqlRows @"
SELECT COALESCE(MAX(id), 0)
FROM private_messages
WHERE (sender_user_id = $SenderUserId AND recipient_user_id = $RecipientUserId)
OR (sender_user_id = $RecipientUserId AND recipient_user_id = $SenderUserId);
"@)
if ($rows.Count -eq 0) {
return 0
}
return [long]$rows[0]
}
function Get-UploadPartUsage {
$rows = @(Invoke-PsqlRows @"
SELECT COUNT(*), COALESCE(SUM(size), 0)
FROM upload_parts
WHERE owner_user_id = $SenderUserId;
"@)
if ($rows.Count -eq 0) {
return [pscustomobject]@{ Parts = 0; Bytes = 0L }
}
$parts = $rows[0] -split "\|"
return [pscustomobject]@{
Parts = [int]$parts[0]
Bytes = [long]$parts[1]
}
}
function Get-NewVideoMessages {
param([long]$AfterMessageId)
$rows = @(Invoke-PsqlRows @"
SELECT
id,
sender_user_id,
recipient_user_id,
COALESCE(media->>'kind', ''),
COALESCE(media->>'video', 'false'),
COALESCE(media->'document'->>'id', '0'),
COALESCE(media->'document'->>'mime_type', ''),
COALESCE(media->'document'->>'size', '0'),
COALESCE(jsonb_array_length(COALESCE(media->'document'->'thumbs', '[]'::jsonb)), 0)
FROM private_messages
WHERE id > $AfterMessageId
AND sender_user_id = $SenderUserId
AND recipient_user_id = $RecipientUserId
ORDER BY id;
"@)
$items = @()
foreach ($row in $rows) {
$parts = $row -split "\|", 9
$items += [pscustomobject]@{
MessageId = [long]$parts[0]
SenderUserId = [long]$parts[1]
RecipientUserId = [long]$parts[2]
Kind = $parts[3]
Video = $parts[4]
DocumentId = [long]$parts[5]
MimeType = $parts[6]
Size = [long]$parts[7]
ThumbCount = [int]$parts[8]
}
}
return $items
}
function Get-DocumentRows {
param([long[]]$DocumentIds)
if ($DocumentIds.Count -eq 0) {
return @()
}
$ids = ($DocumentIds | ForEach-Object { $_.ToString() }) -join ","
$rows = @(Invoke-PsqlRows @"
SELECT id, mime_type, size, jsonb_array_length(COALESCE(thumbs, '[]'::jsonb))
FROM documents
WHERE id IN ($ids)
ORDER BY id;
"@)
$items = @()
foreach ($row in $rows) {
$parts = $row -split "\|", 4
$items += [pscustomobject]@{
DocumentId = [long]$parts[0]
MimeType = $parts[1]
Size = [long]$parts[2]
ThumbCount = [int]$parts[3]
}
}
return $items
}
function Get-FileBlobRows {
param([long[]]$DocumentIds)
if ($DocumentIds.Count -eq 0) {
return @()
}
$keys = @()
foreach ($docId in $DocumentIds) {
$keys += "doc:$docId"
$keys += "doc:${docId}:m"
}
$quoted = ($keys | ForEach-Object { "'" + $_.Replace("'", "''") + "'" }) -join ","
$rows = @(Invoke-PsqlRows @"
SELECT location_key, backend, object_key, size, mime_type
FROM file_blobs
WHERE location_key IN ($quoted)
ORDER BY location_key;
"@)
$items = @()
foreach ($row in $rows) {
$parts = $row -split "\|", 5
$items += [pscustomobject]@{
LocationKey = $parts[0]
Backend = $parts[1]
ObjectKey = $parts[2]
Size = [long]$parts[3]
MimeType = $parts[4]
}
}
return $items
}
function Wait-FileBlobRows {
param(
[long[]]$DocumentIds,
[int]$TimeoutSeconds = 10
)
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
$last = @()
while ($true) {
$last = @(Get-FileBlobRows $DocumentIds)
$hasBody = @($last | Where-Object { $_.LocationKey -like "doc:*" -and $_.LocationKey -notlike "*:m" -and $_.Size -gt 0 }).Count -ge 1
$hasThumb = $AllowMissingThumb -or (@($last | Where-Object { $_.LocationKey -like "doc:*:m" -and $_.Size -gt 0 }).Count -ge 1)
if ($hasBody -and $hasThumb) {
return $last
}
if ((Get-Date) -ge $deadline) {
return $last
}
Start-Sleep -Milliseconds 500
}
}
function Get-EffectiveLogSkip {
param([pscustomobject]$State)
$stateLog = ""
if ($State.PSObject.Properties.Name -contains "ServerLogPath") {
$stateLog = [string]$State.ServerLogPath
}
if ($stateLog) {
$stateFull = [System.IO.Path]::GetFullPath($stateLog)
$currentFull = [System.IO.Path]::GetFullPath($ServerLogPath)
if ($stateFull.Equals($currentFull, [System.StringComparison]::OrdinalIgnoreCase)) {
return [int]$State.BaselineLogLineCount
}
Write-Warn "server log changed since baseline; scanning current log from the beginning"
return 0
}
return [int]$State.BaselineLogLineCount
}
function Get-BlobFilePath {
param([string]$ObjectKey)
if ($ObjectKey.Length -lt 4) {
return Join-Path $BlobDir $ObjectKey
}
return Join-Path (Join-Path (Join-Path $BlobDir $ObjectKey.Substring(0, 2)) $ObjectKey.Substring(2, 2)) $ObjectKey
}
function Save-State {
param([pscustomobject]$State)
$dir = Split-Path -Parent $StatePath
if ($dir) {
New-Item -ItemType Directory -Force -Path $dir | Out-Null
}
$State | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $StatePath -Encoding UTF8
Write-Ok "state saved to $StatePath"
}
function Load-State {
if (-not (Test-Path -LiteralPath $StatePath)) {
throw "state file not found: $StatePath. Run -Phase BeforeSend first."
}
Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json
}
function New-VideoFixture {
param([string]$Path)
$ffmpeg = Get-Command ffmpeg -ErrorAction SilentlyContinue
if (-not $ffmpeg) {
Add-Failure "ffmpeg is available or -VideoFixture is supplied"
return
}
Invoke-External "ffmpeg" @(
"-y",
"-f", "lavfi",
"-i", "testsrc=size=568x1280:rate=30",
"-f", "lavfi",
"-i", "sine=frequency=880:sample_rate=44100",
"-t", "3",
"-pix_fmt", "yuv420p",
"-c:v", "libx264",
"-c:a", "aac",
"-shortest",
$Path
) | Out-Null
}
function Ensure-VideoFixture {
New-Item -ItemType Directory -Force -Path $FixtureDir | Out-Null
if (-not $VideoFixture) {
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
$script:VideoFixture = Join-Path $FixtureDir "telesrv-android-video-upload-$stamp.mp4"
New-VideoFixture $script:VideoFixture
}
Assert-Check ($VideoFixture -and (Test-Path -LiteralPath $VideoFixture)) "video fixture exists: $VideoFixture"
}
function Push-VideoFixture {
if ($SkipAdb) {
Write-Warn "adb push skipped"
return
}
Invoke-Adb @("shell", "mkdir", "-p", $RemoteMovieDir) | Out-Null
Invoke-Adb @("push", $VideoFixture, "$RemoteMovieDir/") | Out-Null
$leaf = Split-Path -Leaf $VideoFixture
Invoke-Adb @("shell", "am", "broadcast", "-a", "android.intent.action.MEDIA_SCANNER_SCAN_FILE", "-d", "file://$RemoteMovieDir/$leaf") | Out-Null
Write-Ok "video pushed to Android: $RemoteMovieDir/$leaf"
}
function Run-Preflight {
Write-Step "Preflight"
if (-not $SkipAdb) {
if (Assert-Command "adb") {
$devices = Invoke-Adb @("devices")
$deviceLines = @($devices.Output -split "`r?`n" | Where-Object { $_ -match "\tdevice$" })
Assert-Check ($deviceLines.Count -ge 1) "adb has at least one connected device"
Assert-Check (($deviceLines.Count -eq 1) -or [bool]$DeviceSerial) "adb selects a single device or -DeviceSerial is set"
if (($deviceLines.Count -eq 1) -or [bool]$DeviceSerial) {
$pkg = Invoke-Adb @("shell", "dumpsys", "package", $AndroidPackage)
Assert-Check ($pkg.Output -match "versionName=") "Android package $AndroidPackage is installed"
$model = (Invoke-Adb @("shell", "getprop", "ro.product.model")).Output.Trim()
$sdk = (Invoke-Adb @("shell", "getprop", "ro.build.version.sdk")).Output.Trim()
Write-Host "Android device: model=$model sdk=$sdk"
}
}
} else {
Write-Warn "adb checks skipped"
}
if (Assert-Command "docker") {
Invoke-PsqlRows "SELECT 1;" | Out-Null
Write-Ok "PostgreSQL is reachable through docker container $PostgresContainer"
}
Assert-Check (Test-Path -LiteralPath $ServerLogPath) "server log exists: $ServerLogPath"
Write-Host "server log: $ServerLogPath"
}
function Run-Prepare {
Write-Step "Prepare video fixture"
Run-Preflight
Ensure-VideoFixture
Push-VideoFixture
Write-Host "Manual step: send this video from Android/Alice to Bob:"
Write-Host " $VideoFixture"
}
function Run-BeforeSend {
Write-Step "BeforeSend snapshot"
$maxMessageId = Get-PrivateMessageMaxId
$usage = Get-UploadPartUsage
$lineCount = Get-LogLineCount
Write-Host "private_messages max id before send: $maxMessageId"
Write-Host "upload_parts before send: parts=$($usage.Parts) bytes=$($usage.Bytes)"
$state = [pscustomobject]@{
SenderUserId = $SenderUserId
RecipientUserId = $RecipientUserId
BaselinePrivateMessageId = $maxMessageId
BaselineUploadParts = $usage.Parts
BaselineUploadPartBytes = $usage.Bytes
BaselineLogLineCount = $lineCount
ServerLogPath = $ServerLogPath
BlobDir = $BlobDir
CreatedAt = (Get-Date -Format o)
VideoFixture = $VideoFixture
}
Save-State $state
}
function Run-AfterSend {
Write-Step "AfterSend assertions"
$state = Load-State
$messages = @(Get-NewVideoMessages ([long]$state.BaselinePrivateMessageId))
foreach ($message in $messages) {
Write-Host ("new private message id={0} kind={1} video={2} doc={3} mime={4} size={5} thumbs={6}" -f $message.MessageId, $message.Kind, $message.Video, $message.DocumentId, $message.MimeType, $message.Size, $message.ThumbCount)
}
$videos = @($messages | Where-Object {
$_.Kind -eq "document" -and $_.Video -eq "true" -and $_.MimeType -eq "video/mp4" -and $_.DocumentId -gt 0
})
Assert-Check ($videos.Count -ge 1) "new private message includes uploaded video/mp4 document"
$docIds = @($videos | Select-Object -ExpandProperty DocumentId -Unique)
$documents = @(Get-DocumentRows $docIds)
$blobs = @(Wait-FileBlobRows $docIds)
foreach ($doc in $documents) {
Write-Host ("document id={0} mime={1} size={2} thumbs={3}" -f $doc.DocumentId, $doc.MimeType, $doc.Size, $doc.ThumbCount)
}
foreach ($blob in $blobs) {
Write-Host ("blob key={0} backend={1} object={2} size={3} mime={4}" -f $blob.LocationKey, $blob.Backend, $blob.ObjectKey, $blob.Size, $blob.MimeType)
}
Assert-Check ($documents.Count -ge $docIds.Count) "documents rows exist for uploaded video"
Assert-Check (@($documents | Where-Object { $_.MimeType -eq "video/mp4" -and $_.Size -gt 0 }).Count -ge 1) "uploaded video document metadata is persisted"
if (-not $AllowMissingThumb) {
Assert-Check (@($documents | Where-Object { $_.ThumbCount -gt 0 }).Count -ge 1) "uploaded video document has thumbnail metadata"
}
Assert-Check (@($blobs | Where-Object { $_.LocationKey -like "doc:*" -and $_.LocationKey -notlike "*:m" -and $_.MimeType -eq "video/mp4" -and $_.Size -gt 0 }).Count -ge 1) "video body file_blobs row exists"
if (-not $AllowMissingThumb) {
Assert-Check (@($blobs | Where-Object { $_.LocationKey -like "doc:*:m" -and $_.Size -gt 0 }).Count -ge 1) "video thumbnail file_blobs row exists"
}
foreach ($blob in $blobs) {
if ($blob.Backend -eq "localfs" -and $blob.ObjectKey) {
$path = Get-BlobFilePath $blob.ObjectKey
Assert-Check (Test-Path -LiteralPath $path) "localfs blob exists: $($blob.LocationKey)"
}
}
$usage = Get-UploadPartUsage
Write-Host "upload_parts after send: parts=$($usage.Parts) bytes=$($usage.Bytes)"
if ([int]$state.BaselineUploadParts -eq 0) {
Assert-Check ($usage.Parts -eq 0) "upload_parts cleaned after successful upload"
} else {
Assert-Check ($usage.Parts -le [int]$state.BaselineUploadParts) "upload_parts did not grow after successful upload"
}
$lines = @(Get-LogLinesSince (Get-EffectiveLogSkip $state))
$savePartHits = @($lines | Where-Object {
($_ -like "*upload.saveFilePart*" -or $_ -like "*upload.saveBigFilePart*") -and $_ -like '*client_type": "android"*'
})
$sendMediaHits = @($lines | Where-Object {
$_ -like "*messages.sendMedia*" -and $_ -like '*client_type": "android"*'
})
$bad = @($lines | Where-Object {
$_ -cmatch "INTERNAL_SERVER_ERROR|rpc error|Unhandled RPC|NOT_IMPLEMENTED|bad_msg|panic|\tERROR\t"
})
Assert-Check ($savePartHits.Count -ge 1) "server log has Android upload.saveFilePart/saveBigFilePart"
Assert-Check ($sendMediaHits.Count -ge 1) "server log has Android messages.sendMedia"
Assert-Check ($bad.Count -eq 0) "server log has no upload-era internal errors or unhandled RPCs"
if (-not $SkipAdb) {
$logcat = Invoke-Adb @("logcat", "-d", "-t", "1200") -AllowFailure
if ($logcat.ExitCode -eq 0) {
$androidErrors = @($logcat.Output -split "`r?`n" | Where-Object {
$_ -match "INTERNAL_SERVER_ERROR|rpc error 500|saveFilePart|saveBigFilePart|FileUploadOperation"
})
if ($androidErrors.Count -gt 0) {
Write-Host "Recent Android upload log lines:"
$androidErrors | Select-Object -Last 40 | ForEach-Object { Write-Host $_ }
}
$fatalAndroidErrors = @($androidErrors | Where-Object { $_ -match "INTERNAL_SERVER_ERROR|rpc error 500" })
Assert-Check ($fatalAndroidErrors.Count -eq 0) "recent Android logcat has no upload 500"
} else {
Write-Warn "adb logcat scan failed: $($logcat.Output)"
}
}
}
function Finish-Run {
if ($Failures.Count -gt 0) {
Write-Host ""
Write-Host "Validation failed:"
foreach ($failure in $Failures) {
Write-Host " - $failure"
}
exit 1
}
Write-Host ""
Write-Host "Validation passed."
}
switch ($Phase) {
"Preflight" { Run-Preflight }
"Prepare" { Run-Prepare }
"BeforeSend" { Run-BeforeSend }
"AfterSend" { Run-AfterSend }
"All" {
Run-Prepare
Run-BeforeSend
Read-Host "Send the prepared video from Android/Alice to Bob, then press Enter"
Run-AfterSend
}
}
Finish-Run