package rpc import ( "context" "errors" "github.com/gotd/td/tg" "go.uber.org/zap" "telesrv/internal/domain" "telesrv/internal/sfu" ) // 超级群语音聊天(group call)核心 RPC。信令真值在 GroupCallStore(version 单调), // 媒体面经 deps.SFU(M0 为 Disabled:纯信令,客户端停留在 Connecting 属预期)。 // groupCallErr 把 domain 群通话错误映射为 RPC_ERROR。 func groupCallErr(err error) error { switch { case errors.Is(err, domain.ErrGroupCallInvalid): return groupCallInvalidErr() case errors.Is(err, domain.ErrGroupCallDiscarded): return groupCallAlreadyDiscardedErr() case errors.Is(err, domain.ErrGroupCallAlreadyStarted): return groupCallAlreadyStartedErr() case errors.Is(err, domain.ErrGroupCallSSRCDuplicate): return groupCallSSRCDuplicateErr() case errors.Is(err, domain.ErrGroupCallNotJoined): return groupCallJoinMissingErr() default: return internalErr() } } // groupCallScope 是群通话 handler 的通用前置解析结果。 type groupCallScope struct { userID int64 call domain.GroupCall channel domain.Channel member domain.ChannelMember } func (s *groupCallScope) canManage() bool { return channelMemberIsAdmin(s.member) } // groupCallScopeFrom 解析 InputGroupCallClass(仅 id+access_hash 变体;slug/ // inviteMessage 属 conference 路径,返回 GROUPCALL_INVALID)并校验成员资格。 func (r *Router) groupCallScopeFrom(ctx context.Context, in tg.InputGroupCallClass) (*groupCallScope, error) { if r.deps.GroupCalls == nil || r.deps.Channels == 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 { return nil, groupCallInvalidErr() } view, err := r.deps.Channels.GetChannel(ctx, userID, call.ChannelID) if err != nil { return nil, groupCallForbiddenErr() } if view.Self.Status != domain.ChannelMemberActive { return nil, groupCallForbiddenErr() } return &groupCallScope{userID: userID, call: call, channel: view.Channel, member: view.Self}, nil } func (r *Router) onPhoneCreateGroupCall(ctx context.Context, req *tg.PhoneCreateGroupCallRequest) (tg.UpdatesClass, error) { if req == nil { return nil, inputRequestInvalidErr() } if r.deps.GroupCalls == nil || r.deps.Channels == nil { return nil, notImplementedErr() } userID, err := r.phoneRequireUser(ctx) if err != nil { return nil, err } if req.RtmpStream { return nil, notImplementedErr() } if _, ok := req.GetScheduleDate(); ok { return nil, notImplementedErr() } peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) if err != nil { return nil, err } if peer.Type != domain.PeerTypeChannel || peer.ID == 0 { return nil, peerIDInvalidErr() } view, err := r.deps.Channels.GetChannel(ctx, userID, peer.ID) if err != nil { return nil, peerIDInvalidErr() } if view.Self.Status != domain.ChannelMemberActive || !channelMemberIsAdmin(view.Self) { return nil, tgerr400("CHAT_ADMIN_REQUIRED") } if !view.Channel.Megagroup { // broadcast 频道的 livestream 属范围外。 return nil, notImplementedErr() } now := int(r.clock.Now().Unix()) call, err := r.deps.GroupCalls.Create(ctx, view.Channel.ID, userID, req.Title, now) if err != nil { return nil, groupCallErr(err) } // started 服务消息(带频道 pts,离线成员经 channels difference 补收)。 var serviceRes domain.SendChannelMessageResult if res, err := r.deps.Channels.AppendCallServiceMessage(ctx, view.Channel.ID, userID, now, domain.ChannelMessageAction{ Type: domain.ChannelActionGroupCall, CallID: call.ID, CallAccessHash: call.AccessHash, }); err == nil { serviceRes = res _ = r.deps.GroupCalls.SetStartedMessageID(ctx, call.ID, res.Message.ID) } else { r.log.Warn("group call started service message", zap.Int64("channel_id", view.Channel.ID), zap.Error(err)) } channel, err := r.deps.Channels.SetActiveCall(ctx, view.Channel.ID, call.ID, call.AccessHash, false) if err != nil { channel = view.Channel channel.ActiveCallID = call.ID channel.ActiveCallAccessHash = call.AccessHash } // 扇出:banner flag 刷新 + updateGroupCall + 服务消息。 r.pushChannelStateToMembers(ctx, userID, channel) r.pushGroupCallUpdate(ctx, channel, call) if serviceRes.Event.Pts != 0 { r.pushGroupCallServiceMessage(ctx, userID, serviceRes) } // 响应:updateGroupCall + 服务消息(发起设备视角)。TDesktop 创建后自行 joinGroupCall。 update := &tg.UpdateGroupCall{Call: tgGroupCall(call, userID, true)} update.SetPeer(&tg.PeerChannel{ChannelID: channel.ID}) out := r.groupCallUpdateContainer(ctx, userID, channel, update, []int64{userID}) if serviceRes.Event.Pts != 0 { if msgUpdate := tgChannelUpdate(userID, serviceRes.Event); msgUpdate != nil { out.Updates = append(out.Updates, msgUpdate) } } return out, nil } func (r *Router) onPhoneJoinGroupCall(ctx context.Context, req *tg.PhoneJoinGroupCallRequest) (tg.UpdatesClass, 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, groupCallAlreadyDiscardedErr() } // 解析上行 join JSON(容忍 video_stopped 等 flag 与 ssrc-groups——TDesktop join 即带)。 offer, ssrc, err := parseGroupCallJoinPayload(req.Params.Data) if err != nil { r.log.Warn("group call join payload", zap.Error(err)) return nil, groupCallInvalidErr() } // 房间上限(演示规模):rejoin(已在会换 ssrc)不受限。 if max := r.cfg.GroupCallMaxParticipants; max > 0 && scope.call.ParticipantsCount >= max { if p, found, _ := r.deps.GroupCalls.Participant(ctx, scope.call.ID, scope.userID); !found || p.Left { return nil, groupCallForbiddenErr() } } now := int(r.clock.Now().Unix()) // 视频内部状态:endpoint 服务端铸造(join 响应 video.endpoint 与日后 // participant.video.endpoint 必须逐字节一致);ssrc-groups 无论摄像头开关都 // 存档——video_stopped=false(join flag 或后续 self-edit)时原样回放。 endpoint := groupCallEndpointID(sfu.EndpointMain, offer.AudioSSRC) videoState := participantVideoState{ Endpoint: endpoint, SourceGroups: groupCallSsrcGroupsFromOffer(offer), Active: !req.VideoStopped && len(offer.SsrcGroups) > 0, } mut, err := r.deps.GroupCalls.Join(ctx, domain.JoinGroupCallRequest{ CallID: scope.call.ID, UserID: scope.userID, SSRC: ssrc, Muted: req.Muted, IsAdmin: scope.canManage(), VideoJSON: encodeVideoState(videoState), Now: now, }) if err != nil { return nil, groupCallErr(err) } // 媒体面:SFU 分配 endpoint(M0 Disabled:语法完备空 candidates,客户端保持 // Connecting 并以 4s checkGroupCall 心跳维持保活——M0 sweeper 判据依赖该行为)。 sfuService := r.deps.SFU if sfuService == nil { sfuService = sfu.Disabled() } answer, err := sfuService.Join(ctx, scope.call.ID, scope.userID, sfu.EndpointMain, offer) if err != nil { // 媒体面失败回滚信令侧(保持两面一致),返回 500。 _, _ = r.deps.GroupCalls.Leave(ctx, scope.call.ID, scope.userID, now) r.log.Warn("group call sfu join", zap.Error(err)) return nil, internalErr() } params, err := buildGroupCallConnectionParams(answer, endpoint) 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, internalErr() } // 扇出给房间/在线群成员(操作者其它设备含其中;本设备从 RPC 返回拿)。 channel := r.groupCallMutationFanout(ctx, scope.channel, mut) // 响应(TDesktop 从本 RPC 返回的 Updates 摘取 updateGroupCallConnection,不会等推送): 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}) 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}) out.Updates = append(out.Updates, callUpdate) return out, nil } func (r *Router) onPhoneLeaveGroupCall(ctx context.Context, req *tg.PhoneLeaveGroupCallRequest) (tg.UpdatesClass, error) { if req == nil { return nil, inputRequestInvalidErr() } scope, err := r.groupCallScopeFrom(ctx, req.Call) if err != nil { return nil, err } now := int(r.clock.Now().Unix()) mut, err := r.deps.GroupCalls.Leave(ctx, scope.call.ID, scope.userID, now) if errors.Is(err, domain.ErrGroupCallNotJoined) { // 幂等:重复 leave / sweeper 已清,返回当前快照。 return r.groupCallUpdateContainer(ctx, scope.userID, scope.channel, groupCallUpdateFor(scope.channel, scope.call, scope.userID, false), nil), nil } if err != nil { return nil, groupCallErr(err) } if r.deps.SFU != nil { _ = 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, &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 } func (r *Router) onPhoneDiscardGroupCall(ctx context.Context, in tg.InputGroupCallClass) (tg.UpdatesClass, error) { scope, err := r.groupCallScopeFrom(ctx, in) if err != nil { return nil, err } if !scope.canManage() { return nil, tgerr400("CHAT_ADMIN_REQUIRED") } now := int(r.clock.Now().Unix()) call, _, 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) } // 清 channel 关联 + ended 服务消息(带 duration)。 channel := scope.channel if updated, err := r.deps.Channels.SetActiveCall(ctx, channel.ID, 0, 0, false); err == nil { channel = updated } var serviceRes domain.SendChannelMessageResult if res, err := r.deps.Channels.AppendCallServiceMessage(ctx, channel.ID, scope.userID, now, domain.ChannelMessageAction{ Type: domain.ChannelActionGroupCall, CallID: call.ID, CallAccessHash: call.AccessHash, CallDuration: call.Duration, }); err == nil { serviceRes = res } else { r.log.Warn("group call ended service message", zap.Int64("channel_id", channel.ID), zap.Error(err)) } r.pushChannelStateToMembers(ctx, scope.userID, channel) r.pushGroupCallUpdate(ctx, channel, call) if serviceRes.Event.Pts != 0 { r.pushGroupCallServiceMessage(ctx, scope.userID, serviceRes) } out := r.groupCallUpdateContainer(ctx, scope.userID, channel, groupCallUpdateFor(channel, call, scope.userID, true), nil) if serviceRes.Event.Pts != 0 { if msgUpdate := tgChannelUpdate(scope.userID, serviceRes.Event); msgUpdate != nil { out.Updates = append(out.Updates, msgUpdate) } } return out, nil } func (r *Router) onPhoneGetGroupCall(ctx context.Context, req *tg.PhoneGetGroupCallRequest) (*tg.PhoneGroupCall, error) { if req == nil { return nil, inputRequestInvalidErr() } scope, err := r.groupCallScopeFrom(ctx, req.Call) if err != nil { return nil, err } limit := req.Limit if limit <= 0 || limit > 200 { limit = 50 } page, err := r.deps.GroupCalls.Participants(ctx, scope.call.ID, "", limit) if err != nil { return nil, groupCallErr(err) } userIDs := make([]int64, 0, len(page.Participants)) for _, p := range page.Participants { userIDs = append(userIDs, p.UserID) } 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)}, Users: r.tgUsersForIDs(ctx, scope.userID, userIDs), }, nil } func (r *Router) onPhoneGetGroupParticipants(ctx context.Context, req *tg.PhoneGetGroupParticipantsRequest) (*tg.PhoneGroupParticipants, error) { if req == nil { return nil, inputRequestInvalidErr() } scope, err := r.groupCallScopeFrom(ctx, req.Call) if err != nil { return nil, err } limit := req.Limit if limit <= 0 || limit > 200 { limit = 50 } page, err := r.deps.GroupCalls.Participants(ctx, scope.call.ID, req.Offset, limit) if err != nil { return nil, groupCallErr(err) } userIDs := make([]int64, 0, len(page.Participants)) for _, p := range page.Participants { userIDs = append(userIDs, p.UserID) } // 响应 version=当前值:客户端 version 跳号后据此重建本地状态并恢复增量应用。 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)}, Users: r.tgUsersForIDs(ctx, scope.userID, userIDs), Version: page.Version, }, nil } // onPhoneCheckGroupCall 是保活与「踢人/重启恢复」的统一出口:返回入参 sources 中 // 仍属于该用户活跃 endpoint 的子集;自己的 ssrc 不在 ⇒ 客户端自动 rejoin。 // 注意:客户端只在 Connecting 态调它(媒体连通后心跳停止),不可据此单独判死。 func (r *Router) onPhoneCheckGroupCall(ctx context.Context, req *tg.PhoneCheckGroupCallRequest) ([]int, error) { if req == nil { return nil, inputRequestInvalidErr() } scope, err := r.groupCallScopeFrom(ctx, req.Call) if err != nil { return nil, err } now := int(r.clock.Now().Unix()) active, joined, err := r.deps.GroupCalls.Touch(ctx, scope.call.ID, scope.userID, now) if err != nil { return nil, groupCallErr(err) } if !joined { return []int{}, nil } activeSet := make(map[int]struct{}, len(active)) for _, ssrc := range active { activeSet[int(int32(uint32(ssrc)))] = struct{}{} } // presentation 的全部 ssrc(音频+视频层+RTX)也属于活跃 endpoint:TDesktop 把 // screen ssrc 一并塞进 sources,DrKLO 用 presentation.audio_source(缺省退化 // 取首个视频 ssrc)——任一不在返回集合都会触发 4s 循环重建屏幕实例。 if p, found, err := r.deps.GroupCalls.Participant(ctx, scope.call.ID, scope.userID); err == nil && found && !p.Left { if st, ok := decodeVideoState(p.PresentationJSON); ok && st.Active { if st.AudioSource != 0 { activeSet[int(int32(uint32(st.AudioSource)))] = struct{}{} } for _, g := range st.SourceGroups { for _, src := range g.Sources { activeSet[int(int32(uint32(src)))] = struct{}{} } } } } out := make([]int, 0, len(req.Sources)) for _, src := range req.Sources { if _, ok := activeSet[src]; ok { out = append(out, src) } } return out, nil } 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}) return update }