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

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

@ -66,18 +66,19 @@ func tgPhoneCallForViewer(call domain.PhoneCall, viewerID int64) tg.PhoneCallCla
gaOrB = call.GB // 主叫视角:拿被叫的 g_b
}
out := &tg.PhoneCall{
P2PAllowed: call.P2PAllowed,
Video: call.Video,
ID: call.ID,
AccessHash: call.AccessHash,
Date: call.Date,
AdminID: call.AdminID,
ParticipantID: call.ParticipantID,
GAOrB: gaOrB,
KeyFingerprint: call.KeyFingerprint,
Protocol: tgPhoneCallProtocol(call.Protocol),
Connections: tgPhoneConnections(call.Connections),
StartDate: call.StartDate,
P2PAllowed: call.P2PAllowed,
Video: call.Video,
ConferenceSupported: true,
ID: call.ID,
AccessHash: call.AccessHash,
Date: call.Date,
AdminID: call.AdminID,
ParticipantID: call.ParticipantID,
GAOrB: gaOrB,
KeyFingerprint: call.KeyFingerprint,
Protocol: tgPhoneCallProtocol(call.Protocol),
Connections: tgPhoneConnections(call.Connections),
StartDate: call.StartDate,
}
return out
case domain.PhoneCallStateDiscarded:
@ -132,7 +133,7 @@ func tgPhoneCallDiscarded(call domain.PhoneCall) *tg.PhoneCallDiscarded {
Video: call.Video,
ID: call.ID,
}
if reason := tgPhoneCallDiscardReason(call.DiscardReason); reason != nil {
if reason := tgPhoneCallDiscardReasonWithSlug(call.DiscardReason, call.DiscardReasonSlug); reason != nil {
out.SetReason(reason)
}
if call.Duration > 0 {
@ -155,6 +156,10 @@ func tgPhoneCallStopRinging(call domain.PhoneCall) *tg.PhoneCallDiscarded {
}
func tgPhoneCallDiscardReason(r domain.PhoneCallDiscardReason) tg.PhoneCallDiscardReasonClass {
return tgPhoneCallDiscardReasonWithSlug(r, "")
}
func tgPhoneCallDiscardReasonWithSlug(r domain.PhoneCallDiscardReason, slug string) tg.PhoneCallDiscardReasonClass {
switch r {
case domain.PhoneCallDiscardReasonMissed:
return &tg.PhoneCallDiscardReasonMissed{}
@ -165,7 +170,7 @@ func tgPhoneCallDiscardReason(r domain.PhoneCallDiscardReason) tg.PhoneCallDisca
case domain.PhoneCallDiscardReasonBusy:
return &tg.PhoneCallDiscardReasonBusy{}
case domain.PhoneCallDiscardReasonMigrateConference:
return &tg.PhoneCallDiscardReasonMigrateConferenceCall{}
return &tg.PhoneCallDiscardReasonMigrateConferenceCall{Slug: slug}
default:
return nil
}
@ -188,3 +193,10 @@ func phoneCallDiscardReasonFromTL(r tg.PhoneCallDiscardReasonClass) domain.Phone
return domain.PhoneCallDiscardReasonHangup
}
}
func phoneCallDiscardReasonSlugFromTL(r tg.PhoneCallDiscardReasonClass) string {
if migrate, ok := r.(*tg.PhoneCallDiscardReasonMigrateConferenceCall); ok {
return migrate.Slug
}
return ""
}

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 {
return nil, messageIDInvalidErr()
}
if !forwardMessageIDsValid(req.ID, req.RandomID) {
return nil, messageIDInvalidErr()
}
if err := r.checkSendRateLimit(ctx, userID, len(req.ID)); err != nil {
return nil, err
}
if req.ScheduleDate != 0 && !scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix())) {
return r.scheduleForwardMessages(ctx, userID, fromPeer, toPeer, req, replyTo, sendAs)
return r.scheduleForwardMessages(ctx, userID, fromPeer, toPeer, req, replyTo, sendAs, preloadedSources)
}
if toPeer.Type == domain.PeerTypeChannel {
if r.deps.Channels == nil {
return nil, peerIDInvalidErr()
}
sources, err := r.forwardSources(ctx, userID, fromPeer, req.ID)
sources, err := r.forwardSourcesForRequest(ctx, userID, fromPeer, req.ID, preloadedSources)
if err != nil {
return nil, messageForwardErr(err)
}
@ -152,7 +153,7 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages
// 私聊源与频道源统一经 forwardSources 取源:首次生成的 forward header 在
// forwardSources 内已按原作者 PrivacyKeyForwards 降级(不允许链接回账号时仅保
// 留 from_name避免私聊→私聊路径泄漏原作者可点击账号media 也随 source 透传。
sources, err := r.forwardSources(ctx, userID, fromPeer, req.ID)
sources, err := r.forwardSourcesForRequest(ctx, userID, fromPeer, req.ID, preloadedSources)
if err != nil {
return nil, messageForwardErr(err)
}
@ -200,6 +201,110 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages
return nil, peerIDInvalidErr()
}
func normalizeForwardMessageVectors(ids []int, randomIDs []int64) ([]int, []int64, bool) {
if len(ids) == 0 || len(randomIDs) == 0 {
return nil, nil, false
}
if len(ids) == len(randomIDs) {
return ids, randomIDs, true
}
if len(ids) < len(randomIDs) {
return nil, nil, false
}
compact := make([]int, 0, len(randomIDs))
runLength := 0
for i, id := range ids {
if i == 0 || id != ids[i-1] {
compact = append(compact, id)
runLength = 1
continue
}
runLength++
if runLength > 2 {
return nil, nil, false
}
}
if len(compact) != len(randomIDs) {
return nil, nil, false
}
return compact, randomIDs, true
}
func (r *Router) forwardFromPeerAndSources(ctx context.Context, userID int64, input tg.InputPeerClass, ids []int, randomIDs []int64) (domain.Peer, []forwardSource, error) {
if forwardFromPeerIsEmpty(input) {
if !forwardMessageIDsValid(ids, randomIDs) {
return domain.Peer{}, nil, messageIDInvalidErr()
}
fromPeer, sources, err := r.forwardSourcesFromEmptyPeer(ctx, userID, ids)
if err != nil {
return domain.Peer{}, nil, messageForwardErr(err)
}
return fromPeer, sources, nil
}
fromPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input)
return fromPeer, nil, err
}
func forwardMessageIDsValid(ids []int, randomIDs []int64) bool {
if len(ids) == 0 || len(ids) != len(randomIDs) {
return false
}
for i, id := range ids {
if id <= 0 || id > domain.MaxMessageBoxID || randomIDs[i] == 0 {
return false
}
}
return true
}
func forwardFromPeerIsEmpty(peer tg.InputPeerClass) bool {
if inputPeerClassNil(peer) {
return false
}
_, ok := peer.(*tg.InputPeerEmpty)
return ok
}
func (r *Router) forwardSourcesFromEmptyPeer(ctx context.Context, userID int64, ids []int) (domain.Peer, []forwardSource, error) {
if r.deps.Messages == nil {
return domain.Peer{}, nil, domain.ErrMessageIDInvalid
}
list, err := r.deps.Messages.GetMessages(ctx, userID, ids)
if err != nil {
return domain.Peer{}, nil, domain.ErrMessageIDInvalid
}
var fromPeer domain.Peer
byID := make(map[int]domain.Message, len(list.Messages))
for _, msg := range list.Messages {
byID[msg.ID] = msg
}
for _, id := range ids {
msg, ok := byID[id]
if !ok || msg.Peer.Type != domain.PeerTypeUser || msg.Peer.ID == 0 {
return domain.Peer{}, nil, domain.ErrMessageIDInvalid
}
if fromPeer.ID == 0 {
fromPeer = msg.Peer
continue
}
if msg.Peer != fromPeer {
return domain.Peer{}, nil, domain.ErrMessageIDInvalid
}
}
sources, err := r.forwardSourcesFromPrivateMessages(ctx, userID, fromPeer, ids, list.Messages)
if err != nil {
return domain.Peer{}, nil, err
}
return fromPeer, sources, nil
}
func (r *Router) forwardSourcesForRequest(ctx context.Context, userID int64, fromPeer domain.Peer, ids []int, preloaded []forwardSource) ([]forwardSource, error) {
if preloaded != nil {
return preloaded, nil
}
return r.forwardSources(ctx, userID, fromPeer, ids)
}
func mergeForwardTopMsgID(toPeer domain.Peer, replyTo *domain.MessageReply, topMsgID int, topMsgIDSet bool) (*domain.MessageReply, error) {
if !topMsgIDSet || topMsgID == 0 {
return replyTo, nil
@ -241,36 +346,11 @@ func (r *Router) forwardSources(ctx context.Context, userID int64, fromPeer doma
if err != nil {
return nil, domain.ErrMessageIDInvalid
}
byID := make(map[int]domain.Message, len(list.Messages))
for _, msg := range list.Messages {
byID[msg.ID] = msg
}
for _, id := range ids {
msg, ok := byID[id]
if !ok {
return nil, domain.ErrMessageIDInvalid
}
if msg.Peer != fromPeer {
return nil, domain.ErrMessageIDInvalid
}
if msg.NoForwards {
return nil, domain.ErrChatForwardsRestricted
}
forward := cloneDomainMessageForward(msg.Forward)
if forward == nil {
forward = &domain.MessageForward{From: msg.From, Date: msg.Date}
r.applyForwardAuthorPrivacy(ctx, userID, forward)
}
out = append(out, forwardSource{
body: msg.Body,
entities: append([]domain.MessageEntity(nil),
msg.Entities...),
media: msg.Media,
forward: forward,
from: msg.From,
date: msg.Date,
})
sources, err := r.forwardSourcesFromPrivateMessages(ctx, userID, fromPeer, ids, list.Messages)
if err != nil {
return nil, err
}
out = append(out, sources...)
case domain.PeerTypeChannel:
if r.deps.Channels == nil {
return nil, domain.ErrMessageIDInvalid
@ -325,6 +405,44 @@ func (r *Router) forwardSources(ctx context.Context, userID int64, fromPeer doma
return out, nil
}
func (r *Router) forwardSourcesFromPrivateMessages(ctx context.Context, userID int64, fromPeer domain.Peer, ids []int, messages []domain.Message) ([]forwardSource, error) {
if fromPeer.Type != domain.PeerTypeUser || fromPeer.ID == 0 {
return nil, domain.ErrMessageIDInvalid
}
byID := make(map[int]domain.Message, len(messages))
for _, msg := range messages {
byID[msg.ID] = msg
}
out := make([]forwardSource, 0, len(ids))
for _, id := range ids {
msg, ok := byID[id]
if !ok {
return nil, domain.ErrMessageIDInvalid
}
if msg.Peer != fromPeer {
return nil, domain.ErrMessageIDInvalid
}
if msg.NoForwards {
return nil, domain.ErrChatForwardsRestricted
}
forward := cloneDomainMessageForward(msg.Forward)
if forward == nil {
forward = &domain.MessageForward{From: msg.From, Date: msg.Date}
r.applyForwardAuthorPrivacy(ctx, userID, forward)
}
out = append(out, forwardSource{
body: msg.Body,
entities: append([]domain.MessageEntity(nil),
msg.Entities...),
media: msg.Media,
forward: forward,
from: msg.From,
date: msg.Date,
})
}
return out, nil
}
func cloneDomainMessageForward(in *domain.MessageForward) *domain.MessageForward {
if in == nil {
return nil

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,15 +210,28 @@ func (r *Router) onPhoneDiscardCall(ctx context.Context, req *tg.PhoneDiscardCal
return nil, err
}
reason := phoneCallDiscardReasonFromTL(req.Reason)
call, already, err := r.deps.Phone.DiscardCall(ctx, userID, req.Peer.ID, req.Peer.AccessHash, reason, req.Duration)
reasonSlug := phoneCallDiscardReasonSlugFromTL(req.Reason)
if reason == domain.PhoneCallDiscardReasonMigrateConference {
if reasonSlug == "" || r.deps.GroupCalls == nil {
return nil, groupCallInvalidErr()
}
if call, found, err := r.deps.GroupCalls.GetBySlug(ctx, reasonSlug); err != nil {
return nil, internalErr()
} else if !found || !call.Conference() || !call.Active() {
return nil, groupCallInvalidErr()
}
}
call, already, err := r.deps.Phone.DiscardCallWithSlug(ctx, userID, req.Peer.ID, req.Peer.AccessHash, reason, reasonSlug, req.Duration)
if err != nil {
return nil, phoneCallErr(err)
}
if !already {
// 对端全部设备 + 发起者其它设备ctx except 排除发起设备,其结果在 RPC 响应里)。
r.pushPhoneCallDiscardedBoth(ctx, call)
// 落 messageActionPhoneCall 历史(带 pts 走 outbox双方全部设备可靠收到
r.sendPhoneCallServiceMessage(ctx, call)
if reason != domain.PhoneCallDiscardReasonMigrateConference {
// 落 messageActionPhoneCall 历史(带 pts 走 outbox双方全部设备可靠收到
r.sendPhoneCallServiceMessage(ctx, call)
}
}
// 双方同时挂断的竞态:先到者定 reason后到者拿终态快照幂等成功
return r.phoneCallUpdates(ctx, call, userID), nil

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)
if err != nil {
return nil, internalErr()
}
if !found || call.AccessHash != accessHash {
var call domain.GroupCall
var found bool
allowBySlug := false
switch v := in.(type) {
case *tg.InputGroupCall:
call, found, err = r.deps.GroupCalls.Get(ctx, v.ID)
if err != nil {
return nil, internalErr()
}
if !found || call.AccessHash != v.AccessHash {
return nil, groupCallInvalidErr()
}
case *tg.InputGroupCallSlug:
call, found, err = r.deps.GroupCalls.GetBySlug(ctx, v.Slug)
if err != nil {
return nil, internalErr()
}
if !found || !call.Conference() {
return nil, groupCallInvalidErr()
}
allowBySlug = true
case *tg.InputGroupCallInviteMessage:
call, _, found, err = r.deps.GroupCalls.GetByInviteMessage(ctx, userID, v.MsgID)
if err != nil {
return nil, internalErr()
}
if !found || !call.Conference() {
return nil, groupCallInvalidErr()
}
default:
return nil, groupCallInvalidErr()
}
if call.Conference() {
if !allowBySlug {
allowed, err := r.conferenceCallCanAccess(ctx, call.ID, userID)
if err != nil {
return nil, internalErr()
}
if !allowed {
return nil, groupCallForbiddenErr()
}
}
return &groupCallScope{userID: userID, call: call}, nil
}
if r.deps.Channels == nil {
return nil, notImplementedErr()
}
view, err := r.deps.Channels.GetChannel(ctx, userID, call.ChannelID)
if err != nil {
return nil, groupCallForbiddenErr()
@ -75,6 +116,19 @@ func (r *Router) groupCallScopeFrom(ctx context.Context, in tg.InputGroupCallCla
return &groupCallScope{userID: userID, call: call, channel: view.Channel, member: view.Self}, nil
}
func (r *Router) conferenceCallCanAccess(ctx context.Context, callID, userID int64) (bool, error) {
recipients, err := r.deps.GroupCalls.ConferenceRecipients(ctx, callID)
if err != nil {
return false, err
}
for _, id := range recipients {
if id == userID {
return true, nil
}
}
return false, nil
}
func (r *Router) onPhoneCreateGroupCall(ctx context.Context, req *tg.PhoneCreateGroupCallRequest) (tg.UpdatesClass, error) {
if req == nil {
return nil, inputRequestInvalidErr()
@ -175,6 +229,14 @@ func (r *Router) onPhoneJoinGroupCall(ctx context.Context, req *tg.PhoneJoinGrou
}
}
now := int(r.clock.Now().Unix())
var publicKey []byte
if pk, ok := req.GetPublicKey(); ok {
publicKey = append([]byte(nil), pk[:]...)
}
var joinBlock []byte
if block, ok := req.GetBlock(); ok {
joinBlock = append([]byte(nil), block...)
}
// 视频内部状态endpoint 服务端铸造join 响应 video.endpoint 与日后
// participant.video.endpoint 必须逐字节一致ssrc-groups 无论摄像头开关都
// 存档——video_stopped=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())}
callUpdate.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
if channel.ID != 0 {
callUpdate.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
}
out.Updates = append(out.Updates, callUpdate)
if hasConferenceJoinBlock {
nextOffset := conferenceJoinBlock.Offset + 1
blocks := [][]byte{conferenceJoinBlock.Block}
r.pushConferenceChainBlocks(ctx, mut.Call, conferenceJoinBlock.SubChainID, blocks, nextOffset)
out.Updates = append(out.Updates, conferenceChainBlocksUpdate(mut.Call, conferenceJoinBlock.SubChainID, blocks, nextOffset))
}
return out, nil
}
@ -253,12 +344,16 @@ func (r *Router) onPhoneLeaveGroupCall(ctx context.Context, req *tg.PhoneLeaveGr
_ = r.deps.SFU.Leave(ctx, scope.call.ID, scope.userID, sfu.EndpointMain)
}
channel := r.groupCallMutationFanout(ctx, scope.channel, mut)
return r.groupCallUpdateContainer(ctx, scope.userID, channel,
out := r.groupCallUpdateContainer(ctx, scope.userID, channel,
&tg.UpdateGroupCallParticipants{
Call: &tg.InputGroupCall{ID: mut.Call.ID, AccessHash: mut.Call.AccessHash},
Participants: tgGroupCallParticipants([]domain.GroupCallParticipant{mut.Participant}, scope.userID),
Version: mut.Call.Version,
}, []int64{scope.userID}), nil
}, []int64{scope.userID})
if mut.Call.Conference() && !mut.Call.Active() {
out.Updates = append(out.Updates, groupCallUpdateFor(domain.Channel{}, mut.Call, scope.userID, scope.userID == mut.Call.CreatorUserID))
}
return out, nil
}
func (r *Router) onPhoneDiscardGroupCall(ctx context.Context, in tg.InputGroupCallClass) (tg.UpdatesClass, error) {
@ -270,13 +365,18 @@ func (r *Router) onPhoneDiscardGroupCall(ctx context.Context, in tg.InputGroupCa
return nil, tgerr400("CHAT_ADMIN_REQUIRED")
}
now := int(r.clock.Now().Unix())
call, _, err := r.deps.GroupCalls.Discard(ctx, scope.call.ID, now)
call, activeBeforeDiscard, err := r.deps.GroupCalls.Discard(ctx, scope.call.ID, now)
if err != nil {
return nil, groupCallErr(err)
}
if r.deps.SFU != nil {
_ = r.deps.SFU.CloseRoom(ctx, call.ID)
}
if call.Conference() {
r.pushConferenceGroupCallUpdateTo(ctx, call, groupCallParticipantUserIDs(activeBeforeDiscard))
return r.groupCallUpdateContainer(ctx, scope.userID, domain.Channel{},
groupCallUpdateFor(domain.Channel{}, call, scope.userID, true), nil), nil
}
// 清 channel 关联 + ended 服务消息(带 duration
channel := scope.channel
if updated, err := r.deps.Channels.SetActiveCall(ctx, channel.ID, 0, 0, false); err == nil {
@ -328,11 +428,15 @@ func (r *Router) onPhoneGetGroupCall(ctx context.Context, req *tg.PhoneGetGroupC
for _, p := range page.Participants {
userIDs = append(userIDs, p.UserID)
}
chats := []tg.ChatClass{}
if !scope.call.Conference() {
chats = append(chats, tgChannel(scope.userID, scope.channel, &scope.member))
}
return &tg.PhoneGroupCall{
Call: tgGroupCall(scope.call, scope.userID, scope.canManage()),
Participants: tgGroupCallParticipants(page.Participants, scope.userID),
ParticipantsNextOffset: page.NextOffset,
Chats: []tg.ChatClass{tgChannel(scope.userID, scope.channel, &scope.member)},
Chats: chats,
Users: r.tgUsersForIDs(ctx, scope.userID, userIDs),
}, nil
}
@ -358,11 +462,15 @@ func (r *Router) onPhoneGetGroupParticipants(ctx context.Context, req *tg.PhoneG
userIDs = append(userIDs, p.UserID)
}
// 响应 version=当前值:客户端 version 跳号后据此重建本地状态并恢复增量应用。
chats := []tg.ChatClass{}
if !scope.call.Conference() {
chats = append(chats, tgChannel(scope.userID, scope.channel, &scope.member))
}
return &tg.PhoneGroupParticipants{
Count: page.Count,
Participants: tgGroupCallParticipants(page.Participants, scope.userID),
NextOffset: page.NextOffset,
Chats: []tg.ChatClass{tgChannel(scope.userID, scope.channel, &scope.member)},
Chats: chats,
Users: r.tgUsersForIDs(ctx, scope.userID, userIDs),
Version: page.Version,
}, nil
@ -417,6 +525,8 @@ func (r *Router) onPhoneCheckGroupCall(ctx context.Context, req *tg.PhoneCheckGr
func groupCallUpdateFor(channel domain.Channel, call domain.GroupCall, viewerUserID int64, canManage bool) *tg.UpdateGroupCall {
update := &tg.UpdateGroupCall{Call: tgGroupCall(call, viewerUserID, canManage)}
update.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
if channel.ID != 0 {
update.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
}
return update
}

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)