chore: refresh gramsrv public release

This commit is contained in:
A 2026-06-30 14:37:43 +08:00
parent 75cebe8dbf
commit 70b6820474
1274 changed files with 378751 additions and 59919 deletions

View file

@ -0,0 +1,422 @@
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
}