feat: sync recent call and channel fixes
This commit is contained in:
parent
e5e0080216
commit
866a87583e
65 changed files with 6680 additions and 229 deletions
|
|
@ -15,6 +15,17 @@ import (
|
|||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type acceptPasswordAccountService struct {
|
||||
AccountService
|
||||
}
|
||||
|
||||
func (acceptPasswordAccountService) CheckPassword(_ context.Context, _ int64, check domain.PasswordCheck) error {
|
||||
if check.Empty {
|
||||
return domain.ErrPasswordHashInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestMessagesGetFutureChatCreatorAfterLeaveAndCreatorLeaveTransfers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
|
|
@ -98,6 +109,85 @@ func TestMessagesGetFutureChatCreatorAfterLeaveAndCreatorLeaveTransfers(t *testi
|
|||
}
|
||||
}
|
||||
|
||||
func TestMessagesEditChatCreatorTransfersWithoutChannelPts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
owner, err := userStore.Create(ctx, domain.User{AccessHash: 9121, Phone: "15550009121", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
member, err := userStore.Create(ctx, domain.User{AccessHash: 9122, Phone: "15550009122", FirstName: "Member"})
|
||||
if err != nil {
|
||||
t.Fatalf("create member: %v", err)
|
||||
}
|
||||
channelStore := memory.NewChannelStore()
|
||||
channelService := appchannels.NewService(channelStore)
|
||||
r := New(Config{}, Deps{
|
||||
Account: acceptPasswordAccountService{},
|
||||
Users: appusers.NewService(userStore),
|
||||
Channels: channelService,
|
||||
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700009130, 0)})
|
||||
created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
|
||||
CreatorUserID: owner.ID,
|
||||
Title: "explicit transfer",
|
||||
Megagroup: true,
|
||||
MemberUserIDs: []int64{member.ID},
|
||||
Date: 1700009130,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
ownerCtx := WithUserID(ctx, owner.ID)
|
||||
peer := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}
|
||||
if _, err := r.onMessagesEditChatCreator(ownerCtx, &tg.MessagesEditChatCreatorRequest{
|
||||
Peer: peer,
|
||||
UserID: &tg.InputUserEmpty{},
|
||||
Password: &tg.InputCheckPasswordEmpty{},
|
||||
}); err == nil || !tgerr.Is(err, "PASSWORD_HASH_INVALID") {
|
||||
t.Fatalf("editChatCreator probe err = %v, want PASSWORD_HASH_INVALID", err)
|
||||
}
|
||||
|
||||
updatesClass, err := r.onMessagesEditChatCreator(ownerCtx, &tg.MessagesEditChatCreatorRequest{
|
||||
Peer: peer,
|
||||
UserID: &tg.InputUser{UserID: member.ID, AccessHash: member.AccessHash},
|
||||
Password: &tg.InputCheckPasswordSRP{SRPID: 1, A: []byte{1}, M1: []byte{2}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("editChatCreator transfer: %v", err)
|
||||
}
|
||||
updates := updatesClass.(*tg.Updates)
|
||||
participantUpdates := 0
|
||||
hasChannel := false
|
||||
for _, update := range updates.Updates {
|
||||
switch update.(type) {
|
||||
case *tg.UpdateChannelParticipant:
|
||||
participantUpdates++
|
||||
case *tg.UpdateChannel:
|
||||
hasChannel = true
|
||||
}
|
||||
}
|
||||
if participantUpdates != 2 || !hasChannel {
|
||||
t.Fatalf("transfer updates = %+v, want two participant updates and updateChannel", updates.Updates)
|
||||
}
|
||||
if chat, ok := updates.Chats[0].(*tg.Channel); !ok || chat.Creator || !chat.AdminRights.AddAdmins {
|
||||
t.Fatalf("owner response chat = %T %+v, want old owner as non-creator admin", updates.Chats[0], updates.Chats[0])
|
||||
}
|
||||
view, err := channelService.GetChannel(ctx, member.ID, created.Channel.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("member get channel after transfer: %v", err)
|
||||
}
|
||||
if view.Channel.CreatorUserID != member.ID || view.Self.Role != domain.ChannelRoleCreator || view.Channel.Pts != created.Channel.Pts {
|
||||
t.Fatalf("channel after transfer = %+v self=%+v, want member creator and pts unchanged %d", view.Channel, view.Self, created.Channel.Pts)
|
||||
}
|
||||
oldOwner, err := channelService.GetParticipant(ctx, member.ID, created.Channel.ID, owner.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("old owner participant after transfer: %v", err)
|
||||
}
|
||||
if oldOwner.Role != domain.ChannelRoleAdmin {
|
||||
t.Fatalf("old owner after transfer = %+v, want admin", oldOwner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesGetFutureChatCreatorAfterLeaveNoCandidate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/gotd/td/tgerr"
|
||||
"go.uber.org/zap"
|
||||
"telesrv/internal/compat/tdesktop"
|
||||
"telesrv/internal/domain"
|
||||
|
|
@ -283,6 +282,9 @@ func (r *Router) onMessagesEditChatDefaultBannedRights(ctx context.Context, req
|
|||
}
|
||||
|
||||
func (r *Router) onMessagesEditChatCreator(ctx context.Context, req *tg.MessagesEditChatCreatorRequest) (tg.UpdatesClass, error) {
|
||||
if r.deps.Channels == nil {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
if req.UserID == nil {
|
||||
return nil, peerIDInvalidErr()
|
||||
}
|
||||
|
|
@ -290,15 +292,52 @@ func (r *Router) onMessagesEditChatCreator(ctx context.Context, req *tg.Messages
|
|||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if _, err := r.channelIDFromLegacyInputPeerChecked(ctx, userID, req.Peer); err != nil {
|
||||
channelID, err := r.channelIDFromLegacyInputPeerChecked(ctx, userID, req.Peer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, found, err := r.userFromInput(ctx, userID, req.UserID); err != nil {
|
||||
if req.Password == nil {
|
||||
return nil, passwordHashInvalidErr()
|
||||
}
|
||||
if _, ok := req.UserID.(*tg.InputUserEmpty); ok {
|
||||
return nil, passwordHashInvalidErr()
|
||||
}
|
||||
if _, ok := req.Password.(*tg.InputCheckPasswordEmpty); ok {
|
||||
return nil, passwordHashInvalidErr()
|
||||
}
|
||||
target, found, err := r.userFromInput(ctx, userID, req.UserID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
} else if !found {
|
||||
}
|
||||
if !found || target.ID == 0 {
|
||||
return nil, peerIDInvalidErr()
|
||||
}
|
||||
return nil, tgerr.New(400, "PASSWORD_HASH_INVALID")
|
||||
if target.Bot {
|
||||
return nil, userIDInvalidErr()
|
||||
}
|
||||
if r.deps.Account == nil {
|
||||
return nil, passwordHashInvalidErr()
|
||||
}
|
||||
if err := r.deps.Account.CheckPassword(ctx, userID, domainPasswordCheck(req.Password)); err != nil {
|
||||
return nil, passwordErr(err)
|
||||
}
|
||||
res, err := r.deps.Channels.TransferOwnership(ctx, userID, domain.TransferChannelOwnershipRequest{
|
||||
UserID: userID,
|
||||
ChannelID: channelID,
|
||||
NewOwnerID: target.ID,
|
||||
Date: int(r.clock.Now().Unix()),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, channelTransferErr(err)
|
||||
}
|
||||
r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID)
|
||||
r.addOnlineChannelMemberships(res.Channel.ID, res.OldOwner.UserID, res.NewOwner.UserID)
|
||||
cache := newViewerPeerCache(r)
|
||||
updates := r.channelOwnershipTransferUpdatesWithPeerCache(ctx, userID, userID, res, cache)
|
||||
r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates {
|
||||
return r.channelOwnershipTransferUpdatesWithPeerCache(ctx, viewerUserID, userID, res, cache)
|
||||
})
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func (r *Router) onMessagesGetFutureChatCreatorAfterLeave(ctx context.Context, peer tg.InputPeerClass) (tg.UserClass, error) {
|
||||
|
|
|
|||
|
|
@ -716,6 +716,47 @@ func (r *Router) channelParticipantUpdatesWithPeerCache(ctx context.Context, vie
|
|||
}
|
||||
}
|
||||
|
||||
func (r *Router) channelOwnershipTransferUpdatesWithPeerCache(ctx context.Context, viewerUserID, actorUserID int64, res domain.TransferChannelOwnershipResult, cache *viewerPeerCache) *tg.Updates {
|
||||
if cache == nil {
|
||||
cache = newViewerPeerCache(r)
|
||||
}
|
||||
date := res.Date
|
||||
if date == 0 {
|
||||
date = int(r.clock.Now().Unix())
|
||||
}
|
||||
updates := make([]tg.UpdateClass, 0, len(res.Events)+1)
|
||||
userIDs := []int64{actorUserID, res.PreviousOwner.UserID, res.OldOwner.UserID, res.OldOwner.InviterUserID, res.PreviousNewOwner.UserID, res.NewOwner.UserID, res.NewOwner.InviterUserID}
|
||||
for _, event := range res.Events {
|
||||
update := tgChannelUpdate(viewerUserID, event)
|
||||
if update == nil {
|
||||
continue
|
||||
}
|
||||
updates = append(updates, update)
|
||||
userIDs = append(userIDs, event.SenderUserID, event.Previous.UserID, event.Previous.InviterUserID, event.Participant.UserID, event.Participant.InviterUserID)
|
||||
}
|
||||
updates = append(updates, &tg.UpdateChannel{ChannelID: res.Channel.ID})
|
||||
var self *domain.ChannelMember
|
||||
switch viewerUserID {
|
||||
case res.OldOwner.UserID:
|
||||
member := res.OldOwner
|
||||
self = &member
|
||||
case res.NewOwner.UserID:
|
||||
member := res.NewOwner
|
||||
self = &member
|
||||
}
|
||||
chat := tgChannelChatMin(viewerUserID, res.Channel)
|
||||
if self != nil {
|
||||
chat = tgChannelChat(viewerUserID, res.Channel, self)
|
||||
}
|
||||
return &tg.Updates{
|
||||
Updates: updates,
|
||||
Users: tgUsersForViewer(viewerUserID, cache.usersForIDs(ctx, viewerUserID, uniqueRecipientIDs(userIDs))),
|
||||
Chats: []tg.ChatClass{chat},
|
||||
Date: date,
|
||||
Seq: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func domainChannelAdminLogFilter(req *tg.ChannelsGetAdminLogRequest) domain.ChannelAdminLogFilter {
|
||||
filter, ok := req.GetEventsFilter()
|
||||
if !ok {
|
||||
|
|
@ -828,3 +869,14 @@ func channelAdminErr(err error) error {
|
|||
return channelInvalidErr(err)
|
||||
}
|
||||
}
|
||||
|
||||
func channelTransferErr(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrChannelAdminRequired):
|
||||
return tgerr400("CHAT_CREATOR_REQUIRED")
|
||||
case errors.Is(err, domain.ErrUserNotParticipant):
|
||||
return tgerr400("PARTICIPANT_MISSING")
|
||||
default:
|
||||
return channelAdminErr(err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -196,4 +196,27 @@ func TestPublicChannelPreviewRPCsAllowNonMember(t *testing.T) {
|
|||
if !ok || !peerDialogChat.Left || peerDialogChat.ID != public.Channel.ID {
|
||||
t.Fatalf("peer dialog chat = %T %+v, want left public channel", peerDialogs.Chats[0], peerDialogs.Chats[0])
|
||||
}
|
||||
|
||||
if _, err := channelService.JoinChannel(ctx, viewer.ID, public.Channel.ID, 1700010120); err != nil {
|
||||
t.Fatalf("join public channel after preview: %v", err)
|
||||
}
|
||||
var joinedPeerDialogsIn bin.Buffer
|
||||
if err := peerDialogsReq.Encode(&joinedPeerDialogsIn); err != nil {
|
||||
t.Fatalf("encode joined getPeerDialogs: %v", err)
|
||||
}
|
||||
joinedPeerDialogsEnc, err := r.Dispatch(WithUserID(ctx, viewer.ID), [8]byte{}, 0, &joinedPeerDialogsIn)
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch getPeerDialogs after join: %v", err)
|
||||
}
|
||||
joinedPeerDialogs, ok := joinedPeerDialogsEnc.(*tg.MessagesPeerDialogs)
|
||||
if !ok {
|
||||
t.Fatalf("joined getPeerDialogs response = %T, want peer dialogs", joinedPeerDialogsEnc)
|
||||
}
|
||||
if len(joinedPeerDialogs.Chats) != 1 {
|
||||
t.Fatalf("joined peer dialog chats = %d, want one channel", len(joinedPeerDialogs.Chats))
|
||||
}
|
||||
joinedChat, ok := joinedPeerDialogs.Chats[0].(*tg.Channel)
|
||||
if !ok || joinedChat.Left || joinedChat.ID != public.Channel.ID {
|
||||
t.Fatalf("joined peer dialog chat = %T %+v, want active channel with left=false", joinedPeerDialogs.Chats[0], joinedPeerDialogs.Chats[0])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -848,7 +848,6 @@ func (r *Router) onContactsSearch(ctx context.Context, req *tg.ContactsSearchReq
|
|||
if err != nil {
|
||||
return nil, channelInvalidErr(err)
|
||||
}
|
||||
res.MyChannelResults = channelRes.MyResults
|
||||
res.ChannelResults = channelRes.Results
|
||||
}
|
||||
return r.tgContactsFound(ctx, userID, r.withUserSearchPresence(res)), nil
|
||||
|
|
|
|||
|
|
@ -591,19 +591,8 @@ func TestContactsSearchFindsPublicChannels(t *testing.T) {
|
|||
if !ok {
|
||||
t.Fatalf("result type = %T, want *tg.ContactsFound", enc)
|
||||
}
|
||||
if len(box.MyResults) != 1 || len(box.Chats) != 1 {
|
||||
t.Fatalf("search result sizes = my %d chats %d, want 1/1", len(box.MyResults), len(box.Chats))
|
||||
}
|
||||
peer, ok := box.MyResults[0].(*tg.PeerChannel)
|
||||
if !ok || peer.ChannelID != public.ID {
|
||||
t.Fatalf("peer = %T %+v, want public channel", box.MyResults[0], box.MyResults[0])
|
||||
}
|
||||
chat, ok := box.Chats[0].(*tg.Channel)
|
||||
if !ok || chat.ID != public.ID || chat.Username != "cu_public_rpc" {
|
||||
t.Fatalf("chat = %T %+v, want public channel chat", box.Chats[0], box.Chats[0])
|
||||
}
|
||||
if chat.Left {
|
||||
t.Fatalf("member search chat left = true, want active member channel")
|
||||
if len(box.MyResults) != 0 || len(box.Results) != 0 || len(box.Chats) != 0 {
|
||||
t.Fatalf("member public search = my %d results %d chats %d, want no discovery channel for active member", len(box.MyResults), len(box.Results), len(box.Chats))
|
||||
}
|
||||
|
||||
var strangerIn bin.Buffer
|
||||
|
|
|
|||
|
|
@ -436,7 +436,7 @@ func tgChannel(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember
|
|||
switch self.Role {
|
||||
case domain.ChannelRoleCreator:
|
||||
out.Creator = true
|
||||
out.SetAdminRights(tgChatAdminRights(self.AdminRights))
|
||||
out.SetAdminRights(tgChatAdminRights(creatorProjectionAdminRights(self.AdminRights)))
|
||||
case domain.ChannelRoleAdmin:
|
||||
out.SetAdminRights(tgChatAdminRights(self.AdminRights))
|
||||
}
|
||||
|
|
@ -611,7 +611,7 @@ func tgChannelParticipant(selfUserID int64, member domain.ChannelMember) tg.Chan
|
|||
case domain.ChannelRoleCreator:
|
||||
out := &tg.ChannelParticipantCreator{
|
||||
UserID: member.UserID,
|
||||
AdminRights: tgChatAdminRights(member.AdminRights),
|
||||
AdminRights: tgChatAdminRights(creatorProjectionAdminRights(member.AdminRights)),
|
||||
}
|
||||
if member.Rank != "" {
|
||||
out.SetRank(member.Rank)
|
||||
|
|
@ -779,6 +779,13 @@ func tgChatAdminRights(rights domain.ChannelAdminRights) tg.ChatAdminRights {
|
|||
}
|
||||
}
|
||||
|
||||
func creatorProjectionAdminRights(rights domain.ChannelAdminRights) domain.ChannelAdminRights {
|
||||
creatorRights := domain.CreatorChannelAdminRights()
|
||||
creatorRights.Anonymous = rights.Anonymous
|
||||
creatorRights.ManageDirectMessages = rights.ManageDirectMessages
|
||||
return creatorRights
|
||||
}
|
||||
|
||||
func domainChannelAdminRights(rights tg.ChatAdminRights) domain.ChannelAdminRights {
|
||||
return domain.ChannelAdminRights{
|
||||
ChangeInfo: rights.ChangeInfo,
|
||||
|
|
|
|||
|
|
@ -322,6 +322,12 @@ func tgChannelsForDialogs(viewerUserID int64, channels []domain.Channel, dialogs
|
|||
UserID: viewerUserID,
|
||||
Status: domain.ChannelMemberLeft,
|
||||
}
|
||||
} else {
|
||||
self = &domain.ChannelMember{
|
||||
ChannelID: ch.ID,
|
||||
UserID: viewerUserID,
|
||||
Status: domain.ChannelMemberActive,
|
||||
}
|
||||
}
|
||||
out = append(out, tgChannelChat(viewerUserID, ch, self))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,6 +159,27 @@ func tgMessageServiceAction(msg domain.Message) tg.MessageActionClass {
|
|||
action.SetDuration(m.ServiceAction.Call.Duration)
|
||||
}
|
||||
return action
|
||||
case domain.MessageServiceActionConferenceCall:
|
||||
c := m.ServiceAction.ConferenceCall
|
||||
if c == nil {
|
||||
return &tg.MessageActionEmpty{}
|
||||
}
|
||||
action := &tg.MessageActionConferenceCall{
|
||||
Missed: c.Missed,
|
||||
Active: c.Active,
|
||||
Video: c.Video,
|
||||
CallID: c.CallID,
|
||||
}
|
||||
if c.Duration > 0 {
|
||||
action.SetDuration(c.Duration)
|
||||
}
|
||||
if len(c.OtherParticipants) > 0 {
|
||||
peers := tgPeerList(c.OtherParticipants)
|
||||
if len(peers) > 0 {
|
||||
action.SetOtherParticipants(peers)
|
||||
}
|
||||
}
|
||||
return action
|
||||
case domain.MessageServiceActionBotAllowed:
|
||||
allowed := m.ServiceAction.BotAllowed
|
||||
if allowed == nil {
|
||||
|
|
|
|||
|
|
@ -66,18 +66,19 @@ func tgPhoneCallForViewer(call domain.PhoneCall, viewerID int64) tg.PhoneCallCla
|
|||
gaOrB = call.GB // 主叫视角:拿被叫的 g_b
|
||||
}
|
||||
out := &tg.PhoneCall{
|
||||
P2PAllowed: call.P2PAllowed,
|
||||
Video: call.Video,
|
||||
ID: call.ID,
|
||||
AccessHash: call.AccessHash,
|
||||
Date: call.Date,
|
||||
AdminID: call.AdminID,
|
||||
ParticipantID: call.ParticipantID,
|
||||
GAOrB: gaOrB,
|
||||
KeyFingerprint: call.KeyFingerprint,
|
||||
Protocol: tgPhoneCallProtocol(call.Protocol),
|
||||
Connections: tgPhoneConnections(call.Connections),
|
||||
StartDate: call.StartDate,
|
||||
P2PAllowed: call.P2PAllowed,
|
||||
Video: call.Video,
|
||||
ConferenceSupported: true,
|
||||
ID: call.ID,
|
||||
AccessHash: call.AccessHash,
|
||||
Date: call.Date,
|
||||
AdminID: call.AdminID,
|
||||
ParticipantID: call.ParticipantID,
|
||||
GAOrB: gaOrB,
|
||||
KeyFingerprint: call.KeyFingerprint,
|
||||
Protocol: tgPhoneCallProtocol(call.Protocol),
|
||||
Connections: tgPhoneConnections(call.Connections),
|
||||
StartDate: call.StartDate,
|
||||
}
|
||||
return out
|
||||
case domain.PhoneCallStateDiscarded:
|
||||
|
|
@ -132,7 +133,7 @@ func tgPhoneCallDiscarded(call domain.PhoneCall) *tg.PhoneCallDiscarded {
|
|||
Video: call.Video,
|
||||
ID: call.ID,
|
||||
}
|
||||
if reason := tgPhoneCallDiscardReason(call.DiscardReason); reason != nil {
|
||||
if reason := tgPhoneCallDiscardReasonWithSlug(call.DiscardReason, call.DiscardReasonSlug); reason != nil {
|
||||
out.SetReason(reason)
|
||||
}
|
||||
if call.Duration > 0 {
|
||||
|
|
@ -155,6 +156,10 @@ func tgPhoneCallStopRinging(call domain.PhoneCall) *tg.PhoneCallDiscarded {
|
|||
}
|
||||
|
||||
func tgPhoneCallDiscardReason(r domain.PhoneCallDiscardReason) tg.PhoneCallDiscardReasonClass {
|
||||
return tgPhoneCallDiscardReasonWithSlug(r, "")
|
||||
}
|
||||
|
||||
func tgPhoneCallDiscardReasonWithSlug(r domain.PhoneCallDiscardReason, slug string) tg.PhoneCallDiscardReasonClass {
|
||||
switch r {
|
||||
case domain.PhoneCallDiscardReasonMissed:
|
||||
return &tg.PhoneCallDiscardReasonMissed{}
|
||||
|
|
@ -165,7 +170,7 @@ func tgPhoneCallDiscardReason(r domain.PhoneCallDiscardReason) tg.PhoneCallDisca
|
|||
case domain.PhoneCallDiscardReasonBusy:
|
||||
return &tg.PhoneCallDiscardReasonBusy{}
|
||||
case domain.PhoneCallDiscardReasonMigrateConference:
|
||||
return &tg.PhoneCallDiscardReasonMigrateConferenceCall{}
|
||||
return &tg.PhoneCallDiscardReasonMigrateConferenceCall{Slug: slug}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
|
@ -188,3 +193,10 @@ func phoneCallDiscardReasonFromTL(r tg.PhoneCallDiscardReasonClass) domain.Phone
|
|||
return domain.PhoneCallDiscardReasonHangup
|
||||
}
|
||||
}
|
||||
|
||||
func phoneCallDiscardReasonSlugFromTL(r tg.PhoneCallDiscardReasonClass) string {
|
||||
if migrate, ok := r.(*tg.PhoneCallDiscardReasonMigrateConferenceCall); ok {
|
||||
return migrate.Slug
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ func tgGroupCall(call domain.GroupCall, viewerUserID int64, canManage bool) tg.G
|
|||
JoinMuted: call.JoinMuted,
|
||||
CanChangeJoinMuted: canManage,
|
||||
Creator: call.CreatorUserID == viewerUserID && viewerUserID != 0,
|
||||
Conference: call.Conference(),
|
||||
// can_start_video:TDesktop 不读;DrKLO 用它喂入会前 dummy self 行的
|
||||
// video_joined。RTC 通话一律放行。
|
||||
CanStartVideo: true,
|
||||
|
|
@ -41,6 +42,13 @@ func tgGroupCall(call domain.GroupCall, viewerUserID int64, canManage bool) tg.G
|
|||
if call.Title != "" {
|
||||
out.SetTitle(call.Title)
|
||||
}
|
||||
if call.Conference() {
|
||||
if link := conferenceCanonicalInviteLink(call.InviteSlug); link != "" {
|
||||
out.SetInviteLink(link)
|
||||
} else if call.InviteLink != "" {
|
||||
out.SetInviteLink(call.InviteLink)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -229,7 +229,7 @@ func tgContactsFound(viewerUserID int64, res domain.UserSearchResult) *tg.Contac
|
|||
}
|
||||
for _, ch := range res.MyChannelResults {
|
||||
out.MyResults = append(out.MyResults, &tg.PeerChannel{ChannelID: ch.ID})
|
||||
appendChannel(ch, nil)
|
||||
appendChannel(ch, &domain.ChannelMember{ChannelID: ch.ID, UserID: viewerUserID, Status: domain.ChannelMemberActive})
|
||||
}
|
||||
for _, u := range res.Results {
|
||||
out.Results = append(out.Results, &tg.PeerUser{UserID: u.ID})
|
||||
|
|
|
|||
|
|
@ -470,6 +470,7 @@ type ChannelsService interface {
|
|||
SetWallpaper(ctx context.Context, userID int64, req domain.SetChannelWallpaperRequest) (domain.SetChannelWallpaperResult, error)
|
||||
EditAbout(ctx context.Context, userID int64, req domain.EditChannelAboutRequest) (domain.Channel, error)
|
||||
EditAdmin(ctx context.Context, userID int64, req domain.EditChannelAdminRequest) (domain.EditChannelAdminResult, error)
|
||||
TransferOwnership(ctx context.Context, userID int64, req domain.TransferChannelOwnershipRequest) (domain.TransferChannelOwnershipResult, error)
|
||||
EditMemberRank(ctx context.Context, userID int64, req domain.EditChannelMemberRankRequest) (domain.EditChannelAdminResult, error)
|
||||
EditBanned(ctx context.Context, userID int64, req domain.EditChannelBannedRequest) (domain.EditChannelBannedResult, error)
|
||||
EditDefaultBannedRights(ctx context.Context, userID int64, req domain.EditChannelDefaultBannedRightsRequest) (domain.Channel, error)
|
||||
|
|
@ -760,6 +761,7 @@ type PhoneService interface {
|
|||
AcceptCall(ctx context.Context, userID, callID, accessHash int64, gb []byte, proto domain.PhoneCallProtocol, device domain.SessionRef) (domain.PhoneCall, error)
|
||||
ConfirmCall(ctx context.Context, userID, callID, accessHash int64, ga []byte, keyFingerprint int64, proto domain.PhoneCallProtocol) (call domain.PhoneCall, forcedDiscard bool, err error)
|
||||
DiscardCall(ctx context.Context, userID, callID, accessHash int64, reason domain.PhoneCallDiscardReason, duration int) (call domain.PhoneCall, already bool, err error)
|
||||
DiscardCallWithSlug(ctx context.Context, userID, callID, accessHash int64, reason domain.PhoneCallDiscardReason, reasonSlug string, duration int) (call domain.PhoneCall, already bool, err error)
|
||||
// Signal 在该通话的信令顺序锁内执行 forward;drop=true 表示按契约静默吞掉。
|
||||
// peerDevice 是对端受理设备锚点(可零值/失效),定向推送失败须回退 user 扇出。
|
||||
Signal(ctx context.Context, userID, callID, accessHash int64, forward func(peerUserID int64, peerDevice domain.SessionRef)) (drop bool, err error)
|
||||
|
|
@ -772,9 +774,13 @@ type PhoneService interface {
|
|||
// 错误集合见 domain.ErrGroupCall*(rpc 层映射为 GROUPCALL_* RPC_ERROR)。
|
||||
type GroupCallsService interface {
|
||||
Create(ctx context.Context, channelID, creatorUserID int64, title string, now int) (domain.GroupCall, error)
|
||||
CreateConference(ctx context.Context, creatorUserID, randomID, migratedFromPhoneCallID int64, now int) (domain.GroupCall, error)
|
||||
Get(ctx context.Context, callID int64) (domain.GroupCall, bool, error)
|
||||
GetBySlug(ctx context.Context, slug string) (domain.GroupCall, bool, error)
|
||||
GetByInviteMessage(ctx context.Context, userID int64, msgID int) (domain.GroupCall, domain.GroupCallInvite, bool, error)
|
||||
Join(ctx context.Context, req domain.JoinGroupCallRequest) (domain.GroupCallMutation, error)
|
||||
Leave(ctx context.Context, callID, userID int64, now int) (domain.GroupCallMutation, error)
|
||||
RemoveConferenceParticipants(ctx context.Context, req domain.RemoveConferenceCallParticipantsRequest) (domain.RemoveConferenceCallParticipantsResult, error)
|
||||
Discard(ctx context.Context, callID int64, now int) (domain.GroupCall, []domain.GroupCallParticipant, error)
|
||||
Touch(ctx context.Context, callID, userID int64, now int) (activeSSRCs []int64, joined bool, err error)
|
||||
Participant(ctx context.Context, callID, userID int64) (domain.GroupCallParticipant, bool, error)
|
||||
|
|
@ -788,6 +794,11 @@ type GroupCallsService interface {
|
|||
NextRaiseHandRating(ctx context.Context, callID int64) (int64, error)
|
||||
SetParticipantOverride(ctx context.Context, callID, setterUserID, targetUserID int64, override domain.GroupCallParticipantOverride, clear bool) error
|
||||
ParticipantOverride(ctx context.Context, callID, setterUserID, targetUserID int64) (domain.GroupCallParticipantOverride, bool, error)
|
||||
CreateConferenceInvite(ctx context.Context, invite domain.GroupCallInvite) (domain.GroupCallInvite, error)
|
||||
SetConferenceInviteStatus(ctx context.Context, callID, inviteeUserID int64, msgID int, status domain.GroupCallInviteStatus, now int) (domain.GroupCallInvite, bool, error)
|
||||
ConferenceRecipients(ctx context.Context, callID int64) ([]int64, error)
|
||||
AppendChainBlock(ctx context.Context, block domain.GroupCallChainBlock) (domain.GroupCallChainBlock, error)
|
||||
ChainBlocks(ctx context.Context, callID int64, subChainID, offset, limit int) (domain.GroupCallChainBlockPage, error)
|
||||
}
|
||||
|
||||
// PollsService 抽象 poll 权威态的发送时创建与投票人列表(messages.getPollVotes)。
|
||||
|
|
|
|||
|
|
@ -307,11 +307,15 @@ func groupCallInvalidErr() error { return tgerr.New(400, "GROUPCALL_INV
|
|||
func groupCallAlreadyDiscardedErr() error { return tgerr.New(400, "GROUPCALL_ALREADY_DISCARDED") }
|
||||
func groupCallAlreadyStartedErr() error { return tgerr.New(400, "GROUPCALL_ALREADY_STARTED") }
|
||||
func groupCallForbiddenErr() error { return tgerr.New(403, "GROUPCALL_FORBIDDEN") }
|
||||
func publicChannelMissingErr() error { return tgerr.New(403, "PUBLIC_CHANNEL_MISSING") }
|
||||
func groupCallSSRCDuplicateErr() error {
|
||||
return tgerr.New(400, "GROUPCALL_SSRC_DUPLICATE_MUCH")
|
||||
}
|
||||
func groupCallJoinMissingErr() error { return tgerr.New(400, "GROUPCALL_JOIN_MISSING") }
|
||||
func groupCallNotModifiedErr() error { return tgerr.New(400, "GROUPCALL_NOT_MODIFIED") }
|
||||
func confWriteChainInvalidErr() error {
|
||||
return tgerr.New(400, "CONF_WRITE_CHAIN_INVALID")
|
||||
}
|
||||
|
||||
// 私聊端对端加密(Secret Chat / encrypted chat)错误;触发点见
|
||||
// internal/rpc/encrypted_chats.go 与 app/secretchat、domain 错误映射。
|
||||
|
|
|
|||
|
|
@ -69,6 +69,14 @@ func (d *GroupCallSweepDispatcher) DispatchOnce(ctx context.Context) {
|
|||
if d.router.deps.SFU != nil {
|
||||
_ = d.router.deps.SFU.Leave(ctx, mut.Call.ID, mut.Participant.UserID, sfu.EndpointMain)
|
||||
}
|
||||
if mut.Call.Conference() {
|
||||
d.router.groupCallMutationFanout(ctx, domain.Channel{}, mut)
|
||||
d.log.Info("conference call participant swept",
|
||||
zap.Int64("call_id", mut.Call.ID),
|
||||
zap.Int64("user_id", mut.Participant.UserID),
|
||||
zap.String("state", string(mut.Call.State)))
|
||||
continue
|
||||
}
|
||||
channel, err := d.router.channelForGroupCall(ctx, mut.Call)
|
||||
if err != nil {
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -11,9 +11,12 @@ import (
|
|||
)
|
||||
|
||||
func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.MessagesForwardMessagesRequest) (tg.UpdatesClass, error) {
|
||||
if len(req.ID) == 0 || len(req.ID) != len(req.RandomID) {
|
||||
ids, randomIDs, ok := normalizeForwardMessageVectors(req.ID, req.RandomID)
|
||||
if !ok {
|
||||
return nil, inputRequestInvalidErr()
|
||||
}
|
||||
req.ID = ids
|
||||
req.RandomID = randomIDs
|
||||
if len(req.ID) > domain.MaxForwardMessageIDs {
|
||||
return nil, limitInvalidErr()
|
||||
}
|
||||
|
|
@ -37,7 +40,7 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages
|
|||
if userID == 0 {
|
||||
return nil, peerIDInvalidErr()
|
||||
}
|
||||
fromPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.FromPeer)
|
||||
fromPeer, preloadedSources, err := r.forwardFromPeerAndSources(ctx, userID, req.FromPeer, req.ID, req.RandomID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -69,22 +72,20 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages
|
|||
}
|
||||
}
|
||||
}
|
||||
for i, id := range req.ID {
|
||||
if id <= 0 || id > domain.MaxMessageBoxID || req.RandomID[i] == 0 {
|
||||
return nil, messageIDInvalidErr()
|
||||
}
|
||||
if !forwardMessageIDsValid(req.ID, req.RandomID) {
|
||||
return nil, messageIDInvalidErr()
|
||||
}
|
||||
if err := r.checkSendRateLimit(ctx, userID, len(req.ID)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.ScheduleDate != 0 && !scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix())) {
|
||||
return r.scheduleForwardMessages(ctx, userID, fromPeer, toPeer, req, replyTo, sendAs)
|
||||
return r.scheduleForwardMessages(ctx, userID, fromPeer, toPeer, req, replyTo, sendAs, preloadedSources)
|
||||
}
|
||||
if toPeer.Type == domain.PeerTypeChannel {
|
||||
if r.deps.Channels == nil {
|
||||
return nil, peerIDInvalidErr()
|
||||
}
|
||||
sources, err := r.forwardSources(ctx, userID, fromPeer, req.ID)
|
||||
sources, err := r.forwardSourcesForRequest(ctx, userID, fromPeer, req.ID, preloadedSources)
|
||||
if err != nil {
|
||||
return nil, messageForwardErr(err)
|
||||
}
|
||||
|
|
@ -152,7 +153,7 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages
|
|||
// 私聊源与频道源统一经 forwardSources 取源:首次生成的 forward header 在
|
||||
// forwardSources 内已按原作者 PrivacyKeyForwards 降级(不允许链接回账号时仅保
|
||||
// 留 from_name),避免私聊→私聊路径泄漏原作者可点击账号;media 也随 source 透传。
|
||||
sources, err := r.forwardSources(ctx, userID, fromPeer, req.ID)
|
||||
sources, err := r.forwardSourcesForRequest(ctx, userID, fromPeer, req.ID, preloadedSources)
|
||||
if err != nil {
|
||||
return nil, messageForwardErr(err)
|
||||
}
|
||||
|
|
@ -200,6 +201,110 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages
|
|||
return nil, peerIDInvalidErr()
|
||||
}
|
||||
|
||||
func normalizeForwardMessageVectors(ids []int, randomIDs []int64) ([]int, []int64, bool) {
|
||||
if len(ids) == 0 || len(randomIDs) == 0 {
|
||||
return nil, nil, false
|
||||
}
|
||||
if len(ids) == len(randomIDs) {
|
||||
return ids, randomIDs, true
|
||||
}
|
||||
if len(ids) < len(randomIDs) {
|
||||
return nil, nil, false
|
||||
}
|
||||
compact := make([]int, 0, len(randomIDs))
|
||||
runLength := 0
|
||||
for i, id := range ids {
|
||||
if i == 0 || id != ids[i-1] {
|
||||
compact = append(compact, id)
|
||||
runLength = 1
|
||||
continue
|
||||
}
|
||||
runLength++
|
||||
if runLength > 2 {
|
||||
return nil, nil, false
|
||||
}
|
||||
}
|
||||
if len(compact) != len(randomIDs) {
|
||||
return nil, nil, false
|
||||
}
|
||||
return compact, randomIDs, true
|
||||
}
|
||||
|
||||
func (r *Router) forwardFromPeerAndSources(ctx context.Context, userID int64, input tg.InputPeerClass, ids []int, randomIDs []int64) (domain.Peer, []forwardSource, error) {
|
||||
if forwardFromPeerIsEmpty(input) {
|
||||
if !forwardMessageIDsValid(ids, randomIDs) {
|
||||
return domain.Peer{}, nil, messageIDInvalidErr()
|
||||
}
|
||||
fromPeer, sources, err := r.forwardSourcesFromEmptyPeer(ctx, userID, ids)
|
||||
if err != nil {
|
||||
return domain.Peer{}, nil, messageForwardErr(err)
|
||||
}
|
||||
return fromPeer, sources, nil
|
||||
}
|
||||
fromPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input)
|
||||
return fromPeer, nil, err
|
||||
}
|
||||
|
||||
func forwardMessageIDsValid(ids []int, randomIDs []int64) bool {
|
||||
if len(ids) == 0 || len(ids) != len(randomIDs) {
|
||||
return false
|
||||
}
|
||||
for i, id := range ids {
|
||||
if id <= 0 || id > domain.MaxMessageBoxID || randomIDs[i] == 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func forwardFromPeerIsEmpty(peer tg.InputPeerClass) bool {
|
||||
if inputPeerClassNil(peer) {
|
||||
return false
|
||||
}
|
||||
_, ok := peer.(*tg.InputPeerEmpty)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (r *Router) forwardSourcesFromEmptyPeer(ctx context.Context, userID int64, ids []int) (domain.Peer, []forwardSource, error) {
|
||||
if r.deps.Messages == nil {
|
||||
return domain.Peer{}, nil, domain.ErrMessageIDInvalid
|
||||
}
|
||||
list, err := r.deps.Messages.GetMessages(ctx, userID, ids)
|
||||
if err != nil {
|
||||
return domain.Peer{}, nil, domain.ErrMessageIDInvalid
|
||||
}
|
||||
var fromPeer domain.Peer
|
||||
byID := make(map[int]domain.Message, len(list.Messages))
|
||||
for _, msg := range list.Messages {
|
||||
byID[msg.ID] = msg
|
||||
}
|
||||
for _, id := range ids {
|
||||
msg, ok := byID[id]
|
||||
if !ok || msg.Peer.Type != domain.PeerTypeUser || msg.Peer.ID == 0 {
|
||||
return domain.Peer{}, nil, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if fromPeer.ID == 0 {
|
||||
fromPeer = msg.Peer
|
||||
continue
|
||||
}
|
||||
if msg.Peer != fromPeer {
|
||||
return domain.Peer{}, nil, domain.ErrMessageIDInvalid
|
||||
}
|
||||
}
|
||||
sources, err := r.forwardSourcesFromPrivateMessages(ctx, userID, fromPeer, ids, list.Messages)
|
||||
if err != nil {
|
||||
return domain.Peer{}, nil, err
|
||||
}
|
||||
return fromPeer, sources, nil
|
||||
}
|
||||
|
||||
func (r *Router) forwardSourcesForRequest(ctx context.Context, userID int64, fromPeer domain.Peer, ids []int, preloaded []forwardSource) ([]forwardSource, error) {
|
||||
if preloaded != nil {
|
||||
return preloaded, nil
|
||||
}
|
||||
return r.forwardSources(ctx, userID, fromPeer, ids)
|
||||
}
|
||||
|
||||
func mergeForwardTopMsgID(toPeer domain.Peer, replyTo *domain.MessageReply, topMsgID int, topMsgIDSet bool) (*domain.MessageReply, error) {
|
||||
if !topMsgIDSet || topMsgID == 0 {
|
||||
return replyTo, nil
|
||||
|
|
@ -241,36 +346,11 @@ func (r *Router) forwardSources(ctx context.Context, userID int64, fromPeer doma
|
|||
if err != nil {
|
||||
return nil, domain.ErrMessageIDInvalid
|
||||
}
|
||||
byID := make(map[int]domain.Message, len(list.Messages))
|
||||
for _, msg := range list.Messages {
|
||||
byID[msg.ID] = msg
|
||||
}
|
||||
for _, id := range ids {
|
||||
msg, ok := byID[id]
|
||||
if !ok {
|
||||
return nil, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if msg.Peer != fromPeer {
|
||||
return nil, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if msg.NoForwards {
|
||||
return nil, domain.ErrChatForwardsRestricted
|
||||
}
|
||||
forward := cloneDomainMessageForward(msg.Forward)
|
||||
if forward == nil {
|
||||
forward = &domain.MessageForward{From: msg.From, Date: msg.Date}
|
||||
r.applyForwardAuthorPrivacy(ctx, userID, forward)
|
||||
}
|
||||
out = append(out, forwardSource{
|
||||
body: msg.Body,
|
||||
entities: append([]domain.MessageEntity(nil),
|
||||
msg.Entities...),
|
||||
media: msg.Media,
|
||||
forward: forward,
|
||||
from: msg.From,
|
||||
date: msg.Date,
|
||||
})
|
||||
sources, err := r.forwardSourcesFromPrivateMessages(ctx, userID, fromPeer, ids, list.Messages)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, sources...)
|
||||
case domain.PeerTypeChannel:
|
||||
if r.deps.Channels == nil {
|
||||
return nil, domain.ErrMessageIDInvalid
|
||||
|
|
@ -325,6 +405,44 @@ func (r *Router) forwardSources(ctx context.Context, userID int64, fromPeer doma
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Router) forwardSourcesFromPrivateMessages(ctx context.Context, userID int64, fromPeer domain.Peer, ids []int, messages []domain.Message) ([]forwardSource, error) {
|
||||
if fromPeer.Type != domain.PeerTypeUser || fromPeer.ID == 0 {
|
||||
return nil, domain.ErrMessageIDInvalid
|
||||
}
|
||||
byID := make(map[int]domain.Message, len(messages))
|
||||
for _, msg := range messages {
|
||||
byID[msg.ID] = msg
|
||||
}
|
||||
out := make([]forwardSource, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
msg, ok := byID[id]
|
||||
if !ok {
|
||||
return nil, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if msg.Peer != fromPeer {
|
||||
return nil, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if msg.NoForwards {
|
||||
return nil, domain.ErrChatForwardsRestricted
|
||||
}
|
||||
forward := cloneDomainMessageForward(msg.Forward)
|
||||
if forward == nil {
|
||||
forward = &domain.MessageForward{From: msg.From, Date: msg.Date}
|
||||
r.applyForwardAuthorPrivacy(ctx, userID, forward)
|
||||
}
|
||||
out = append(out, forwardSource{
|
||||
body: msg.Body,
|
||||
entities: append([]domain.MessageEntity(nil),
|
||||
msg.Entities...),
|
||||
media: msg.Media,
|
||||
forward: forward,
|
||||
from: msg.From,
|
||||
date: msg.Date,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func cloneDomainMessageForward(in *domain.MessageForward) *domain.MessageForward {
|
||||
if in == nil {
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -195,6 +195,200 @@ func TestMessagesForwardMessagesLoadsPrivateSourcesInSingleBatch(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestMessagesForwardMessagesInfersPrivateSourceFromInputPeerEmpty(t *testing.T) {
|
||||
const (
|
||||
ownerID = int64(1780243210)
|
||||
fromID = int64(1780243211)
|
||||
toID = int64(1780243212)
|
||||
)
|
||||
ctx := context.Background()
|
||||
messages := &captureMessages{
|
||||
getMessagesListed: true,
|
||||
list: domain.MessageList{Messages: []domain.Message{
|
||||
{
|
||||
ID: 189,
|
||||
OwnerUserID: ownerID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: fromID},
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: fromID},
|
||||
Date: 1700002189,
|
||||
Body: "android source",
|
||||
},
|
||||
}},
|
||||
}
|
||||
r := New(Config{}, Deps{
|
||||
Messages: messages,
|
||||
Users: mapUsersService{users: map[int64]domain.User{
|
||||
ownerID: {ID: ownerID, FirstName: "Owner"},
|
||||
fromID: {ID: fromID, FirstName: "From"},
|
||||
toID: {ID: toID, FirstName: "To"},
|
||||
}},
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
updatesClass, err := r.onMessagesForwardMessages(WithUserID(ctx, ownerID), &tg.MessagesForwardMessagesRequest{
|
||||
FromPeer: &tg.InputPeerEmpty{},
|
||||
ToPeer: &tg.InputPeerUser{UserID: toID},
|
||||
ID: []int{189},
|
||||
RandomID: []int64{5069400637215652584},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("forward with empty source peer: %v", err)
|
||||
}
|
||||
if messages.getMessagesCalls != 1 || len(messages.getMessagesIDs) != 1 || len(messages.getMessagesIDs[0]) != 1 || messages.getMessagesIDs[0][0] != 189 {
|
||||
t.Fatalf("GetMessages calls=%d ids=%+v, want one source lookup for [189]", messages.getMessagesCalls, messages.getMessagesIDs)
|
||||
}
|
||||
if messages.sendReq.RecipientUserID != toID || messages.sendReq.Message != "android source" {
|
||||
t.Fatalf("send request = %+v, want inferred source body to target", messages.sendReq)
|
||||
}
|
||||
if messages.sendReq.Forward == nil || messages.sendReq.Forward.From != (domain.Peer{Type: domain.PeerTypeUser, ID: fromID}) {
|
||||
t.Fatalf("forward header = %+v, want original author %d", messages.sendReq.Forward, fromID)
|
||||
}
|
||||
updates, ok := updatesClass.(*tg.Updates)
|
||||
if !ok || len(updates.Updates) != 2 {
|
||||
t.Fatalf("updates = %T %+v, want updateMessageID + updateNewMessage", updatesClass, updatesClass)
|
||||
}
|
||||
if id, ok := updates.Updates[0].(*tg.UpdateMessageID); !ok || id.RandomID != 5069400637215652584 {
|
||||
t.Fatalf("first update = %#v, want request random id", updates.Updates[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesForwardMessagesInputPeerEmptyRejectsMixedPrivateSources(t *testing.T) {
|
||||
const (
|
||||
ownerID = int64(1780243210)
|
||||
fromA = int64(1780243211)
|
||||
fromB = int64(1780243212)
|
||||
toID = int64(1780243213)
|
||||
)
|
||||
ctx := context.Background()
|
||||
messages := &captureMessages{
|
||||
getMessagesListed: true,
|
||||
list: domain.MessageList{Messages: []domain.Message{
|
||||
{ID: 10, OwnerUserID: ownerID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: fromA}, From: domain.Peer{Type: domain.PeerTypeUser, ID: fromA}, Date: 1700002210, Body: "first"},
|
||||
{ID: 11, OwnerUserID: ownerID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: fromB}, From: domain.Peer{Type: domain.PeerTypeUser, ID: fromB}, Date: 1700002211, Body: "second"},
|
||||
}},
|
||||
}
|
||||
r := New(Config{}, Deps{
|
||||
Messages: messages,
|
||||
Users: mapUsersService{users: map[int64]domain.User{
|
||||
ownerID: {ID: ownerID, FirstName: "Owner"},
|
||||
fromA: {ID: fromA, FirstName: "FromA"},
|
||||
fromB: {ID: fromB, FirstName: "FromB"},
|
||||
toID: {ID: toID, FirstName: "To"},
|
||||
}},
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
_, err := r.onMessagesForwardMessages(WithUserID(ctx, ownerID), &tg.MessagesForwardMessagesRequest{
|
||||
FromPeer: &tg.InputPeerEmpty{},
|
||||
ToPeer: &tg.InputPeerUser{UserID: toID},
|
||||
ID: []int{10, 11},
|
||||
RandomID: []int64{10010, 10011},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") {
|
||||
t.Fatalf("forward mixed empty-source ids err = %v, want MESSAGE_ID_INVALID", err)
|
||||
}
|
||||
if messages.sendReq.RecipientUserID != 0 {
|
||||
t.Fatalf("send request = %+v, want no send after mixed source rejection", messages.sendReq)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesForwardMessagesInputPeerEmptyRejectsBadIDsBeforeLookup(t *testing.T) {
|
||||
const ownerID = int64(1780243210)
|
||||
ctx := context.Background()
|
||||
messages := &captureMessages{}
|
||||
r := New(Config{}, Deps{
|
||||
Messages: messages,
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
_, err := r.onMessagesForwardMessages(WithUserID(ctx, ownerID), &tg.MessagesForwardMessagesRequest{
|
||||
FromPeer: &tg.InputPeerEmpty{},
|
||||
ToPeer: &tg.InputPeerUser{UserID: 1780243211},
|
||||
ID: []int{0},
|
||||
RandomID: []int64{10001},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") {
|
||||
t.Fatalf("forward bad empty-source id err = %v, want MESSAGE_ID_INVALID", err)
|
||||
}
|
||||
if messages.getMessagesCalls != 0 {
|
||||
t.Fatalf("GetMessages calls = %d, want no source lookup for invalid id", messages.getMessagesCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesForwardMessagesNormalizesAndroidDuplicateIDRetry(t *testing.T) {
|
||||
const (
|
||||
ownerID = int64(1780243210)
|
||||
fromID = int64(1780243211)
|
||||
)
|
||||
ctx := context.Background()
|
||||
messages := &captureMessages{
|
||||
getMessagesListed: true,
|
||||
list: domain.MessageList{Messages: []domain.Message{
|
||||
{
|
||||
ID: 187,
|
||||
OwnerUserID: ownerID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: fromID},
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: fromID},
|
||||
Date: 1700002187,
|
||||
Body: "retry source",
|
||||
},
|
||||
}},
|
||||
}
|
||||
r := New(Config{}, Deps{
|
||||
Messages: messages,
|
||||
Users: mapUsersService{users: map[int64]domain.User{
|
||||
ownerID: {ID: ownerID, FirstName: "Owner"},
|
||||
fromID: {ID: fromID, FirstName: "From"},
|
||||
}},
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
updatesClass, err := r.onMessagesForwardMessages(WithUserID(ctx, ownerID), &tg.MessagesForwardMessagesRequest{
|
||||
FromPeer: &tg.InputPeerEmpty{},
|
||||
ToPeer: &tg.InputPeerUser{UserID: fromID},
|
||||
ID: []int{187, 187},
|
||||
RandomID: []int64{1993272996073519809},
|
||||
DropAuthor: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("forward android duplicate-id retry: %v", err)
|
||||
}
|
||||
if messages.getMessagesCalls != 1 || len(messages.getMessagesIDs) != 1 || len(messages.getMessagesIDs[0]) != 1 || messages.getMessagesIDs[0][0] != 187 {
|
||||
t.Fatalf("GetMessages calls=%d ids=%+v, want one normalized source lookup for [187]", messages.getMessagesCalls, messages.getMessagesIDs)
|
||||
}
|
||||
if messages.sendReq.RecipientUserID != fromID || messages.sendReq.Message != "retry source" {
|
||||
t.Fatalf("send request = %+v, want one forwarded message to current peer", messages.sendReq)
|
||||
}
|
||||
if messages.sendReq.Forward != nil {
|
||||
t.Fatalf("forward header = %+v, want dropped author", messages.sendReq.Forward)
|
||||
}
|
||||
updates, ok := updatesClass.(*tg.Updates)
|
||||
if !ok || len(updates.Updates) != 2 {
|
||||
t.Fatalf("updates = %T %+v, want one updateMessageID + one updateNewMessage", updatesClass, updatesClass)
|
||||
}
|
||||
if id, ok := updates.Updates[0].(*tg.UpdateMessageID); !ok || id.RandomID != 1993272996073519809 {
|
||||
t.Fatalf("first update = %#v, want normalized random id", updates.Updates[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesForwardMessagesRejectsUnpairedIDRandomVectors(t *testing.T) {
|
||||
const ownerID = int64(1780243210)
|
||||
ctx := context.Background()
|
||||
messages := &captureMessages{}
|
||||
r := New(Config{}, Deps{
|
||||
Messages: messages,
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
_, err := r.onMessagesForwardMessages(WithUserID(ctx, ownerID), &tg.MessagesForwardMessagesRequest{
|
||||
FromPeer: &tg.InputPeerEmpty{},
|
||||
ToPeer: &tg.InputPeerUser{UserID: 1780243211},
|
||||
ID: []int{187, 188},
|
||||
RandomID: []int64{1993272996073519809},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "INPUT_REQUEST_INVALID") {
|
||||
t.Fatalf("forward unpaired vectors err = %v, want INPUT_REQUEST_INVALID", err)
|
||||
}
|
||||
if messages.getMessagesCalls != 0 {
|
||||
t.Fatalf("GetMessages calls = %d, want no source lookup for unpaired vectors", messages.getMessagesCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatsForMessageUpdatesUsesBatchChannelProjection(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
|
|
|
|||
|
|
@ -365,12 +365,12 @@ func sentMessageIDFromUpdates(updates tg.UpdatesClass) int {
|
|||
return 0
|
||||
}
|
||||
|
||||
func (r *Router) scheduleForwardMessages(ctx context.Context, userID int64, fromPeer, toPeer domain.Peer, req *tg.MessagesForwardMessagesRequest, replyTo *domain.MessageReply, sendAs *domain.Peer) (tg.UpdatesClass, error) {
|
||||
func (r *Router) scheduleForwardMessages(ctx context.Context, userID int64, fromPeer, toPeer domain.Peer, req *tg.MessagesForwardMessagesRequest, replyTo *domain.MessageReply, sendAs *domain.Peer, preloadedSources []forwardSource) (tg.UpdatesClass, error) {
|
||||
scheduledSvc, ok := r.deps.Messages.(scheduledMessagesService)
|
||||
if r.deps.Messages == nil || !ok {
|
||||
return nil, peerIDInvalidErr()
|
||||
}
|
||||
sources, err := r.forwardSources(ctx, userID, fromPeer, req.ID)
|
||||
sources, err := r.forwardSourcesForRequest(ctx, userID, fromPeer, req.ID, preloadedSources)
|
||||
if err != nil {
|
||||
return nil, messageForwardErr(err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -210,15 +210,28 @@ func (r *Router) onPhoneDiscardCall(ctx context.Context, req *tg.PhoneDiscardCal
|
|||
return nil, err
|
||||
}
|
||||
reason := phoneCallDiscardReasonFromTL(req.Reason)
|
||||
call, already, err := r.deps.Phone.DiscardCall(ctx, userID, req.Peer.ID, req.Peer.AccessHash, reason, req.Duration)
|
||||
reasonSlug := phoneCallDiscardReasonSlugFromTL(req.Reason)
|
||||
if reason == domain.PhoneCallDiscardReasonMigrateConference {
|
||||
if reasonSlug == "" || r.deps.GroupCalls == nil {
|
||||
return nil, groupCallInvalidErr()
|
||||
}
|
||||
if call, found, err := r.deps.GroupCalls.GetBySlug(ctx, reasonSlug); err != nil {
|
||||
return nil, internalErr()
|
||||
} else if !found || !call.Conference() || !call.Active() {
|
||||
return nil, groupCallInvalidErr()
|
||||
}
|
||||
}
|
||||
call, already, err := r.deps.Phone.DiscardCallWithSlug(ctx, userID, req.Peer.ID, req.Peer.AccessHash, reason, reasonSlug, req.Duration)
|
||||
if err != nil {
|
||||
return nil, phoneCallErr(err)
|
||||
}
|
||||
if !already {
|
||||
// 对端全部设备 + 发起者其它设备(ctx except 排除发起设备,其结果在 RPC 响应里)。
|
||||
r.pushPhoneCallDiscardedBoth(ctx, call)
|
||||
// 落 messageActionPhoneCall 历史(带 pts 走 outbox,双方全部设备可靠收到)。
|
||||
r.sendPhoneCallServiceMessage(ctx, call)
|
||||
if reason != domain.PhoneCallDiscardReasonMigrateConference {
|
||||
// 落 messageActionPhoneCall 历史(带 pts 走 outbox,双方全部设备可靠收到)。
|
||||
r.sendPhoneCallServiceMessage(ctx, call)
|
||||
}
|
||||
}
|
||||
// 双方同时挂断的竞态:先到者定 reason,后到者拿终态快照(幂等成功)。
|
||||
return r.phoneCallUpdates(ctx, call, userID), nil
|
||||
|
|
|
|||
468
internal/rpc/phone_conference.go
Normal file
468
internal/rpc/phone_conference.go
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
|
||||
"github.com/gotd/td/tg"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/sfu"
|
||||
)
|
||||
|
||||
const (
|
||||
maxConferenceChainBlockBytes = 64 * 1024
|
||||
maxConferenceChainBlocks = 100
|
||||
|
||||
conferenceChainBlockConstructor = 0x639a3db6
|
||||
conferenceChainBlockServerConstructor = 0x639a3db7
|
||||
conferenceBroadcastCommitConstructor = 0xd1512ae7
|
||||
conferenceBroadcastCommitServerConstructor = 0xd1512ae8
|
||||
conferenceBroadcastRevealConstructor = 0x83f4f9d8
|
||||
conferenceBroadcastRevealServerConstructor = 0x83f4f9d9
|
||||
)
|
||||
|
||||
func (r *Router) onPhoneCreateConferenceCall(ctx context.Context, req *tg.PhoneCreateConferenceCallRequest) (tg.UpdatesClass, error) {
|
||||
if req == nil {
|
||||
return nil, inputRequestInvalidErr()
|
||||
}
|
||||
if r.deps.GroupCalls == nil {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
userID, err := r.phoneRequireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := int(r.clock.Now().Unix())
|
||||
call, err := r.deps.GroupCalls.CreateConference(ctx, userID, int64(req.RandomID), 0, now)
|
||||
if err != nil {
|
||||
return nil, groupCallErr(err)
|
||||
}
|
||||
out := r.groupCallUpdateContainer(ctx, userID, domain.Channel{},
|
||||
&tg.UpdateGroupCall{Call: tgGroupCall(call, userID, true)}, []int64{userID})
|
||||
if !req.Join {
|
||||
return out, nil
|
||||
}
|
||||
params, ok := req.GetParams()
|
||||
if !ok {
|
||||
return nil, groupCallInvalidErr()
|
||||
}
|
||||
joinReq := &tg.PhoneJoinGroupCallRequest{
|
||||
Muted: req.Muted,
|
||||
VideoStopped: req.VideoStopped,
|
||||
Call: &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash},
|
||||
JoinAs: &tg.InputPeerSelf{},
|
||||
Params: params,
|
||||
}
|
||||
if pk, ok := req.GetPublicKey(); ok {
|
||||
joinReq.SetPublicKey(pk)
|
||||
}
|
||||
if block, ok := req.GetBlock(); ok {
|
||||
joinReq.SetBlock(block)
|
||||
}
|
||||
joinUpdates, err := r.onPhoneJoinGroupCall(ctx, joinReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appendUpdates(out, joinUpdates)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Router) onPhoneExportGroupCallInvite(ctx context.Context, req *tg.PhoneExportGroupCallInviteRequest) (*tg.PhoneExportedGroupCallInvite, error) {
|
||||
if req == nil {
|
||||
return nil, inputRequestInvalidErr()
|
||||
}
|
||||
scope, err := r.groupCallScopeFrom(ctx, req.Call)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !scope.call.Active() {
|
||||
return nil, groupCallInvalidErr()
|
||||
}
|
||||
if scope.call.Conference() {
|
||||
link := conferenceExportInviteLink(scope.call)
|
||||
if link == "" {
|
||||
return nil, groupCallInvalidErr()
|
||||
}
|
||||
return &tg.PhoneExportedGroupCallInvite{Link: link}, nil
|
||||
}
|
||||
if scope.call.InviteLink != "" {
|
||||
return &tg.PhoneExportedGroupCallInvite{Link: scope.call.InviteLink}, nil
|
||||
}
|
||||
if scope.channel.Username == "" {
|
||||
return nil, publicChannelMissingErr()
|
||||
}
|
||||
return &tg.PhoneExportedGroupCallInvite{Link: "https://telesrv.net/" + scope.channel.Username}, nil
|
||||
}
|
||||
|
||||
func conferenceExportInviteLink(call domain.GroupCall) string {
|
||||
if link := conferenceCanonicalInviteLink(call.InviteSlug); link != "" {
|
||||
return link
|
||||
}
|
||||
return call.InviteLink
|
||||
}
|
||||
|
||||
func conferenceCanonicalInviteLink(slug string) string {
|
||||
if slug == "" {
|
||||
return ""
|
||||
}
|
||||
return "https://telesrv.net/call/" + slug + "?slug=" + slug
|
||||
}
|
||||
|
||||
func (r *Router) onPhoneInviteConferenceCallParticipant(ctx context.Context, req *tg.PhoneInviteConferenceCallParticipantRequest) (tg.UpdatesClass, error) {
|
||||
if req == nil {
|
||||
return nil, inputRequestInvalidErr()
|
||||
}
|
||||
if r.deps.GroupCalls == nil || r.deps.Messages == nil {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
scope, err := r.groupCallScopeFrom(ctx, req.Call)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !scope.call.Conference() || !scope.call.Active() {
|
||||
return nil, groupCallInvalidErr()
|
||||
}
|
||||
target, found, err := r.userFromInput(ctx, scope.userID, req.UserID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !found || target.ID == 0 || target.Bot || target.ID == scope.userID {
|
||||
return nil, userIDInvalidErr()
|
||||
}
|
||||
if p, found, err := r.deps.GroupCalls.Participant(ctx, scope.call.ID, target.ID); err != nil {
|
||||
return nil, internalErr()
|
||||
} else if found && !p.Left {
|
||||
return nil, tgerr400("USER_ALREADY_PARTICIPANT")
|
||||
}
|
||||
recipientBlocked, err := r.peerBlocksUser(ctx, scope.userID, target.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := int(r.clock.Now().Unix())
|
||||
res, err := r.deps.Messages.SendPrivateText(ctx, scope.userID, domain.SendPrivateTextRequest{
|
||||
SenderUserID: scope.userID,
|
||||
RecipientUserID: target.ID,
|
||||
RandomID: conferenceInviteRandomID(scope.call.ID, target.ID, now),
|
||||
Media: &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindService,
|
||||
ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionConferenceCall,
|
||||
ConferenceCall: &domain.MessageConferenceCallAction{
|
||||
CallID: scope.call.ID,
|
||||
Video: req.Video,
|
||||
OtherParticipants: []domain.Peer{
|
||||
{Type: domain.PeerTypeUser, ID: scope.userID},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Date: now,
|
||||
OriginAuthKeyID: authKeyIDFromCtx(ctx),
|
||||
OriginSessionID: sessionIDFromCtx(ctx),
|
||||
RecipientBlocked: recipientBlocked,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, messageSendErr(err)
|
||||
}
|
||||
invite, err := r.deps.GroupCalls.CreateConferenceInvite(ctx, domain.GroupCallInvite{
|
||||
CallID: scope.call.ID,
|
||||
InviterUserID: scope.userID,
|
||||
InviteeUserID: target.ID,
|
||||
MessageID: res.RecipientMessage.ID,
|
||||
Status: domain.GroupCallInvitePending,
|
||||
Video: req.Video,
|
||||
CreatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, groupCallErr(err)
|
||||
}
|
||||
_ = invite
|
||||
users := r.tgUsersForIDs(ctx, scope.userID, []int64{scope.userID, target.ID})
|
||||
out := tgPrivateMessageUpdates(res.SenderEvent, res.SenderMessage, 0, false, users, nil)
|
||||
recipientUsers := r.tgUsersForIDs(ctx, target.ID, []int64{scope.userID, target.ID})
|
||||
r.pushUserMessage(ctx, target.ID, "conference invite",
|
||||
tgPrivateMessageUpdates(res.RecipientEvent, res.RecipientMessage, 0, false, recipientUsers, nil))
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Router) onPhoneDeclineConferenceCallInvite(ctx context.Context, msgID int) (tg.UpdatesClass, error) {
|
||||
if r.deps.GroupCalls == nil {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
userID, err := r.phoneRequireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
call, inv, found, err := r.deps.GroupCalls.GetByInviteMessage(ctx, userID, msgID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !found || !call.Conference() {
|
||||
return nil, msgIDInvalidErr()
|
||||
}
|
||||
now := int(r.clock.Now().Unix())
|
||||
if _, _, err := r.deps.GroupCalls.SetConferenceInviteStatus(ctx, call.ID, userID, msgID, domain.GroupCallInviteDeclined, now); err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
r.pushConferenceGroupCallUpdate(ctx, call)
|
||||
return r.groupCallUpdateContainer(ctx, userID, domain.Channel{},
|
||||
&tg.UpdateGroupCall{Call: tgGroupCall(call, userID, userID == call.CreatorUserID)}, []int64{inv.InviterUserID, inv.InviteeUserID}), nil
|
||||
}
|
||||
|
||||
func (r *Router) onPhoneDeleteConferenceCallParticipants(ctx context.Context, req *tg.PhoneDeleteConferenceCallParticipantsRequest) (tg.UpdatesClass, error) {
|
||||
if req == nil {
|
||||
return nil, inputRequestInvalidErr()
|
||||
}
|
||||
if len(req.IDs) == 0 || len(req.IDs) > 100 {
|
||||
return nil, limitInvalidErr()
|
||||
}
|
||||
if len(req.Block) > maxConferenceChainBlockBytes {
|
||||
return nil, limitInvalidErr()
|
||||
}
|
||||
scope, err := r.groupCallScopeFrom(ctx, req.Call)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !scope.call.Conference() {
|
||||
return nil, groupCallInvalidErr()
|
||||
}
|
||||
if req.OnlyLeft == req.Kick {
|
||||
return nil, inputRequestInvalidErr()
|
||||
}
|
||||
now := int(r.clock.Now().Unix())
|
||||
if req.OnlyLeft && !scope.canManage() {
|
||||
self, found, err := r.deps.GroupCalls.Participant(ctx, scope.call.ID, scope.userID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !found || self.Left {
|
||||
return nil, groupCallForbiddenErr()
|
||||
}
|
||||
}
|
||||
for _, targetID := range req.IDs {
|
||||
if targetID <= 0 {
|
||||
return nil, userIDInvalidErr()
|
||||
}
|
||||
if req.Kick && targetID != scope.userID && !scope.canManage() {
|
||||
return nil, groupCallForbiddenErr()
|
||||
}
|
||||
}
|
||||
result, err := r.deps.GroupCalls.RemoveConferenceParticipants(ctx, domain.RemoveConferenceCallParticipantsRequest{
|
||||
CallID: scope.call.ID,
|
||||
AuthorUserID: scope.userID,
|
||||
TargetUserIDs: req.IDs,
|
||||
OnlyLeft: req.OnlyLeft,
|
||||
Kick: req.Kick,
|
||||
Block: req.Block,
|
||||
Now: now,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, groupCallErr(err)
|
||||
}
|
||||
if result.ChainBlockAppended {
|
||||
block := result.ChainBlock
|
||||
r.pushConferenceChainBlocks(ctx, result.Call, block.SubChainID, [][]byte{block.Block}, block.Offset+1)
|
||||
}
|
||||
if len(result.ParticipantsChanged) > 0 {
|
||||
r.pushConferenceGroupCallParticipantsUpdate(ctx, result.Call, result.ParticipantsChanged)
|
||||
r.pushConferenceGroupCallUpdate(ctx, result.Call)
|
||||
}
|
||||
for _, p := range result.ParticipantsChanged {
|
||||
if r.deps.SFU != nil {
|
||||
_ = r.deps.SFU.Leave(ctx, scope.call.ID, p.UserID, sfu.EndpointMain)
|
||||
}
|
||||
}
|
||||
out := r.groupCallUpdateContainer(ctx, scope.userID, domain.Channel{},
|
||||
&tg.UpdateGroupCallParticipants{
|
||||
Call: &tg.InputGroupCall{ID: result.Call.ID, AccessHash: result.Call.AccessHash},
|
||||
Participants: tgGroupCallParticipants(result.ParticipantsChanged, scope.userID),
|
||||
Version: result.Call.Version,
|
||||
}, req.IDs)
|
||||
if result.ChainBlockAppended {
|
||||
block := result.ChainBlock
|
||||
out.Updates = append(out.Updates, conferenceChainBlocksUpdate(result.Call, block.SubChainID, [][]byte{block.Block}, block.Offset+1))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Router) onPhoneSendConferenceCallBroadcast(ctx context.Context, req *tg.PhoneSendConferenceCallBroadcastRequest) (tg.UpdatesClass, error) {
|
||||
if req == nil {
|
||||
return nil, inputRequestInvalidErr()
|
||||
}
|
||||
if len(req.Block) == 0 || len(req.Block) > maxConferenceChainBlockBytes {
|
||||
return nil, limitInvalidErr()
|
||||
}
|
||||
scope, err := r.groupCallScopeFrom(ctx, req.Call)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !scope.call.Conference() {
|
||||
return nil, groupCallInvalidErr()
|
||||
}
|
||||
if err := r.requireActiveConferenceParticipant(ctx, scope.call.ID, scope.userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := int(r.clock.Now().Unix())
|
||||
block, err := r.deps.GroupCalls.AppendChainBlock(ctx, domain.GroupCallChainBlock{
|
||||
CallID: scope.call.ID,
|
||||
SubChainID: 1,
|
||||
Offset: -1,
|
||||
AuthorUserID: scope.userID,
|
||||
Block: req.Block,
|
||||
CreatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, groupCallErr(err)
|
||||
}
|
||||
nextOffset := block.Offset + 1
|
||||
r.pushConferenceChainBlocks(ctx, scope.call, block.SubChainID, [][]byte{block.Block}, nextOffset)
|
||||
return r.conferenceChainBlocksUpdates(ctx, scope.userID, scope.call, block.SubChainID, [][]byte{block.Block}, nextOffset), nil
|
||||
}
|
||||
|
||||
func (r *Router) requireActiveConferenceParticipant(ctx context.Context, callID, userID int64) error {
|
||||
if r.deps.GroupCalls == nil {
|
||||
return notImplementedErr()
|
||||
}
|
||||
p, found, err := r.deps.GroupCalls.Participant(ctx, callID, userID)
|
||||
if err != nil {
|
||||
return internalErr()
|
||||
}
|
||||
if !found || p.Left {
|
||||
return groupCallJoinMissingErr()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Router) onPhoneGetGroupCallChainBlocks(ctx context.Context, req *tg.PhoneGetGroupCallChainBlocksRequest) (tg.UpdatesClass, error) {
|
||||
if req == nil {
|
||||
return nil, inputRequestInvalidErr()
|
||||
}
|
||||
if req.Offset < domain.GroupCallChainBlockLatestOffset {
|
||||
return nil, inputRequestInvalidErr()
|
||||
}
|
||||
limit := req.Limit
|
||||
if limit <= 0 || limit > maxConferenceChainBlocks {
|
||||
limit = maxConferenceChainBlocks
|
||||
}
|
||||
scope, err := r.groupCallScopeFrom(ctx, req.Call)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !scope.call.Conference() {
|
||||
return nil, groupCallInvalidErr()
|
||||
}
|
||||
page, err := r.deps.GroupCalls.ChainBlocks(ctx, scope.call.ID, req.SubChainID, req.Offset, limit)
|
||||
if err != nil {
|
||||
return nil, groupCallErr(err)
|
||||
}
|
||||
blocks := make([][]byte, 0, len(page.Blocks))
|
||||
for _, row := range page.Blocks {
|
||||
blocks = append(blocks, row.Block)
|
||||
}
|
||||
return r.conferenceChainBlocksUpdates(ctx, scope.userID, scope.call, req.SubChainID, blocks, page.NextOffset), nil
|
||||
}
|
||||
|
||||
func (r *Router) pushConferenceChainBlocks(ctx context.Context, call domain.GroupCall, subChainID int, blocks [][]byte, nextOffset int) {
|
||||
recipients := r.conferenceCallRecipients(ctx, call.ID)
|
||||
for _, viewerID := range recipients {
|
||||
r.pushUserMessage(ctx, viewerID, "conference chain blocks",
|
||||
r.conferenceChainBlocksUpdates(ctx, viewerID, call, subChainID, blocks, nextOffset))
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) conferenceChainBlocksUpdates(ctx context.Context, viewerID int64, call domain.GroupCall, subChainID int, blocks [][]byte, nextOffset int) *tg.Updates {
|
||||
return &tg.Updates{
|
||||
Updates: []tg.UpdateClass{conferenceChainBlocksUpdate(call, subChainID, blocks, nextOffset)},
|
||||
Users: r.tgUsersForIDs(ctx, viewerID, []int64{viewerID}),
|
||||
Date: int(r.clock.Now().Unix()),
|
||||
Seq: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func conferenceChainBlocksUpdate(call domain.GroupCall, subChainID int, blocks [][]byte, nextOffset int) *tg.UpdateGroupCallChainBlocks {
|
||||
return &tg.UpdateGroupCallChainBlocks{
|
||||
Call: &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash},
|
||||
SubChainID: subChainID,
|
||||
Blocks: conferenceServerBlocks(subChainID, blocks),
|
||||
NextOffset: nextOffset,
|
||||
}
|
||||
}
|
||||
|
||||
func conferenceServerBlocks(subChainID int, blocks [][]byte) [][]byte {
|
||||
out := make([][]byte, 0, len(blocks))
|
||||
for _, block := range blocks {
|
||||
out = append(out, conferenceServerBlock(subChainID, block))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func conferenceServerBlock(subChainID int, block []byte) []byte {
|
||||
out := append([]byte(nil), block...)
|
||||
if len(out) < 4 {
|
||||
return out
|
||||
}
|
||||
constructor := binary.LittleEndian.Uint32(out[:4])
|
||||
switch subChainID {
|
||||
case 0:
|
||||
if constructor == conferenceChainBlockConstructor {
|
||||
binary.LittleEndian.PutUint32(out[:4], conferenceChainBlockServerConstructor)
|
||||
}
|
||||
case 1:
|
||||
switch constructor {
|
||||
case conferenceBroadcastCommitConstructor:
|
||||
binary.LittleEndian.PutUint32(out[:4], conferenceBroadcastCommitServerConstructor)
|
||||
case conferenceBroadcastRevealConstructor:
|
||||
binary.LittleEndian.PutUint32(out[:4], conferenceBroadcastRevealServerConstructor)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func appendUpdates(dst *tg.Updates, src tg.UpdatesClass) {
|
||||
if dst == nil || src == nil {
|
||||
return
|
||||
}
|
||||
switch v := src.(type) {
|
||||
case *tg.Updates:
|
||||
dst.Updates = append(dst.Updates, v.Updates...)
|
||||
dst.Users = append(dst.Users, v.Users...)
|
||||
dst.Chats = append(dst.Chats, v.Chats...)
|
||||
case *tg.UpdatesCombined:
|
||||
dst.Updates = append(dst.Updates, v.Updates...)
|
||||
dst.Users = append(dst.Users, v.Users...)
|
||||
dst.Chats = append(dst.Chats, v.Chats...)
|
||||
}
|
||||
}
|
||||
|
||||
func conferenceInviteRandomID(callID, targetID int64, date int) int64 {
|
||||
id := int64(0x636f6e6663616c) // "confcal"
|
||||
id ^= callID << 11
|
||||
id ^= targetID << 3
|
||||
id ^= int64(date) << 29
|
||||
if id == 0 {
|
||||
return 0x636f6e66
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func authKeyIDFromCtx(ctx context.Context) [8]byte {
|
||||
if authKeyID, ok := RawAuthKeyIDFrom(ctx); ok {
|
||||
return authKeyID
|
||||
}
|
||||
return [8]byte{}
|
||||
}
|
||||
|
||||
func sessionIDFromCtx(ctx context.Context) int64 {
|
||||
if sessionID, ok := SessionIDFrom(ctx); ok {
|
||||
return sessionID
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (r *Router) logConferenceMessageFailure(callID int64, err error) {
|
||||
if err != nil {
|
||||
r.log.Warn("conference message", zap.Int64("call_id", callID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
890
internal/rpc/phone_conference_rpc_test.go
Normal file
890
internal/rpc/phone_conference_rpc_test.go
Normal file
|
|
@ -0,0 +1,890 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gotd/td/clock"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/gotd/td/tgerr"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
appgroupcalls "telesrv/internal/app/groupcalls"
|
||||
appmessages "telesrv/internal/app/messages"
|
||||
appphone "telesrv/internal/app/phone"
|
||||
appusers "telesrv/internal/app/users"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type conferenceFixture struct {
|
||||
ctx context.Context
|
||||
router *Router
|
||||
group *appgroupcalls.Service
|
||||
messages *appmessages.Service
|
||||
sessions *groupCallSessions
|
||||
alice domain.User
|
||||
bob domain.User
|
||||
carol domain.User
|
||||
clock *phoneTestClock
|
||||
}
|
||||
|
||||
func newConferenceFixture(t *testing.T) *conferenceFixture {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
dialogs := memory.NewDialogStore()
|
||||
messageStore := memory.NewMessageStore(dialogs)
|
||||
groupStore := memory.NewGroupCallStore()
|
||||
sessions := &groupCallSessions{}
|
||||
clk := &phoneTestClock{now: time.Unix(1_700_000_000, 0)}
|
||||
groupSvc := appgroupcalls.NewService(groupStore)
|
||||
messageSvc := appmessages.NewService(messageStore, dialogs)
|
||||
router := New(Config{GroupCallMaxParticipants: 8}, Deps{
|
||||
Users: appusers.NewService(users),
|
||||
Messages: messageSvc,
|
||||
GroupCalls: groupSvc,
|
||||
Phone: appphone.NewService(appphone.Config{}, appphone.WithClock(clk)),
|
||||
Sessions: sessions,
|
||||
}, zaptest.NewLogger(t), clk)
|
||||
mk := func(hash int64, phone, name string) domain.User {
|
||||
u, err := users.Create(ctx, domain.User{AccessHash: hash, Phone: phone, FirstName: name})
|
||||
if err != nil {
|
||||
t.Fatalf("create user %s: %v", name, err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
f := &conferenceFixture{ctx: ctx, router: router, group: groupSvc, messages: messageSvc, sessions: sessions, clock: clk}
|
||||
f.alice = mk(3001, "13700000001", "Alice")
|
||||
f.bob = mk(3002, "13700000002", "Bob")
|
||||
f.carol = mk(3003, "13700000003", "Carol")
|
||||
f.sessions.online = []int64{f.alice.ID, f.bob.ID, f.carol.ID}
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *conferenceFixture) userCtx(u domain.User, session int64) context.Context {
|
||||
return WithSessionID(WithUserID(f.ctx, u.ID), session)
|
||||
}
|
||||
|
||||
func joinConferenceForTest(t *testing.T, f *conferenceFixture, ctx context.Context, call tg.InputGroupCallClass, blockSuffix string, ssrc int32) {
|
||||
t.Helper()
|
||||
block := conferenceTestBlock(conferenceChainBlockConstructor, blockSuffix)
|
||||
req := &tg.PhoneJoinGroupCallRequest{
|
||||
Call: call,
|
||||
JoinAs: &tg.InputPeerSelf{},
|
||||
Params: groupCallJoinParams(t, ssrc),
|
||||
}
|
||||
req.SetBlock(block)
|
||||
if _, err := f.router.onPhoneJoinGroupCall(ctx, req); err != nil {
|
||||
t.Fatalf("join conference %s: %v", blockSuffix, err)
|
||||
}
|
||||
}
|
||||
|
||||
func optionalUpdate[T tg.UpdateClass](updates tg.UpdatesClass) (T, bool) {
|
||||
box, ok := updates.(*tg.Updates)
|
||||
if !ok {
|
||||
var zero T
|
||||
return zero, false
|
||||
}
|
||||
for _, u := range box.Updates {
|
||||
if v, ok := u.(T); ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
var zero T
|
||||
return zero, false
|
||||
}
|
||||
|
||||
func pushedDiscardedGroupCallUsers(records []phonePushRecord, callID int64) map[int64]bool {
|
||||
seen := map[int64]bool{}
|
||||
for _, rec := range records {
|
||||
box, ok := rec.msg.(*tg.Updates)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, raw := range box.Updates {
|
||||
update, ok := raw.(*tg.UpdateGroupCall)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
discarded, ok := update.Call.(*tg.GroupCallDiscarded)
|
||||
if ok && discarded.ID == callID {
|
||||
seen[rec.userID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return seen
|
||||
}
|
||||
|
||||
func TestConferenceCreateLinkAndGetBySlug(t *testing.T) {
|
||||
f := newConferenceFixture(t)
|
||||
ctx := f.userCtx(f.alice, 11)
|
||||
res, err := f.router.onPhoneCreateConferenceCall(ctx, &tg.PhoneCreateConferenceCallRequest{RandomID: 42})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference: %v", err)
|
||||
}
|
||||
update := findUpdate[*tg.UpdateGroupCall](t, res)
|
||||
call, ok := update.Call.(*tg.GroupCall)
|
||||
if !ok || !call.Conference || call.InviteLink == "" || !strings.Contains(call.InviteLink, "slug=") || !strings.HasPrefix(call.InviteLink, "https://telesrv.net/call/") {
|
||||
t.Fatalf("created call = %#v", update.Call)
|
||||
}
|
||||
slug := conferenceSlugFromLink(t, call.InviteLink)
|
||||
got, err := f.router.onPhoneGetGroupCall(ctx, &tg.PhoneGetGroupCallRequest{
|
||||
Call: &tg.InputGroupCallSlug{Slug: slug},
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get by slug: %v", err)
|
||||
}
|
||||
if got.Call.(*tg.GroupCall).ID != call.ID {
|
||||
t.Fatalf("get by slug id = %d, want %d", got.Call.(*tg.GroupCall).ID, call.ID)
|
||||
}
|
||||
again, err := f.router.onPhoneCreateConferenceCall(ctx, &tg.PhoneCreateConferenceCallRequest{RandomID: 42})
|
||||
if err != nil {
|
||||
t.Fatalf("repeat create conference: %v", err)
|
||||
}
|
||||
if findUpdate[*tg.UpdateGroupCall](t, again).Call.(*tg.GroupCall).ID != call.ID {
|
||||
t.Fatalf("random_id must be idempotent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConferenceExportGroupCallInviteReturnsConferenceLink(t *testing.T) {
|
||||
f := newConferenceFixture(t)
|
||||
ctx := f.userCtx(f.alice, 11)
|
||||
create, err := f.router.onPhoneCreateConferenceCall(ctx, &tg.PhoneCreateConferenceCallRequest{RandomID: 43})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference: %v", err)
|
||||
}
|
||||
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||
exported, err := f.router.onPhoneExportGroupCallInvite(ctx, &tg.PhoneExportGroupCallInviteRequest{
|
||||
Call: &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("export invite: %v", err)
|
||||
}
|
||||
if exported.Link != call.InviteLink || !strings.Contains(exported.Link, "slug=") {
|
||||
t.Fatalf("exported link = %q, want conference invite link %q", exported.Link, call.InviteLink)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConferenceExportGroupCallInviteReturnsPathSlug(t *testing.T) {
|
||||
f := newConferenceFixture(t)
|
||||
ctx := WithClientInfo(f.userCtx(f.alice, 11), ClientInfo{Type: ClientTypeAndroid, AppVersion: "12.8.1"})
|
||||
create, err := f.router.onPhoneCreateConferenceCall(ctx, &tg.PhoneCreateConferenceCallRequest{RandomID: 44})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference: %v", err)
|
||||
}
|
||||
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||
slug := conferenceSlugFromLink(t, call.InviteLink)
|
||||
exported, err := f.router.onPhoneExportGroupCallInvite(ctx, &tg.PhoneExportGroupCallInviteRequest{
|
||||
Call: &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("export invite: %v", err)
|
||||
}
|
||||
if exported.Link != call.InviteLink {
|
||||
t.Fatalf("exported link = %q, want stored canonical invite link %q", exported.Link, call.InviteLink)
|
||||
}
|
||||
if got := lastPathSegmentFromLink(t, exported.Link); got != slug {
|
||||
t.Fatalf("export path slug = %q, want %q (link %q)", got, slug, exported.Link)
|
||||
}
|
||||
if got := conferenceSlugFromLink(t, exported.Link); got != slug {
|
||||
t.Fatalf("export query slug = %q, want %q (link %q)", got, slug, exported.Link)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConferenceLinksNormalizeLegacyTMeLink(t *testing.T) {
|
||||
const slug = "legacy_slug-1"
|
||||
legacy := domain.GroupCall{
|
||||
ID: 1,
|
||||
AccessHash: 2,
|
||||
Kind: domain.GroupCallKindConference,
|
||||
State: domain.GroupCallStateActive,
|
||||
Version: 1,
|
||||
InviteSlug: slug,
|
||||
InviteLink: "https://t.me/call?slug=" + slug,
|
||||
}
|
||||
want := "https://telesrv.net/call/" + slug + "?slug=" + slug
|
||||
if got := conferenceExportInviteLink(legacy); got != want {
|
||||
t.Fatalf("export link = %q, want %q", got, want)
|
||||
}
|
||||
call := tgGroupCall(legacy, 0, false).(*tg.GroupCall)
|
||||
if call.InviteLink != want {
|
||||
t.Fatalf("tg group call invite link = %q, want %q", call.InviteLink, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConferenceJoinBroadcastAndGetChainBlocks(t *testing.T) {
|
||||
f := newConferenceFixture(t)
|
||||
ctx := f.userCtx(f.alice, 11)
|
||||
create, err := f.router.onPhoneCreateConferenceCall(ctx, &tg.PhoneCreateConferenceCallRequest{RandomID: 77})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference: %v", err)
|
||||
}
|
||||
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||
join, err := f.router.onPhoneJoinGroupCall(ctx, &tg.PhoneJoinGroupCallRequest{
|
||||
Call: input,
|
||||
JoinAs: &tg.InputPeerSelf{},
|
||||
Params: groupCallJoinParams(t, 9001),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("join conference: %v", err)
|
||||
}
|
||||
findUpdate[*tg.UpdateGroupCallConnection](t, join)
|
||||
participants := findUpdate[*tg.UpdateGroupCallParticipants](t, join)
|
||||
if len(participants.Participants) != 1 || !participants.Participants[0].Self {
|
||||
t.Fatalf("participants update = %+v", participants.Participants)
|
||||
}
|
||||
block := conferenceTestBlock(conferenceBroadcastCommitConstructor, "opaque-chain-block")
|
||||
broadcast, err := f.router.onPhoneSendConferenceCallBroadcast(ctx, &tg.PhoneSendConferenceCallBroadcastRequest{
|
||||
Call: input,
|
||||
Block: block,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("broadcast: %v", err)
|
||||
}
|
||||
chain := findUpdate[*tg.UpdateGroupCallChainBlocks](t, broadcast)
|
||||
if chain.SubChainID != 1 || chain.NextOffset != 1 || len(chain.Blocks) != 1 || conferenceTestConstructor(chain.Blocks[0]) != conferenceBroadcastCommitServerConstructor {
|
||||
t.Fatalf("chain update = %+v", chain)
|
||||
}
|
||||
list, err := f.router.onPhoneGetGroupCallChainBlocks(ctx, &tg.PhoneGetGroupCallChainBlocksRequest{
|
||||
Call: input, SubChainID: 1, Offset: 0, Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get chain blocks: %v", err)
|
||||
}
|
||||
got := findUpdate[*tg.UpdateGroupCallChainBlocks](t, list)
|
||||
if got.SubChainID != 1 || len(got.Blocks) != 1 || conferenceTestConstructor(got.Blocks[0]) != conferenceBroadcastCommitServerConstructor {
|
||||
t.Fatalf("get chain blocks = %+v", got)
|
||||
}
|
||||
nextBlock := conferenceTestBlock(conferenceBroadcastRevealConstructor, "opaque-chain-block-2")
|
||||
nextBroadcast, err := f.router.onPhoneSendConferenceCallBroadcast(ctx, &tg.PhoneSendConferenceCallBroadcastRequest{
|
||||
Call: input,
|
||||
Block: nextBlock,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second broadcast: %v", err)
|
||||
}
|
||||
nextChain := findUpdate[*tg.UpdateGroupCallChainBlocks](t, nextBroadcast)
|
||||
if nextChain.SubChainID != 1 || nextChain.NextOffset != 2 || len(nextChain.Blocks) != 1 || conferenceTestConstructor(nextChain.Blocks[0]) != conferenceBroadcastRevealServerConstructor {
|
||||
t.Fatalf("second chain update = %+v", nextChain)
|
||||
}
|
||||
latest, err := f.router.onPhoneGetGroupCallChainBlocks(ctx, &tg.PhoneGetGroupCallChainBlocksRequest{
|
||||
Call: input, SubChainID: 1, Offset: domain.GroupCallChainBlockLatestOffset, Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get latest chain block: %v", err)
|
||||
}
|
||||
gotLatest := findUpdate[*tg.UpdateGroupCallChainBlocks](t, latest)
|
||||
if gotLatest.SubChainID != 1 || gotLatest.NextOffset != 2 || len(gotLatest.Blocks) != 1 || conferenceTestConstructor(gotLatest.Blocks[0]) != conferenceBroadcastRevealServerConstructor {
|
||||
t.Fatalf("latest chain block = %+v", gotLatest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConferenceBroadcastRequiresActiveParticipant(t *testing.T) {
|
||||
f := newConferenceFixture(t)
|
||||
aliceCtx := f.userCtx(f.alice, 11)
|
||||
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 770})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference: %v", err)
|
||||
}
|
||||
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||
|
||||
f.sessions.reset()
|
||||
block := conferenceTestBlock(conferenceBroadcastCommitConstructor, "creator-not-joined-broadcast")
|
||||
res, err := f.router.onPhoneSendConferenceCallBroadcast(aliceCtx, &tg.PhoneSendConferenceCallBroadcastRequest{
|
||||
Call: input,
|
||||
Block: block,
|
||||
})
|
||||
if res != nil || !tgerr.Is(err, "GROUPCALL_JOIN_MISSING") {
|
||||
t.Fatalf("broadcast before join = %+v err=%v, want GROUPCALL_JOIN_MISSING", res, err)
|
||||
}
|
||||
if got := f.sessions.records(); len(got) != 0 {
|
||||
t.Fatalf("broadcast before join must not push updates, got %+v", got)
|
||||
}
|
||||
list, err := f.router.onPhoneGetGroupCallChainBlocks(aliceCtx, &tg.PhoneGetGroupCallChainBlocksRequest{
|
||||
Call: input, SubChainID: 1, Offset: 0, Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get chain blocks: %v", err)
|
||||
}
|
||||
chain := findUpdate[*tg.UpdateGroupCallChainBlocks](t, list)
|
||||
if chain.NextOffset != 0 || len(chain.Blocks) != 0 {
|
||||
t.Fatalf("chain after forbidden broadcast = %+v, want empty", chain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConferenceJoinBlockSeedsChainAndDuplicateJoinIsRejected(t *testing.T) {
|
||||
f := newConferenceFixture(t)
|
||||
aliceCtx := f.userCtx(f.alice, 11)
|
||||
bobCtx := f.userCtx(f.bob, 22)
|
||||
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 78})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference: %v", err)
|
||||
}
|
||||
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||
joinBlock := conferenceTestBlock(conferenceChainBlockConstructor, "join-chain-block")
|
||||
joinReq := &tg.PhoneJoinGroupCallRequest{
|
||||
Call: input,
|
||||
JoinAs: &tg.InputPeerSelf{},
|
||||
Params: groupCallJoinParams(t, 9002),
|
||||
}
|
||||
joinReq.SetBlock(joinBlock)
|
||||
join, err := f.router.onPhoneJoinGroupCall(aliceCtx, joinReq)
|
||||
if err != nil {
|
||||
t.Fatalf("join conference: %v", err)
|
||||
}
|
||||
findUpdate[*tg.UpdateGroupCallConnection](t, join)
|
||||
joinChain := findUpdate[*tg.UpdateGroupCallChainBlocks](t, join)
|
||||
if joinChain.SubChainID != 0 || joinChain.NextOffset != 1 || len(joinChain.Blocks) != 1 || conferenceTestConstructor(joinChain.Blocks[0]) != conferenceChainBlockServerConstructor {
|
||||
t.Fatalf("join chain update = %+v", joinChain)
|
||||
}
|
||||
|
||||
dupJoinReq := &tg.PhoneJoinGroupCallRequest{
|
||||
Call: &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)},
|
||||
JoinAs: &tg.InputPeerSelf{},
|
||||
Params: groupCallJoinParams(t, 9003),
|
||||
}
|
||||
dupJoinReq.SetBlock(append([]byte(nil), joinBlock...))
|
||||
dupJoin, err := f.router.onPhoneJoinGroupCall(bobCtx, dupJoinReq)
|
||||
if dupJoin != nil || !tgerr.Is(err, "CONF_WRITE_CHAIN_INVALID") {
|
||||
t.Fatalf("duplicate join = %+v err=%v, want CONF_WRITE_CHAIN_INVALID", dupJoin, err)
|
||||
}
|
||||
list, err := f.router.onPhoneGetGroupCallChainBlocks(aliceCtx, &tg.PhoneGetGroupCallChainBlocksRequest{
|
||||
Call: input, Offset: 0, Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get chain blocks: %v", err)
|
||||
}
|
||||
got := findUpdate[*tg.UpdateGroupCallChainBlocks](t, list)
|
||||
if got.NextOffset != 1 || len(got.Blocks) != 1 || conferenceTestConstructor(got.Blocks[0]) != conferenceChainBlockServerConstructor {
|
||||
t.Fatalf("get chain blocks after duplicate = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConferenceJoinReturnsSubmittedBlockOnly(t *testing.T) {
|
||||
f := newConferenceFixture(t)
|
||||
aliceCtx := f.userCtx(f.alice, 11)
|
||||
bobCtx := f.userCtx(f.bob, 22)
|
||||
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 790})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference: %v", err)
|
||||
}
|
||||
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||
aliceBlock := conferenceTestBlock(conferenceChainBlockConstructor, "alice-join-chain-block")
|
||||
aliceJoin := &tg.PhoneJoinGroupCallRequest{
|
||||
Call: input,
|
||||
JoinAs: &tg.InputPeerSelf{},
|
||||
Params: groupCallJoinParams(t, 9031),
|
||||
}
|
||||
aliceJoin.SetBlock(aliceBlock)
|
||||
if _, err := f.router.onPhoneJoinGroupCall(aliceCtx, aliceJoin); err != nil {
|
||||
t.Fatalf("alice join: %v", err)
|
||||
}
|
||||
|
||||
bobBlock := conferenceTestBlock(conferenceChainBlockConstructor, "bob-join-chain-block")
|
||||
bobJoin := &tg.PhoneJoinGroupCallRequest{
|
||||
Call: &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)},
|
||||
JoinAs: &tg.InputPeerSelf{},
|
||||
Params: groupCallJoinParams(t, 9032),
|
||||
}
|
||||
bobJoin.SetBlock(bobBlock)
|
||||
join, err := f.router.onPhoneJoinGroupCall(bobCtx, bobJoin)
|
||||
if err != nil {
|
||||
t.Fatalf("bob join: %v", err)
|
||||
}
|
||||
joinChain := findUpdate[*tg.UpdateGroupCallChainBlocks](t, join)
|
||||
if joinChain.SubChainID != 0 || joinChain.NextOffset != 2 || len(joinChain.Blocks) != 1 {
|
||||
t.Fatalf("bob join chain update = %+v, want only submitted block at next_offset=2", joinChain)
|
||||
}
|
||||
if conferenceTestConstructor(joinChain.Blocks[0]) != conferenceChainBlockServerConstructor || string(joinChain.Blocks[0][4:]) != string(bobBlock[4:]) {
|
||||
t.Fatalf("bob join block = %x, want server-form submitted block %x", joinChain.Blocks[0], bobBlock)
|
||||
}
|
||||
list, err := f.router.onPhoneGetGroupCallChainBlocks(aliceCtx, &tg.PhoneGetGroupCallChainBlocksRequest{
|
||||
Call: input, Offset: 0, Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get chain blocks: %v", err)
|
||||
}
|
||||
got := findUpdate[*tg.UpdateGroupCallChainBlocks](t, list)
|
||||
if got.NextOffset != 2 || len(got.Blocks) != 2 {
|
||||
t.Fatalf("persisted chain blocks = %+v, want full history through getGroupCallChainBlocks", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConferenceDuplicateJoinBlockDoesNotLeaveParticipantActive(t *testing.T) {
|
||||
f := newConferenceFixture(t)
|
||||
aliceCtx := f.userCtx(f.alice, 11)
|
||||
bobCtx := f.userCtx(f.bob, 22)
|
||||
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 79})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference: %v", err)
|
||||
}
|
||||
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||
joinBlock := conferenceTestBlock(conferenceChainBlockConstructor, "alice-join-chain-block")
|
||||
aliceJoin := &tg.PhoneJoinGroupCallRequest{
|
||||
Call: input,
|
||||
JoinAs: &tg.InputPeerSelf{},
|
||||
Params: groupCallJoinParams(t, 9011),
|
||||
}
|
||||
aliceJoin.SetBlock(joinBlock)
|
||||
if _, err := f.router.onPhoneJoinGroupCall(aliceCtx, aliceJoin); err != nil {
|
||||
t.Fatalf("alice join: %v", err)
|
||||
}
|
||||
|
||||
bobJoin := &tg.PhoneJoinGroupCallRequest{
|
||||
Call: &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)},
|
||||
JoinAs: &tg.InputPeerSelf{},
|
||||
Params: groupCallJoinParams(t, 9022),
|
||||
}
|
||||
bobJoin.SetBlock(append([]byte(nil), joinBlock...))
|
||||
if res, err := f.router.onPhoneJoinGroupCall(bobCtx, bobJoin); res != nil || !tgerr.Is(err, "CONF_WRITE_CHAIN_INVALID") {
|
||||
t.Fatalf("bob duplicate join = %+v err=%v, want CONF_WRITE_CHAIN_INVALID", res, err)
|
||||
}
|
||||
if p, found, err := f.group.Participant(f.ctx, call.ID, f.bob.ID); err != nil || (found && !p.Left) {
|
||||
t.Fatalf("bob participant after duplicate join = %+v found=%v err=%v, want absent or left", p, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConferenceDeleteParticipantsReturnsSubmittedBlock(t *testing.T) {
|
||||
f := newConferenceFixture(t)
|
||||
aliceCtx := f.userCtx(f.alice, 11)
|
||||
bobCtx := f.userCtx(f.bob, 22)
|
||||
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 791})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference: %v", err)
|
||||
}
|
||||
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||
aliceBlock := conferenceTestBlock(conferenceChainBlockConstructor, "alice-delete-seed-block")
|
||||
aliceJoin := &tg.PhoneJoinGroupCallRequest{Call: input, JoinAs: &tg.InputPeerSelf{}, Params: groupCallJoinParams(t, 9041)}
|
||||
aliceJoin.SetBlock(aliceBlock)
|
||||
if _, err := f.router.onPhoneJoinGroupCall(aliceCtx, aliceJoin); err != nil {
|
||||
t.Fatalf("alice join: %v", err)
|
||||
}
|
||||
bobBlock := conferenceTestBlock(conferenceChainBlockConstructor, "bob-delete-seed-block")
|
||||
bobJoin := &tg.PhoneJoinGroupCallRequest{Call: &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, JoinAs: &tg.InputPeerSelf{}, Params: groupCallJoinParams(t, 9042)}
|
||||
bobJoin.SetBlock(bobBlock)
|
||||
if _, err := f.router.onPhoneJoinGroupCall(bobCtx, bobJoin); err != nil {
|
||||
t.Fatalf("bob join: %v", err)
|
||||
}
|
||||
|
||||
removeBlock := conferenceTestBlock(conferenceChainBlockConstructor, "alice-remove-bob-block")
|
||||
res, err := f.router.onPhoneDeleteConferenceCallParticipants(aliceCtx, &tg.PhoneDeleteConferenceCallParticipantsRequest{
|
||||
Call: input,
|
||||
IDs: []int64{f.bob.ID},
|
||||
Kick: true,
|
||||
Block: removeBlock,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("delete conference participants: %v", err)
|
||||
}
|
||||
chain := findUpdate[*tg.UpdateGroupCallChainBlocks](t, res)
|
||||
if chain.SubChainID != 0 || chain.NextOffset != 3 || len(chain.Blocks) != 1 || conferenceTestConstructor(chain.Blocks[0]) != conferenceChainBlockServerConstructor {
|
||||
t.Fatalf("delete participants chain update = %+v", chain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConferenceOnlyLeftRemoveIsIdempotentAndAllowedForParticipant(t *testing.T) {
|
||||
f := newConferenceFixture(t)
|
||||
aliceCtx := f.userCtx(f.alice, 11)
|
||||
bobCtx := f.userCtx(f.bob, 22)
|
||||
carolCtx := f.userCtx(f.carol, 33)
|
||||
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 792})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference: %v", err)
|
||||
}
|
||||
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||
joinConferenceForTest(t, f, aliceCtx, input, "alice-only-left-join", 9051)
|
||||
joinConferenceForTest(t, f, bobCtx, &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, "bob-only-left-join", 9052)
|
||||
joinConferenceForTest(t, f, carolCtx, &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, "carol-only-left-join", 9053)
|
||||
if _, err := f.router.onPhoneLeaveGroupCall(carolCtx, &tg.PhoneLeaveGroupCallRequest{Call: input}); err != nil {
|
||||
t.Fatalf("carol leave: %v", err)
|
||||
}
|
||||
|
||||
aliceRemove := conferenceTestBlock(conferenceChainBlockConstructor, "alice-remove-left-carol")
|
||||
first, err := f.router.onPhoneDeleteConferenceCallParticipants(aliceCtx, &tg.PhoneDeleteConferenceCallParticipantsRequest{
|
||||
Call: input,
|
||||
IDs: []int64{f.carol.ID},
|
||||
OnlyLeft: true,
|
||||
Block: aliceRemove,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("alice only_left remove: %v", err)
|
||||
}
|
||||
firstChain := findUpdate[*tg.UpdateGroupCallChainBlocks](t, first)
|
||||
if firstChain.SubChainID != 0 || firstChain.NextOffset != 4 || len(firstChain.Blocks) != 1 {
|
||||
t.Fatalf("first only_left chain update = %+v", firstChain)
|
||||
}
|
||||
|
||||
bobRemove := conferenceTestBlock(conferenceChainBlockConstructor, "bob-stale-remove-left-carol")
|
||||
second, err := f.router.onPhoneDeleteConferenceCallParticipants(bobCtx, &tg.PhoneDeleteConferenceCallParticipantsRequest{
|
||||
Call: input,
|
||||
IDs: []int64{f.carol.ID},
|
||||
OnlyLeft: true,
|
||||
Block: bobRemove,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("bob duplicate only_left remove: %v", err)
|
||||
}
|
||||
if chain, ok := optionalUpdate[*tg.UpdateGroupCallChainBlocks](second); ok {
|
||||
t.Fatalf("duplicate only_left must not append stale chain block, got %+v", chain)
|
||||
}
|
||||
list, err := f.router.onPhoneGetGroupCallChainBlocks(aliceCtx, &tg.PhoneGetGroupCallChainBlocksRequest{
|
||||
Call: input, Offset: 0, Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get chain blocks: %v", err)
|
||||
}
|
||||
got := findUpdate[*tg.UpdateGroupCallChainBlocks](t, list)
|
||||
if got.NextOffset != 4 || len(got.Blocks) != 4 {
|
||||
t.Fatalf("chain after duplicate only_left = %+v, want exactly 3 joins + 1 remove", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConferenceForbiddenKickDoesNotAppendChainBlock(t *testing.T) {
|
||||
f := newConferenceFixture(t)
|
||||
aliceCtx := f.userCtx(f.alice, 11)
|
||||
bobCtx := f.userCtx(f.bob, 22)
|
||||
carolCtx := f.userCtx(f.carol, 33)
|
||||
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 793})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference: %v", err)
|
||||
}
|
||||
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||
joinConferenceForTest(t, f, aliceCtx, input, "alice-forbidden-kick-join", 9061)
|
||||
joinConferenceForTest(t, f, bobCtx, &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, "bob-forbidden-kick-join", 9062)
|
||||
joinConferenceForTest(t, f, carolCtx, &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, "carol-forbidden-kick-join", 9063)
|
||||
|
||||
staleBlock := conferenceTestBlock(conferenceChainBlockConstructor, "bob-forbidden-kick-carol")
|
||||
res, err := f.router.onPhoneDeleteConferenceCallParticipants(bobCtx, &tg.PhoneDeleteConferenceCallParticipantsRequest{
|
||||
Call: input,
|
||||
IDs: []int64{f.carol.ID},
|
||||
Kick: true,
|
||||
Block: staleBlock,
|
||||
})
|
||||
if res != nil || !tgerr.Is(err, "GROUPCALL_FORBIDDEN") {
|
||||
t.Fatalf("bob forbidden kick = %+v err=%v, want GROUPCALL_FORBIDDEN", res, err)
|
||||
}
|
||||
list, err := f.router.onPhoneGetGroupCallChainBlocks(aliceCtx, &tg.PhoneGetGroupCallChainBlocksRequest{
|
||||
Call: input, Offset: 0, Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get chain blocks: %v", err)
|
||||
}
|
||||
got := findUpdate[*tg.UpdateGroupCallChainBlocks](t, list)
|
||||
if got.NextOffset != 3 || len(got.Blocks) != 3 {
|
||||
t.Fatalf("chain after forbidden kick = %+v, want only join blocks", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConferenceLastLeaveDiscardsCall(t *testing.T) {
|
||||
f := newConferenceFixture(t)
|
||||
aliceCtx := f.userCtx(f.alice, 11)
|
||||
bobCtx := f.userCtx(f.bob, 22)
|
||||
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 794})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference: %v", err)
|
||||
}
|
||||
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||
joinConferenceForTest(t, f, aliceCtx, input, "alice-last-leave-join", 9071)
|
||||
joinConferenceForTest(t, f, bobCtx, &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, "bob-last-leave-join", 9072)
|
||||
|
||||
bobLeave, err := f.router.onPhoneLeaveGroupCall(bobCtx, &tg.PhoneLeaveGroupCallRequest{Call: input})
|
||||
if err != nil {
|
||||
t.Fatalf("bob leave: %v", err)
|
||||
}
|
||||
if update, ok := optionalUpdate[*tg.UpdateGroupCall](bobLeave); ok {
|
||||
if _, discarded := update.Call.(*tg.GroupCallDiscarded); discarded {
|
||||
t.Fatalf("first leave must keep conference active, got %+v", update.Call)
|
||||
}
|
||||
}
|
||||
aliceLeave, err := f.router.onPhoneLeaveGroupCall(aliceCtx, &tg.PhoneLeaveGroupCallRequest{Call: input})
|
||||
if err != nil {
|
||||
t.Fatalf("alice last leave: %v", err)
|
||||
}
|
||||
discardUpdate := findUpdate[*tg.UpdateGroupCall](t, aliceLeave)
|
||||
if discarded, ok := discardUpdate.Call.(*tg.GroupCallDiscarded); !ok || discarded.ID != call.ID {
|
||||
t.Fatalf("last leave update = %+v, want groupCallDiscarded %d", discardUpdate.Call, call.ID)
|
||||
}
|
||||
_, err = f.router.onPhoneJoinGroupCall(bobCtx, &tg.PhoneJoinGroupCallRequest{
|
||||
Call: input,
|
||||
JoinAs: &tg.InputPeerSelf{},
|
||||
Params: groupCallJoinParams(t, 9073),
|
||||
})
|
||||
if !tgerr.Is(err, "GROUPCALL_ALREADY_DISCARDED") {
|
||||
t.Fatalf("join after last leave err = %v, want GROUPCALL_ALREADY_DISCARDED", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConferenceDiscardRequiresCreator(t *testing.T) {
|
||||
f := newConferenceFixture(t)
|
||||
aliceCtx := f.userCtx(f.alice, 11)
|
||||
bobCtx := f.userCtx(f.bob, 22)
|
||||
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 794})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference: %v", err)
|
||||
}
|
||||
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||
joinConferenceForTest(t, f, aliceCtx, input, "alice-discard-permission-join", 9071)
|
||||
joinConferenceForTest(t, f, bobCtx, &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, "bob-discard-permission-join", 9072)
|
||||
|
||||
f.sessions.reset()
|
||||
res, err := f.router.onPhoneDiscardGroupCall(bobCtx, input)
|
||||
if res != nil || !tgerr.Is(err, "CHAT_ADMIN_REQUIRED") {
|
||||
t.Fatalf("bob discard = %+v err=%v, want CHAT_ADMIN_REQUIRED", res, err)
|
||||
}
|
||||
if got := f.sessions.records(); len(got) != 0 {
|
||||
t.Fatalf("forbidden discard must not push updates, got %+v", got)
|
||||
}
|
||||
got, err := f.router.onPhoneGetGroupCall(aliceCtx, &tg.PhoneGetGroupCallRequest{Call: input, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("get group call after forbidden discard: %v", err)
|
||||
}
|
||||
if _, ok := got.Call.(*tg.GroupCall); !ok {
|
||||
t.Fatalf("call after forbidden discard = %T, want active GroupCall", got.Call)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConferenceDiscardFanoutIncludesSlugJoinedParticipants(t *testing.T) {
|
||||
f := newConferenceFixture(t)
|
||||
aliceCtx := f.userCtx(f.alice, 11)
|
||||
bobCtx := f.userCtx(f.bob, 22)
|
||||
carolCtx := f.userCtx(f.carol, 33)
|
||||
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 795})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference: %v", err)
|
||||
}
|
||||
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||
joinConferenceForTest(t, f, aliceCtx, input, "alice-discard-fanout-join", 9081)
|
||||
joinConferenceForTest(t, f, bobCtx, &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, "bob-discard-fanout-join", 9082)
|
||||
joinConferenceForTest(t, f, carolCtx, &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, "carol-discard-fanout-join", 9083)
|
||||
|
||||
f.sessions.reset()
|
||||
discard, err := f.router.onPhoneDiscardGroupCall(aliceCtx, input)
|
||||
if err != nil {
|
||||
t.Fatalf("discard conference: %v", err)
|
||||
}
|
||||
if _, ok := findUpdate[*tg.UpdateGroupCall](t, discard).Call.(*tg.GroupCallDiscarded); !ok {
|
||||
t.Fatalf("discard response missing groupCallDiscarded")
|
||||
}
|
||||
seen := pushedDiscardedGroupCallUsers(f.sessions.records(), call.ID)
|
||||
for _, user := range []domain.User{f.alice, f.bob, f.carol} {
|
||||
if !seen[user.ID] {
|
||||
t.Fatalf("discard fanout missing user %d, seen=%v records=%+v", user.ID, seen, f.sessions.records())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConferenceDiscardAllowsHistoricalParticipantCleanupRPCs(t *testing.T) {
|
||||
f := newConferenceFixture(t)
|
||||
aliceCtx := f.userCtx(f.alice, 11)
|
||||
bobCtx := f.userCtx(f.bob, 22)
|
||||
carolCtx := f.userCtx(f.carol, 33)
|
||||
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 796})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference: %v", err)
|
||||
}
|
||||
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||
slugCall := &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}
|
||||
joinConferenceForTest(t, f, aliceCtx, input, "alice-discard-cleanup-join", 9091)
|
||||
joinConferenceForTest(t, f, bobCtx, slugCall, "bob-discard-cleanup-join", 9092)
|
||||
joinConferenceForTest(t, f, carolCtx, slugCall, "carol-discard-cleanup-join", 9093)
|
||||
|
||||
if _, err := f.router.onPhoneDiscardGroupCall(aliceCtx, input); err != nil {
|
||||
t.Fatalf("discard conference: %v", err)
|
||||
}
|
||||
got, err := f.router.onPhoneGetGroupCall(bobCtx, &tg.PhoneGetGroupCallRequest{Call: input, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("bob get discarded group call: %v", err)
|
||||
}
|
||||
if _, ok := got.Call.(*tg.GroupCallDiscarded); !ok {
|
||||
t.Fatalf("bob call after discard = %T, want GroupCallDiscarded", got.Call)
|
||||
}
|
||||
ssrcs, err := f.router.onPhoneCheckGroupCall(bobCtx, &tg.PhoneCheckGroupCallRequest{
|
||||
Call: input,
|
||||
Sources: []int{9092},
|
||||
})
|
||||
if err != nil || len(ssrcs) != 0 {
|
||||
t.Fatalf("bob check discarded group call = %v err=%v, want empty no error", ssrcs, err)
|
||||
}
|
||||
chain, err := f.router.onPhoneGetGroupCallChainBlocks(bobCtx, &tg.PhoneGetGroupCallChainBlocksRequest{
|
||||
Call: input,
|
||||
SubChainID: 0,
|
||||
Offset: 0,
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("bob get chain blocks after discard: %v", err)
|
||||
}
|
||||
if blocks := findUpdate[*tg.UpdateGroupCallChainBlocks](t, chain).Blocks; len(blocks) != 3 {
|
||||
t.Fatalf("bob cleanup chain blocks len=%d, want 3", len(blocks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConferenceInviteMessageResolvesInputGroupCallInviteMessage(t *testing.T) {
|
||||
f := newConferenceFixture(t)
|
||||
aliceCtx := f.userCtx(f.alice, 11)
|
||||
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 88})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference: %v", err)
|
||||
}
|
||||
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||
invite, err := f.router.onPhoneInviteConferenceCallParticipant(aliceCtx, &tg.PhoneInviteConferenceCallParticipantRequest{
|
||||
Call: input,
|
||||
UserID: &tg.InputUser{UserID: f.bob.ID, AccessHash: f.bob.AccessHash},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("invite conference: %v", err)
|
||||
}
|
||||
msg := findUpdate[*tg.UpdateNewMessage](t, invite).Message.(*tg.MessageService)
|
||||
if _, ok := msg.Action.(*tg.MessageActionConferenceCall); !ok {
|
||||
t.Fatalf("invite action = %T", msg.Action)
|
||||
}
|
||||
history, err := f.messages.GetHistory(f.ctx, f.bob.ID, domain.MessageFilter{
|
||||
HasPeer: true,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: f.alice.ID},
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil || len(history.Messages) != 1 {
|
||||
t.Fatalf("bob history len=%d err=%v", len(history.Messages), err)
|
||||
}
|
||||
bobMsgID := history.Messages[0].ID
|
||||
joinConferenceForTest(t, f, aliceCtx, input, "alice-invite-message-broadcast-join", 9091)
|
||||
got, err := f.router.onPhoneGetGroupCall(f.userCtx(f.bob, 22), &tg.PhoneGetGroupCallRequest{
|
||||
Call: &tg.InputGroupCallInviteMessage{MsgID: bobMsgID},
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get by invite message: %v", err)
|
||||
}
|
||||
if got.Call.(*tg.GroupCall).ID != call.ID {
|
||||
t.Fatalf("invite message call id = %d, want %d", got.Call.(*tg.GroupCall).ID, call.ID)
|
||||
}
|
||||
block := conferenceTestBlock(conferenceBroadcastCommitConstructor, "invite-message-chain-block")
|
||||
if _, err := f.router.onPhoneSendConferenceCallBroadcast(aliceCtx, &tg.PhoneSendConferenceCallBroadcastRequest{
|
||||
Call: input,
|
||||
Block: block,
|
||||
}); err != nil {
|
||||
t.Fatalf("broadcast invite message chain block: %v", err)
|
||||
}
|
||||
chain, err := f.router.onPhoneGetGroupCallChainBlocks(f.userCtx(f.bob, 22), &tg.PhoneGetGroupCallChainBlocksRequest{
|
||||
Call: &tg.InputGroupCallInviteMessage{MsgID: bobMsgID},
|
||||
SubChainID: 1,
|
||||
Offset: domain.GroupCallChainBlockLatestOffset,
|
||||
Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get latest by invite message: %v", err)
|
||||
}
|
||||
latest := findUpdate[*tg.UpdateGroupCallChainBlocks](t, chain)
|
||||
if latest.SubChainID != 1 || latest.NextOffset != 1 || len(latest.Blocks) != 1 || conferenceTestConstructor(latest.Blocks[0]) != conferenceBroadcastCommitServerConstructor {
|
||||
t.Fatalf("invite message latest block = %+v", latest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneDiscardMigrateConferenceCarriesSlug(t *testing.T) {
|
||||
f := newConferenceFixture(t)
|
||||
aliceCtx := f.userCtx(f.alice, 11)
|
||||
bobCtx := f.userCtx(f.bob, 22)
|
||||
ga, gaHash, gb := phoneTestKeys()
|
||||
requested, err := f.router.onPhoneRequestCall(aliceCtx, &tg.PhoneRequestCallRequest{
|
||||
UserID: &tg.InputUser{UserID: f.bob.ID, AccessHash: f.bob.AccessHash},
|
||||
RandomID: 9,
|
||||
GAHash: gaHash,
|
||||
Protocol: phoneTestProtocol(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("request call: %v", err)
|
||||
}
|
||||
peer := tg.InputPhoneCall{ID: requested.PhoneCall.(*tg.PhoneCallWaiting).ID, AccessHash: requested.PhoneCall.(*tg.PhoneCallWaiting).AccessHash}
|
||||
if _, err := f.router.onPhoneAcceptCall(bobCtx, &tg.PhoneAcceptCallRequest{Peer: peer, GB: gb, Protocol: phoneTestProtocol()}); err != nil {
|
||||
t.Fatalf("accept call: %v", err)
|
||||
}
|
||||
if _, err := f.router.onPhoneConfirmCall(aliceCtx, &tg.PhoneConfirmCallRequest{Peer: peer, GA: ga, KeyFingerprint: 123, Protocol: phoneTestProtocol()}); err != nil {
|
||||
t.Fatalf("confirm call: %v", err)
|
||||
}
|
||||
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 99})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference: %v", err)
|
||||
}
|
||||
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||
slug := conferenceSlugFromLink(t, call.InviteLink)
|
||||
updates, err := f.router.onPhoneDiscardCall(aliceCtx, &tg.PhoneDiscardCallRequest{
|
||||
Peer: peer,
|
||||
Duration: 1,
|
||||
Reason: &tg.PhoneCallDiscardReasonMigrateConferenceCall{Slug: slug},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("discard migrate: %v", err)
|
||||
}
|
||||
discarded := findUpdate[*tg.UpdatePhoneCall](t, updates).PhoneCall.(*tg.PhoneCallDiscarded)
|
||||
reason, ok := discarded.Reason.(*tg.PhoneCallDiscardReasonMigrateConferenceCall)
|
||||
if !ok || reason.Slug != slug {
|
||||
t.Fatalf("discard reason = %#v, want slug %q", discarded.Reason, slug)
|
||||
}
|
||||
}
|
||||
|
||||
var _ clock.Clock = (*phoneTestClock)(nil)
|
||||
|
||||
func conferenceSlugFromLink(t *testing.T, link string) string {
|
||||
t.Helper()
|
||||
u, err := url.Parse(link)
|
||||
if err == nil {
|
||||
if slug := u.Query().Get("slug"); slug != "" {
|
||||
return slug
|
||||
}
|
||||
}
|
||||
idx := strings.LastIndex(link, "slug=")
|
||||
if idx < 0 {
|
||||
t.Fatalf("link %q does not contain slug", link)
|
||||
}
|
||||
return link[idx+len("slug="):]
|
||||
}
|
||||
|
||||
func conferenceTestBlock(constructor uint32, suffix string) []byte {
|
||||
block := make([]byte, 4+len(suffix))
|
||||
binary.LittleEndian.PutUint32(block[:4], constructor)
|
||||
copy(block[4:], suffix)
|
||||
return block
|
||||
}
|
||||
|
||||
func conferenceTestConstructor(block []byte) uint32 {
|
||||
if len(block) < 4 {
|
||||
return 0
|
||||
}
|
||||
return binary.LittleEndian.Uint32(block[:4])
|
||||
}
|
||||
|
||||
func lastPathSegmentFromLink(t *testing.T, link string) string {
|
||||
t.Helper()
|
||||
u, err := url.Parse(link)
|
||||
if err != nil {
|
||||
t.Fatalf("parse link %q: %v", link, err)
|
||||
}
|
||||
segments := strings.Split(strings.Trim(u.EscapedPath(), "/"), "/")
|
||||
if len(segments) == 0 || segments[len(segments)-1] == "" {
|
||||
t.Fatalf("link %q has no path segment", link)
|
||||
}
|
||||
segment, err := url.PathUnescape(segments[len(segments)-1])
|
||||
if err != nil {
|
||||
t.Fatalf("decode path segment in %q: %v", link, err)
|
||||
}
|
||||
return segment
|
||||
}
|
||||
|
|
@ -27,6 +27,8 @@ func groupCallErr(err error) error {
|
|||
return groupCallSSRCDuplicateErr()
|
||||
case errors.Is(err, domain.ErrGroupCallNotJoined):
|
||||
return groupCallJoinMissingErr()
|
||||
case errors.Is(err, domain.ErrConferenceChainInvalid):
|
||||
return confWriteChainInvalidErr()
|
||||
default:
|
||||
return internalErr()
|
||||
}
|
||||
|
|
@ -41,30 +43,69 @@ type groupCallScope struct {
|
|||
}
|
||||
|
||||
func (s *groupCallScope) canManage() bool {
|
||||
if s.call.Conference() {
|
||||
return s.userID != 0 && s.userID == s.call.CreatorUserID
|
||||
}
|
||||
return channelMemberIsAdmin(s.member)
|
||||
}
|
||||
|
||||
// groupCallScopeFrom 解析 InputGroupCallClass(仅 id+access_hash 变体;slug/
|
||||
// inviteMessage 属 conference 路径,返回 GROUPCALL_INVALID)并校验成员资格。
|
||||
// groupCallScopeFrom 解析 InputGroupCallClass 并校验访问权。普通 group call 继续
|
||||
// 走频道成员资格;conference call 走 creator/participant/invite/slug 访问模型。
|
||||
func (r *Router) groupCallScopeFrom(ctx context.Context, in tg.InputGroupCallClass) (*groupCallScope, error) {
|
||||
if r.deps.GroupCalls == nil || r.deps.Channels == nil {
|
||||
if r.deps.GroupCalls == nil {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
userID, err := r.phoneRequireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
callID, accessHash, err := inputGroupCallRef(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
call, found, err := r.deps.GroupCalls.Get(ctx, callID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !found || call.AccessHash != accessHash {
|
||||
var call domain.GroupCall
|
||||
var found bool
|
||||
allowBySlug := false
|
||||
switch v := in.(type) {
|
||||
case *tg.InputGroupCall:
|
||||
call, found, err = r.deps.GroupCalls.Get(ctx, v.ID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !found || call.AccessHash != v.AccessHash {
|
||||
return nil, groupCallInvalidErr()
|
||||
}
|
||||
case *tg.InputGroupCallSlug:
|
||||
call, found, err = r.deps.GroupCalls.GetBySlug(ctx, v.Slug)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !found || !call.Conference() {
|
||||
return nil, groupCallInvalidErr()
|
||||
}
|
||||
allowBySlug = true
|
||||
case *tg.InputGroupCallInviteMessage:
|
||||
call, _, found, err = r.deps.GroupCalls.GetByInviteMessage(ctx, userID, v.MsgID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !found || !call.Conference() {
|
||||
return nil, groupCallInvalidErr()
|
||||
}
|
||||
default:
|
||||
return nil, groupCallInvalidErr()
|
||||
}
|
||||
if call.Conference() {
|
||||
if !allowBySlug {
|
||||
allowed, err := r.conferenceCallCanAccess(ctx, call.ID, userID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !allowed {
|
||||
return nil, groupCallForbiddenErr()
|
||||
}
|
||||
}
|
||||
return &groupCallScope{userID: userID, call: call}, nil
|
||||
}
|
||||
if r.deps.Channels == nil {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
view, err := r.deps.Channels.GetChannel(ctx, userID, call.ChannelID)
|
||||
if err != nil {
|
||||
return nil, groupCallForbiddenErr()
|
||||
|
|
@ -75,6 +116,19 @@ func (r *Router) groupCallScopeFrom(ctx context.Context, in tg.InputGroupCallCla
|
|||
return &groupCallScope{userID: userID, call: call, channel: view.Channel, member: view.Self}, nil
|
||||
}
|
||||
|
||||
func (r *Router) conferenceCallCanAccess(ctx context.Context, callID, userID int64) (bool, error) {
|
||||
recipients, err := r.deps.GroupCalls.ConferenceRecipients(ctx, callID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, id := range recipients {
|
||||
if id == userID {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (r *Router) onPhoneCreateGroupCall(ctx context.Context, req *tg.PhoneCreateGroupCallRequest) (tg.UpdatesClass, error) {
|
||||
if req == nil {
|
||||
return nil, inputRequestInvalidErr()
|
||||
|
|
@ -175,6 +229,14 @@ func (r *Router) onPhoneJoinGroupCall(ctx context.Context, req *tg.PhoneJoinGrou
|
|||
}
|
||||
}
|
||||
now := int(r.clock.Now().Unix())
|
||||
var publicKey []byte
|
||||
if pk, ok := req.GetPublicKey(); ok {
|
||||
publicKey = append([]byte(nil), pk[:]...)
|
||||
}
|
||||
var joinBlock []byte
|
||||
if block, ok := req.GetBlock(); ok {
|
||||
joinBlock = append([]byte(nil), block...)
|
||||
}
|
||||
// 视频内部状态:endpoint 服务端铸造(join 响应 video.endpoint 与日后
|
||||
// participant.video.endpoint 必须逐字节一致);ssrc-groups 无论摄像头开关都
|
||||
// 存档——video_stopped=false(join flag 或后续 self-edit)时原样回放。
|
||||
|
|
@ -190,6 +252,8 @@ func (r *Router) onPhoneJoinGroupCall(ctx context.Context, req *tg.PhoneJoinGrou
|
|||
SSRC: ssrc,
|
||||
Muted: req.Muted,
|
||||
IsAdmin: scope.canManage(),
|
||||
PublicKey: publicKey,
|
||||
JoinBlock: joinBlock,
|
||||
VideoJSON: encodeVideoState(videoState),
|
||||
Now: now,
|
||||
})
|
||||
|
|
@ -215,6 +279,25 @@ func (r *Router) onPhoneJoinGroupCall(ctx context.Context, req *tg.PhoneJoinGrou
|
|||
_, _ = r.deps.GroupCalls.Leave(ctx, scope.call.ID, scope.userID, now)
|
||||
return nil, internalErr()
|
||||
}
|
||||
var conferenceJoinBlock domain.GroupCallChainBlock
|
||||
var hasConferenceJoinBlock bool
|
||||
if scope.call.Conference() && len(joinBlock) > 0 {
|
||||
block, err := r.deps.GroupCalls.AppendChainBlock(ctx, domain.GroupCallChainBlock{
|
||||
CallID: scope.call.ID,
|
||||
SubChainID: 0,
|
||||
Offset: -1,
|
||||
AuthorUserID: scope.userID,
|
||||
Block: joinBlock,
|
||||
CreatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
_ = sfuService.Leave(ctx, scope.call.ID, scope.userID, sfu.EndpointMain)
|
||||
_, _ = r.deps.GroupCalls.Leave(ctx, scope.call.ID, scope.userID, now)
|
||||
return nil, groupCallErr(err)
|
||||
}
|
||||
conferenceJoinBlock = block
|
||||
hasConferenceJoinBlock = true
|
||||
}
|
||||
// 扇出给房间/在线群成员(操作者其它设备含其中;本设备从 RPC 返回拿)。
|
||||
channel := r.groupCallMutationFanout(ctx, scope.channel, mut)
|
||||
// 响应(TDesktop 从本 RPC 返回的 Updates 摘取 updateGroupCallConnection,不会等推送):
|
||||
|
|
@ -226,8 +309,16 @@ func (r *Router) onPhoneJoinGroupCall(ctx context.Context, req *tg.PhoneJoinGrou
|
|||
}, []int64{scope.userID})
|
||||
out.Updates = append(out.Updates, &tg.UpdateGroupCallConnection{Params: tg.DataJSON{Data: params}})
|
||||
callUpdate := &tg.UpdateGroupCall{Call: tgGroupCall(mut.Call, scope.userID, scope.canManage())}
|
||||
callUpdate.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
|
||||
if channel.ID != 0 {
|
||||
callUpdate.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
|
||||
}
|
||||
out.Updates = append(out.Updates, callUpdate)
|
||||
if hasConferenceJoinBlock {
|
||||
nextOffset := conferenceJoinBlock.Offset + 1
|
||||
blocks := [][]byte{conferenceJoinBlock.Block}
|
||||
r.pushConferenceChainBlocks(ctx, mut.Call, conferenceJoinBlock.SubChainID, blocks, nextOffset)
|
||||
out.Updates = append(out.Updates, conferenceChainBlocksUpdate(mut.Call, conferenceJoinBlock.SubChainID, blocks, nextOffset))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
|
@ -253,12 +344,16 @@ func (r *Router) onPhoneLeaveGroupCall(ctx context.Context, req *tg.PhoneLeaveGr
|
|||
_ = r.deps.SFU.Leave(ctx, scope.call.ID, scope.userID, sfu.EndpointMain)
|
||||
}
|
||||
channel := r.groupCallMutationFanout(ctx, scope.channel, mut)
|
||||
return r.groupCallUpdateContainer(ctx, scope.userID, channel,
|
||||
out := r.groupCallUpdateContainer(ctx, scope.userID, channel,
|
||||
&tg.UpdateGroupCallParticipants{
|
||||
Call: &tg.InputGroupCall{ID: mut.Call.ID, AccessHash: mut.Call.AccessHash},
|
||||
Participants: tgGroupCallParticipants([]domain.GroupCallParticipant{mut.Participant}, scope.userID),
|
||||
Version: mut.Call.Version,
|
||||
}, []int64{scope.userID}), nil
|
||||
}, []int64{scope.userID})
|
||||
if mut.Call.Conference() && !mut.Call.Active() {
|
||||
out.Updates = append(out.Updates, groupCallUpdateFor(domain.Channel{}, mut.Call, scope.userID, scope.userID == mut.Call.CreatorUserID))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Router) onPhoneDiscardGroupCall(ctx context.Context, in tg.InputGroupCallClass) (tg.UpdatesClass, error) {
|
||||
|
|
@ -270,13 +365,18 @@ func (r *Router) onPhoneDiscardGroupCall(ctx context.Context, in tg.InputGroupCa
|
|||
return nil, tgerr400("CHAT_ADMIN_REQUIRED")
|
||||
}
|
||||
now := int(r.clock.Now().Unix())
|
||||
call, _, err := r.deps.GroupCalls.Discard(ctx, scope.call.ID, now)
|
||||
call, activeBeforeDiscard, err := r.deps.GroupCalls.Discard(ctx, scope.call.ID, now)
|
||||
if err != nil {
|
||||
return nil, groupCallErr(err)
|
||||
}
|
||||
if r.deps.SFU != nil {
|
||||
_ = r.deps.SFU.CloseRoom(ctx, call.ID)
|
||||
}
|
||||
if call.Conference() {
|
||||
r.pushConferenceGroupCallUpdateTo(ctx, call, groupCallParticipantUserIDs(activeBeforeDiscard))
|
||||
return r.groupCallUpdateContainer(ctx, scope.userID, domain.Channel{},
|
||||
groupCallUpdateFor(domain.Channel{}, call, scope.userID, true), nil), nil
|
||||
}
|
||||
// 清 channel 关联 + ended 服务消息(带 duration)。
|
||||
channel := scope.channel
|
||||
if updated, err := r.deps.Channels.SetActiveCall(ctx, channel.ID, 0, 0, false); err == nil {
|
||||
|
|
@ -328,11 +428,15 @@ func (r *Router) onPhoneGetGroupCall(ctx context.Context, req *tg.PhoneGetGroupC
|
|||
for _, p := range page.Participants {
|
||||
userIDs = append(userIDs, p.UserID)
|
||||
}
|
||||
chats := []tg.ChatClass{}
|
||||
if !scope.call.Conference() {
|
||||
chats = append(chats, tgChannel(scope.userID, scope.channel, &scope.member))
|
||||
}
|
||||
return &tg.PhoneGroupCall{
|
||||
Call: tgGroupCall(scope.call, scope.userID, scope.canManage()),
|
||||
Participants: tgGroupCallParticipants(page.Participants, scope.userID),
|
||||
ParticipantsNextOffset: page.NextOffset,
|
||||
Chats: []tg.ChatClass{tgChannel(scope.userID, scope.channel, &scope.member)},
|
||||
Chats: chats,
|
||||
Users: r.tgUsersForIDs(ctx, scope.userID, userIDs),
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -358,11 +462,15 @@ func (r *Router) onPhoneGetGroupParticipants(ctx context.Context, req *tg.PhoneG
|
|||
userIDs = append(userIDs, p.UserID)
|
||||
}
|
||||
// 响应 version=当前值:客户端 version 跳号后据此重建本地状态并恢复增量应用。
|
||||
chats := []tg.ChatClass{}
|
||||
if !scope.call.Conference() {
|
||||
chats = append(chats, tgChannel(scope.userID, scope.channel, &scope.member))
|
||||
}
|
||||
return &tg.PhoneGroupParticipants{
|
||||
Count: page.Count,
|
||||
Participants: tgGroupCallParticipants(page.Participants, scope.userID),
|
||||
NextOffset: page.NextOffset,
|
||||
Chats: []tg.ChatClass{tgChannel(scope.userID, scope.channel, &scope.member)},
|
||||
Chats: chats,
|
||||
Users: r.tgUsersForIDs(ctx, scope.userID, userIDs),
|
||||
Version: page.Version,
|
||||
}, nil
|
||||
|
|
@ -417,6 +525,8 @@ func (r *Router) onPhoneCheckGroupCall(ctx context.Context, req *tg.PhoneCheckGr
|
|||
|
||||
func groupCallUpdateFor(channel domain.Channel, call domain.GroupCall, viewerUserID int64, canManage bool) *tg.UpdateGroupCall {
|
||||
update := &tg.UpdateGroupCall{Call: tgGroupCall(call, viewerUserID, canManage)}
|
||||
update.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
|
||||
if channel.ID != 0 {
|
||||
update.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
|
||||
}
|
||||
return update
|
||||
}
|
||||
|
|
|
|||
|
|
@ -276,6 +276,9 @@ func (r *Router) onPhoneInviteToGroupCall(ctx context.Context, req *tg.PhoneInvi
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if scope.call.Conference() {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
if len(req.Users) == 0 || len(req.Users) > maxInviteToGroupCallUsers {
|
||||
return nil, limitInvalidErr()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,20 @@ type groupCallSessions struct {
|
|||
|
||||
func (s *groupCallSessions) IsUserOnline(userID int64) bool { return false }
|
||||
func (s *groupCallSessions) OnlineUserIDsForCandidates(candidateUserIDs []int64, limit int) []int64 {
|
||||
return nil
|
||||
online := map[int64]struct{}{}
|
||||
for _, id := range s.online {
|
||||
online[id] = struct{}{}
|
||||
}
|
||||
out := make([]int64, 0, len(candidateUserIDs))
|
||||
for _, id := range candidateUserIDs {
|
||||
if _, ok := online[id]; ok {
|
||||
out = append(out, id)
|
||||
if limit > 0 && len(out) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (s *groupCallSessions) TrackChannelInterest([8]byte, int64, int64, []int64) {}
|
||||
func (s *groupCallSessions) ClearChannelInterest([8]byte, int64, int64) {}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ func (r *Router) groupCallUpdateContainer(ctx context.Context, viewerUserID int6
|
|||
|
||||
// pushGroupCallUpdate 把 updateGroupCall(call 行变化)推给在线群成员。
|
||||
func (r *Router) pushGroupCallUpdate(ctx context.Context, channel domain.Channel, call domain.GroupCall) {
|
||||
if call.Conference() {
|
||||
r.pushConferenceGroupCallUpdate(ctx, call)
|
||||
return
|
||||
}
|
||||
recipients := r.groupCallOnlineRecipients(channel.ID)
|
||||
for _, viewerID := range recipients {
|
||||
update := &tg.UpdateGroupCall{Call: tgGroupCall(call, viewerID, false)}
|
||||
|
|
@ -54,6 +58,10 @@ func (r *Router) pushGroupCallUpdate(ctx context.Context, channel domain.Channel
|
|||
// pushGroupCallParticipantsUpdate 把参与者增量(version=N)推给在线群成员。
|
||||
// 每个 viewer 单独构建:participant.Self flag 是 per-viewer 的。
|
||||
func (r *Router) pushGroupCallParticipantsUpdate(ctx context.Context, channel domain.Channel, call domain.GroupCall, rows []domain.GroupCallParticipant) {
|
||||
if call.Conference() {
|
||||
r.pushConferenceGroupCallParticipantsUpdate(ctx, call, rows)
|
||||
return
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
|
|
@ -73,6 +81,62 @@ func (r *Router) pushGroupCallParticipantsUpdate(ctx context.Context, channel do
|
|||
}
|
||||
}
|
||||
|
||||
func (r *Router) conferenceCallRecipients(ctx context.Context, callID int64) []int64 {
|
||||
return r.conferenceCallRecipientsWith(ctx, callID, nil)
|
||||
}
|
||||
|
||||
func (r *Router) conferenceCallRecipientsWith(ctx context.Context, callID int64, extraUserIDs []int64) []int64 {
|
||||
if r.deps.GroupCalls == nil {
|
||||
return nil
|
||||
}
|
||||
recipients, err := r.deps.GroupCalls.ConferenceRecipients(ctx, callID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
recipients = append(recipients, extraUserIDs...)
|
||||
recipients = uniquePositiveUserIDs(recipients)
|
||||
if len(recipients) == 0 {
|
||||
return nil
|
||||
}
|
||||
if provider, ok := r.deps.Sessions.(OnlineUserProvider); ok {
|
||||
return provider.OnlineUserIDsForCandidates(recipients, domain.MaxChannelRealtimeFanout)
|
||||
}
|
||||
return recipients
|
||||
}
|
||||
|
||||
func (r *Router) pushConferenceGroupCallUpdate(ctx context.Context, call domain.GroupCall) {
|
||||
r.pushConferenceGroupCallUpdateTo(ctx, call, nil)
|
||||
}
|
||||
|
||||
func (r *Router) pushConferenceGroupCallUpdateTo(ctx context.Context, call domain.GroupCall, extraUserIDs []int64) {
|
||||
recipients := r.conferenceCallRecipientsWith(ctx, call.ID, extraUserIDs)
|
||||
for _, viewerID := range recipients {
|
||||
update := &tg.UpdateGroupCall{Call: tgGroupCall(call, viewerID, viewerID == call.CreatorUserID)}
|
||||
r.pushUserMessage(ctx, viewerID, "conference call update",
|
||||
r.groupCallUpdateContainer(ctx, viewerID, domain.Channel{}, update, []int64{call.CreatorUserID}))
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) pushConferenceGroupCallParticipantsUpdate(ctx context.Context, call domain.GroupCall, rows []domain.GroupCallParticipant) {
|
||||
if len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
userIDs := make([]int64, 0, len(rows))
|
||||
for _, p := range rows {
|
||||
userIDs = append(userIDs, p.UserID)
|
||||
}
|
||||
recipients := r.conferenceCallRecipients(ctx, call.ID)
|
||||
for _, viewerID := range recipients {
|
||||
update := &tg.UpdateGroupCallParticipants{
|
||||
Call: &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash},
|
||||
Participants: tgGroupCallParticipants(rows, viewerID),
|
||||
Version: call.Version,
|
||||
}
|
||||
r.pushUserMessage(ctx, viewerID, "conference call participants",
|
||||
r.groupCallUpdateContainer(ctx, viewerID, domain.Channel{}, update, userIDs))
|
||||
}
|
||||
}
|
||||
|
||||
// pushGroupCallServiceMessage 把 started/ended/invite 服务消息(带频道 pts)推给
|
||||
// 活跃成员(res.Recipients)。复用 channelOperationUpdates 的 per-viewer 构建。
|
||||
func (r *Router) pushGroupCallServiceMessage(ctx context.Context, originUserID int64, res domain.SendChannelMessageResult) {
|
||||
|
|
@ -93,6 +157,11 @@ func (r *Router) pushGroupCallServiceMessage(ctx context.Context, originUserID i
|
|||
// groupCallMutationFanout 是参与者维度变更后的统一扇出:participants 增量 +
|
||||
// call_not_empty 翻转时的 channel 维度刷新(Android banner 对 flag 依赖更重)。
|
||||
func (r *Router) groupCallMutationFanout(ctx context.Context, channel domain.Channel, mut domain.GroupCallMutation) domain.Channel {
|
||||
if mut.Call.Conference() {
|
||||
r.pushConferenceGroupCallParticipantsUpdate(ctx, mut.Call, []domain.GroupCallParticipant{mut.Participant})
|
||||
r.pushConferenceGroupCallUpdate(ctx, mut.Call)
|
||||
return domain.Channel{}
|
||||
}
|
||||
r.pushGroupCallParticipantsUpdate(ctx, channel, mut.Call, []domain.GroupCallParticipant{mut.Participant})
|
||||
wantNotEmpty := mut.Call.Active() && mut.Call.ParticipantsCount > 0
|
||||
if channel.ActiveCallNotEmpty != wantNotEmpty && r.deps.Channels != nil {
|
||||
|
|
@ -107,3 +176,33 @@ func (r *Router) groupCallMutationFanout(ctx context.Context, channel domain.Cha
|
|||
}
|
||||
return channel
|
||||
}
|
||||
|
||||
func groupCallParticipantUserIDs(rows []domain.GroupCallParticipant) []int64 {
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]int64, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, row.UserID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func uniquePositiveUserIDs(ids []int64) []int64 {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
out := make([]int64, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,10 +36,18 @@ func (r *Router) registerPhone(d *tg.ServerDispatcher) {
|
|||
d.OnPhoneGetGroupCall(r.onPhoneGetGroupCall)
|
||||
d.OnPhoneGetGroupParticipants(r.onPhoneGetGroupParticipants)
|
||||
d.OnPhoneCheckGroupCall(r.onPhoneCheckGroupCall)
|
||||
d.OnPhoneExportGroupCallInvite(r.onPhoneExportGroupCallInvite)
|
||||
d.OnPhoneEditGroupCallParticipant(r.onPhoneEditGroupCallParticipant)
|
||||
d.OnPhoneEditGroupCallTitle(r.onPhoneEditGroupCallTitle)
|
||||
d.OnPhoneToggleGroupCallSettings(r.onPhoneToggleGroupCallSettings)
|
||||
d.OnPhoneInviteToGroupCall(r.onPhoneInviteToGroupCall)
|
||||
// Ad-hoc E2E conference call(P2P 通话升级/拉人路径)。
|
||||
d.OnPhoneCreateConferenceCall(r.onPhoneCreateConferenceCall)
|
||||
d.OnPhoneInviteConferenceCallParticipant(r.onPhoneInviteConferenceCallParticipant)
|
||||
d.OnPhoneDeleteConferenceCallParticipants(r.onPhoneDeleteConferenceCallParticipants)
|
||||
d.OnPhoneSendConferenceCallBroadcast(r.onPhoneSendConferenceCallBroadcast)
|
||||
d.OnPhoneDeclineConferenceCallInvite(r.onPhoneDeclineConferenceCallInvite)
|
||||
d.OnPhoneGetGroupCallChainBlocks(r.onPhoneGetGroupCallChainBlocks)
|
||||
// 屏幕共享(M4):同参与者第二媒体连接。
|
||||
d.OnPhoneJoinGroupCallPresentation(r.onPhoneJoinGroupCallPresentation)
|
||||
d.OnPhoneLeaveGroupCallPresentation(r.onPhoneLeaveGroupCallPresentation)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue