feat: sync group call livestream support

This commit is contained in:
A 2026-07-06 14:31:19 +08:00
parent 56d995474c
commit f1a27996d3
37 changed files with 2219 additions and 83 deletions

View file

@ -258,6 +258,11 @@ func tgChannelMessageAction(action domain.ChannelMessageAction) tg.MessageAction
out.SetDuration(action.CallDuration)
}
return out
case domain.ChannelActionGroupCallScheduled:
return &tg.MessageActionGroupCallScheduled{
Call: &tg.InputGroupCall{ID: action.CallID, AccessHash: action.CallAccessHash},
ScheduleDate: action.CallScheduleDate,
}
case domain.ChannelActionInviteToGroupCall:
return &tg.MessageActionInviteToGroupCall{
Call: &tg.InputGroupCall{ID: action.CallID, AccessHash: action.CallAccessHash},

View file

@ -20,6 +20,11 @@ import (
// this chat」即此(TDesktop emitShareScreenError / DrKLO ChatObject.canStreamVideo)。
const groupCallUnmutedVideoLimit = 30
// groupCallStreamDCID 是写进 RTMP groupCall.stream_dc_id 的 DC 号。本服务单 DC,
// 由 router New() 从 cfg.DC 初始化;缺省 2 与 help.getConfig 的 ThisDC 默认一致。
// 值仅用于客户端拉流 DC 路由标记(telesrv 拉流走同连接),避免 TDesktop fallback 日志。
var groupCallStreamDCID = 2
// tgGroupCall 把 call 行转为 TL groupCall。
func tgGroupCall(call domain.GroupCall, viewerUserID int64, canManage bool) tg.GroupCallClass {
if !call.Active() {
@ -39,6 +44,22 @@ func tgGroupCall(call domain.GroupCall, viewerUserID int64, canManage bool) tg.G
UnmutedVideoLimit: groupCallUnmutedVideoLimit,
Version: call.Version,
}
if call.RtmpStream {
// RTMP 直播房间:观众经 broadcast 拉流。rtmp_stream 决定 TDesktop 打开
// 直播 UI(而非语音聊天);listeners_hidden 让观众数只用 participants_count
// 表达、不逐个下发 listener 行(RTMP 观众通常不进 participant 列表)。
out.RtmpStream = true
out.ListenersHidden = true
// stream_dc_id 缺省会让 TDesktop fallback 到 main DC 并打 log;本服务单 DC,
// 显式回填本 DC id(拉流仍走同连接,DC shift 只影响客户端路由标记)。
out.SetStreamDCID(groupCallStreamDCID)
}
if call.ScheduleDate > 0 {
// 定时通话:客户端据 schedule_date 显示倒计时面板;schedule_start_subscribed
// 是 per-viewer 投影(出站前由 applyScheduleSubscription 回填)。
out.SetScheduleDate(call.ScheduleDate)
out.ScheduleStartSubscribed = call.ScheduleStartSubscribed
}
if call.Title != "" {
out.SetTitle(call.Title)
}
@ -53,13 +74,19 @@ func tgGroupCall(call domain.GroupCall, viewerUserID int64, canManage bool) tg.G
}
// tgGroupCallParticipant 按 viewer 视角转换参与者行(Self flag per-viewer)。
// join_as 频道身份输出 PeerChannel(客户端按 participant.peer==joinAs() 匹配自己,
// 输出用户 peer 会让以频道身份入会的本人面板出现 ghost 双行)。
func tgGroupCallParticipant(p domain.GroupCallParticipant, viewerUserID int64) tg.GroupCallParticipant {
var peer tg.PeerClass = &tg.PeerUser{UserID: p.UserID}
if p.JoinAsChannelID != 0 {
peer = &tg.PeerChannel{ChannelID: p.JoinAsChannelID}
}
out := tg.GroupCallParticipant{
Muted: p.Muted,
Left: p.Left,
CanSelfUnmute: !p.MutedByAdmin,
Self: p.UserID == viewerUserID,
Peer: &tg.PeerUser{UserID: p.UserID},
Peer: peer,
Date: p.JoinDate,
Source: int(int32(uint32(p.SSRC))), // uint32 按位转 int32(join JSON 同款语义)
}

View file

@ -705,6 +705,7 @@ type Deps struct {
Polls PollsService
Phone PhoneService
GroupCalls GroupCallsService
LiveStreams LiveStreamsService
SFU sfu.Service
TURN turnsrv.Service
LangPack LangPackService
@ -811,7 +812,13 @@ type PhoneService interface {
// GroupCallsService 抽象超级群语音聊天信令(app/groupcalls)。
// 错误集合见 domain.ErrGroupCall*(rpc 层映射为 GROUPCALL_* RPC_ERROR)。
type GroupCallsService interface {
Create(ctx context.Context, channelID, creatorUserID int64, title string, now int) (domain.GroupCall, error)
Create(ctx context.Context, channelID, creatorUserID int64, title string, rtmpStream, joinMuted bool, scheduleDate, now int) (domain.GroupCall, error)
// RtmpStreamKey 取/轮换 channel 的持久 RTMP 推流密钥(rotate=true 覆盖旧 key)。
RtmpStreamKey(ctx context.Context, channelID int64, rotate bool, now int) (string, error)
// StartScheduled / SetScheduleSubscription / ScheduleSubscriberIDs 是定时通话流程。
StartScheduled(ctx context.Context, callID int64) (domain.GroupCall, bool, error)
SetScheduleSubscription(ctx context.Context, callID, userID int64, subscribed bool) error
ScheduleSubscriberIDs(ctx context.Context, callID int64) ([]int64, 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)
@ -839,6 +846,17 @@ type GroupCallsService interface {
ChainBlocks(ctx context.Context, callID int64, subChainID, offset, limit int) (domain.GroupCallChainBlockPage, error)
}
// LiveStreamsService 抽象直播媒体面(app/livestream:RTMP ingest + 切段 ring)。
// nil = 直播媒体面未启用(信令仍可用,观众停留在"等待推流"占位)。
type LiveStreamsService interface {
// StreamChannels 返回 channel 当前直播时间轴;无活跃推流返回空。
StreamChannels(channelID int64) []domain.LiveStreamChannel
// StreamPart 按 time_ms/scale 取一段打包好的 tgcalls broadcast part。
StreamPart(channelID int64, timeMs int64, scale int) ([]byte, error)
// DropChannel 断开该 channel 的推流会话并清空缓冲(discard/revoke)。
DropChannel(channelID int64)
}
// PollsService 抽象 poll 权威态的发送时创建与投票人列表(messages.getPollVotes)。
type PollsService interface {
CreatePoll(ctx context.Context, def domain.PollDefinition) error

View file

@ -116,6 +116,53 @@ func (r *Router) groupCallScopeFrom(ctx context.Context, in tg.InputGroupCallCla
return &groupCallScope{userID: userID, call: call, channel: view.Channel, member: view.Self}, nil
}
// groupCallJoinAsChannelID 解析 joinGroupCall.join_as:self(缺省/自己)→0;
// 本频道本身且 viewer 是 admin(匿名管理员/创建者语义,TDesktop RTMP createBox
// 对 creator 硬编码 joinAs=peer)→ channelID;其余身份返回 JOIN_AS_PEER_INVALID。
func (r *Router) groupCallJoinAsChannelID(scope *groupCallScope, joinAs tg.InputPeerClass) (int64, error) {
switch v := joinAs.(type) {
case nil, *tg.InputPeerSelf, *tg.InputPeerEmpty:
return 0, nil
case *tg.InputPeerUser:
if v.UserID == scope.userID {
return 0, nil
}
case *tg.InputPeerChannel:
if !scope.call.Conference() && v.ChannelID == scope.channel.ID && channelMemberIsAdmin(scope.member) {
return v.ChannelID, nil
}
}
return 0, tgerr400("JOIN_AS_PEER_INVALID")
}
// onPhoneGetGroupCallJoinAs 返回入会可选身份:所有人可用自己;频道 admin 额外
// 可用频道本身(匿名身份)。TDesktop 在候选 >1 时显示 "join as" 选择框。
func (r *Router) onPhoneGetGroupCallJoinAs(ctx context.Context, peer tg.InputPeerClass) (*tg.PhoneJoinAsPeers, error) {
userID, err := r.phoneRequireUser(ctx)
if err != nil {
return nil, err
}
out := &tg.PhoneJoinAsPeers{
Peers: []tg.PeerClass{&tg.PeerUser{UserID: userID}},
Chats: []tg.ChatClass{},
Users: r.tgUsersForIDs(ctx, userID, []int64{userID}),
}
if r.deps.Channels == nil {
return out, nil
}
dp, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer)
if err != nil || dp.Type != domain.PeerTypeChannel || dp.ID == 0 {
return out, nil
}
view, err := r.deps.Channels.GetChannel(ctx, userID, dp.ID)
if err != nil || view.Self.Status != domain.ChannelMemberActive || !channelMemberIsAdmin(view.Self) {
return out, nil
}
out.Peers = append(out.Peers, &tg.PeerChannel{ChannelID: view.Channel.ID})
out.Chats = append(out.Chats, tgChannel(userID, view.Channel, &view.Self))
return out, nil
}
func (r *Router) conferenceCallCanAccess(ctx context.Context, callID, userID int64) (bool, error) {
recipients, err := r.deps.GroupCalls.ConferenceRecipients(ctx, callID)
if err != nil {
@ -140,11 +187,11 @@ func (r *Router) onPhoneCreateGroupCall(ctx context.Context, req *tg.PhoneCreate
if err != nil {
return nil, err
}
if req.RtmpStream {
return nil, notImplementedErr()
}
if _, ok := req.GetScheduleDate(); ok {
return nil, notImplementedErr()
now := int(r.clock.Now().Unix())
scheduleDate, _ := req.GetScheduleDate()
if scheduleDate < 0 || (scheduleDate > 0 && scheduleDate <= now) {
// TDesktop 选择器只给未来时间;过去时间直接拒绝(容忍在途秒差由客户端保证)。
return nil, tgerr400("SCHEDULE_DATE_INVALID")
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
@ -160,22 +207,27 @@ func (r *Router) onPhoneCreateGroupCall(ctx context.Context, req *tg.PhoneCreate
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)
// 广播频道直播、以及任意 RTMP 直播:参与者都是纯观众(listener),入会即
// 强制静音且不可自解(join_muted)。RTMP 尤其关键——TDesktop 在 stream 模式下
// 若发现 self 行非 force-muted 会每 3s `Rejoin after unforcemute`,导致死循环。
joinMuted := !view.Channel.Megagroup || req.RtmpStream
call, err := r.deps.GroupCalls.Create(ctx, view.Channel.ID, userID, req.Title, req.RtmpStream, joinMuted, scheduleDate, 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{
// 服务消息(带频道 pts,离线成员经 channels difference 补收):
// 定时通话发 scheduled("预约了视频聊天"),立即通话发 started。
serviceAction := domain.ChannelMessageAction{
Type: domain.ChannelActionGroupCall,
CallID: call.ID,
CallAccessHash: call.AccessHash,
}); err == nil {
}
if scheduleDate > 0 {
serviceAction.Type = domain.ChannelActionGroupCallScheduled
serviceAction.CallScheduleDate = scheduleDate
}
var serviceRes domain.SendChannelMessageResult
if res, err := r.deps.Channels.AppendCallServiceMessage(ctx, view.Channel.ID, userID, now, serviceAction); err == nil {
serviceRes = res
_ = r.deps.GroupCalls.SetStartedMessageID(ctx, call.ID, res.Message.ID)
} else {
@ -216,6 +268,13 @@ func (r *Router) onPhoneJoinGroupCall(ctx context.Context, req *tg.PhoneJoinGrou
if !scope.call.Active() {
return nil, groupCallAlreadyDiscardedErr()
}
if scope.call.RtmpStream {
return r.joinRtmpGroupCall(ctx, scope, req)
}
joinAsChannelID, err := r.groupCallJoinAsChannelID(scope, req.JoinAs)
if err != nil {
return nil, err
}
// 解析上行 join JSON(容忍 video_stopped 等 flag 与 ssrc-groups——TDesktop join 即带)。
offer, ssrc, err := parseGroupCallJoinPayload(req.Params.Data)
if err != nil {
@ -247,15 +306,16 @@ func (r *Router) onPhoneJoinGroupCall(ctx context.Context, req *tg.PhoneJoinGrou
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(),
PublicKey: publicKey,
JoinBlock: joinBlock,
VideoJSON: encodeVideoState(videoState),
Now: now,
CallID: scope.call.ID,
UserID: scope.userID,
JoinAsChannelID: joinAsChannelID,
SSRC: ssrc,
Muted: req.Muted,
IsAdmin: scope.canManage(),
PublicKey: publicKey,
JoinBlock: joinBlock,
VideoJSON: encodeVideoState(videoState),
Now: now,
})
if err != nil {
return nil, groupCallErr(err)
@ -377,6 +437,10 @@ func (r *Router) onPhoneDiscardGroupCall(ctx context.Context, in tg.InputGroupCa
return r.groupCallUpdateContainer(ctx, scope.userID, domain.Channel{},
groupCallUpdateFor(domain.Channel{}, call, scope.userID, true), nil), nil
}
// RTMP 直播结束:断开推流并清空缓冲(观众后续拉流转 resync/停止)。
if call.RtmpStream && r.deps.LiveStreams != nil {
r.deps.LiveStreams.DropChannel(scope.channel.ID)
}
// 清 channel 关联 + ended 服务消息(带 duration)。
channel := scope.channel
if updated, err := r.deps.Channels.SetActiveCall(ctx, channel.ID, 0, 0, false); err == nil {
@ -432,8 +496,10 @@ func (r *Router) onPhoneGetGroupCall(ctx context.Context, req *tg.PhoneGetGroupC
if !scope.call.Conference() {
chats = append(chats, tgChannel(scope.userID, scope.channel, &scope.member))
}
// 定时通话:回填 viewer 自己的开播提醒订阅(客户端 reload 全量重建本地状态)。
call := r.applyScheduleSubscription(ctx, scope.call, scope.userID)
return &tg.PhoneGroupCall{
Call: tgGroupCall(scope.call, scope.userID, scope.canManage()),
Call: tgGroupCall(call, scope.userID, scope.canManage()),
Participants: tgGroupCallParticipants(page.Participants, scope.userID),
ParticipantsNextOffset: page.NextOffset,
Chats: chats,

View file

@ -0,0 +1,113 @@
package rpc
import (
"testing"
"github.com/gotd/td/tg"
)
// TestGroupCallJoinAsChannel 覆盖 join_as 身份闭环:admin 以频道身份入会 →
// 参与者行 peer=PeerChannel(本人与对端视角一致);非 admin 以频道身份被拒;
// getGroupCallJoinAs 对 admin 返回 self+频道两个候选、普通成员只返回 self。
func TestGroupCallJoinAsChannel(t *testing.T) {
f := newGroupCallFixture(t)
ownerCtx := f.userCtx(f.owner, 11)
memberCtx := f.userCtx(f.member, 22)
channelPeer := &tg.InputPeerChannel{ChannelID: f.channel.ID, AccessHash: f.channel.AccessHash}
// --- getGroupCallJoinAs 候选 ---
ownerJoinAs, err := f.router.onPhoneGetGroupCallJoinAs(ownerCtx, channelPeer)
if err != nil {
t.Fatalf("owner getGroupCallJoinAs: %v", err)
}
if len(ownerJoinAs.Peers) != 2 {
t.Fatalf("owner join-as peers = %d, want 2 (self + channel): %+v", len(ownerJoinAs.Peers), ownerJoinAs.Peers)
}
if _, ok := ownerJoinAs.Peers[1].(*tg.PeerChannel); !ok {
t.Fatalf("owner join-as second peer = %T, want PeerChannel", ownerJoinAs.Peers[1])
}
memberJoinAs, err := f.router.onPhoneGetGroupCallJoinAs(memberCtx, channelPeer)
if err != nil {
t.Fatalf("member getGroupCallJoinAs: %v", err)
}
if len(memberJoinAs.Peers) != 1 {
t.Fatalf("member join-as peers = %d, want 1 (self only)", len(memberJoinAs.Peers))
}
// --- create + owner 以频道身份 join ---
createRes, err := f.router.onPhoneCreateGroupCall(ownerCtx, &tg.PhoneCreateGroupCallRequest{
Peer: channelPeer, RandomID: 1,
})
if err != nil {
t.Fatalf("createGroupCall: %v", err)
}
call := findUpdate[*tg.UpdateGroupCall](t, createRes).Call.(*tg.GroupCall)
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
joinRes, err := f.router.onPhoneJoinGroupCall(ownerCtx, &tg.PhoneJoinGroupCallRequest{
Call: input,
JoinAs: channelPeer,
Params: groupCallJoinParams(t, 8001),
})
if err != nil {
t.Fatalf("owner joinGroupCall(join_as=channel): %v", err)
}
participants := findUpdate[*tg.UpdateGroupCallParticipants](t, joinRes)
if len(participants.Participants) != 1 {
t.Fatalf("participants = %d, want 1", len(participants.Participants))
}
self := participants.Participants[0]
peerCh, ok := self.Peer.(*tg.PeerChannel)
if !ok || peerCh.ChannelID != f.channel.ID {
t.Fatalf("self participant peer = %#v, want PeerChannel(%d)", self.Peer, f.channel.ID)
}
if !self.Self {
t.Fatalf("join_as channel row missing self flag for the joining user")
}
// --- 对端视角:member 拉参与者列表也看到频道身份 ---
page, err := f.router.onPhoneGetGroupParticipants(memberCtx, &tg.PhoneGetGroupParticipantsRequest{
Call: input, Limit: 10,
})
if err != nil {
t.Fatalf("member getGroupParticipants: %v", err)
}
if len(page.Participants) != 1 {
t.Fatalf("member sees %d participants, want 1", len(page.Participants))
}
if pc, ok := page.Participants[0].Peer.(*tg.PeerChannel); !ok || pc.ChannelID != f.channel.ID {
t.Fatalf("member view participant peer = %#v, want PeerChannel(%d)", page.Participants[0].Peer, f.channel.ID)
}
if page.Participants[0].Self {
t.Fatalf("member view incorrectly flags channel row as self")
}
// --- 非 admin 以频道身份 join 被拒 ---
if _, err := f.router.onPhoneJoinGroupCall(memberCtx, &tg.PhoneJoinGroupCallRequest{
Call: input,
JoinAs: channelPeer,
Params: groupCallJoinParams(t, 8002),
}); err == nil {
t.Fatalf("non-admin joined as channel")
} else {
assertPhoneRPCErr(t, err, "JOIN_AS_PEER_INVALID")
}
// --- rejoin 换回本人身份:行替换而非新增 ---
rejoinRes, err := f.router.onPhoneJoinGroupCall(ownerCtx, &tg.PhoneJoinGroupCallRequest{
Call: input,
JoinAs: &tg.InputPeerSelf{},
Params: groupCallJoinParams(t, 8003),
})
if err != nil {
t.Fatalf("owner rejoin as self: %v", err)
}
rejoined := findUpdate[*tg.UpdateGroupCallParticipants](t, rejoinRes).Participants[0]
if _, ok := rejoined.Peer.(*tg.PeerUser); !ok {
t.Fatalf("rejoin-as-self participant peer = %#v, want PeerUser", rejoined.Peer)
}
page2, _ := f.router.onPhoneGetGroupParticipants(memberCtx, &tg.PhoneGetGroupParticipantsRequest{Call: input, Limit: 10})
if len(page2.Participants) != 1 {
t.Fatalf("after identity switch participants = %d, want 1 (replace not add)", len(page2.Participants))
}
}

View file

@ -0,0 +1,192 @@
package rpc
import (
"context"
"crypto/rand"
"encoding/binary"
"encoding/json"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
// RTMP 直播(Live Stream)RPC:createGroupCall(rtmp_stream) 建房后,推流方(OBS)
// 用 getGroupCallStreamRtmpUrl 拿到的 url/key 推流,观众 join 后经
// upload.getFile(inputGroupCallStream) 拉 broadcast chunk。
//
// 与普通语音聊天(RTC/SFU)关键差异:RTMP join 不建 SFU 连接、不做 ssrc 唯一性
// 媒体面校验;updateGroupCallConnection.params 返回 {"stream":true,"rtmp":true}
// 让 TDesktop 切 broadcast 模式(ParseJoinResponse → JoinBroadcastStream)。
// rtmpConnectionParams 是 RTMP 观众 join 响应的下行 JSON(tgcalls broadcast 分支)。
// 可管理者附 rtmp_stream_url/key 供直播设置页展示(普通观众不下发,避免泄漏推流凭据)。
type rtmpConnectionParams struct {
Stream bool `json:"stream"`
Rtmp bool `json:"rtmp"`
RtmpStreamURL string `json:"rtmp_stream_url,omitempty"`
RtmpStreamKey string `json:"rtmp_stream_key,omitempty"`
}
func buildRtmpConnectionParams(url, key string) (string, error) {
p := rtmpConnectionParams{Stream: true, Rtmp: true, RtmpStreamURL: url, RtmpStreamKey: key}
out, err := json.Marshal(p)
if err != nil {
return "", err
}
return string(out), nil
}
// joinRtmpGroupCall 处理 RTMP 直播房间的 joinGroupCall。
func (r *Router) joinRtmpGroupCall(ctx context.Context, scope *groupCallScope, req *tg.PhoneJoinGroupCallRequest) (tg.UpdatesClass, error) {
// 房间上限:RTMP 观众也计入 participants_count;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()
}
}
joinAsChannelID, err := r.groupCallJoinAsChannelID(scope, req.JoinAs)
if err != nil {
return nil, err
}
now := int(r.clock.Now().Unix())
// RTMP 观众的 join JSON 仍带 tgcalls ssrc(客户端为拉流也建了本地 controller);
// 解析失败/缺失不致命——直播不需要 SFU,用随机 ssrc 记账保证参与者行有效。
ssrc := int64(0)
if _, s, err := parseGroupCallJoinPayload(req.Params.Data); err == nil {
ssrc = s
}
if ssrc == 0 {
ssrc = randomSSRC()
}
// RTMP 观众(含创建者,其经 OBS 独立推流,在 group call 里同样是纯观众)一律
// force-muted:IsAdmin=false 让 store 对 join_muted 房间置 muted+muted_by_admin,
// self 行输出 muted=true / can_self_unmute=false,TDesktop 才会稳定停在 stream 模式。
mut, err := r.deps.GroupCalls.Join(ctx, domain.JoinGroupCallRequest{
CallID: scope.call.ID,
UserID: scope.userID,
JoinAsChannelID: joinAsChannelID,
SSRC: ssrc,
Muted: true,
IsAdmin: false,
Now: now,
})
if err != nil {
return nil, groupCallErr(err)
}
// 可管理者拿推流 url/key(直播设置页展示);普通观众只得 stream:true。
var url, key string
if scope.canManage() && r.deps.GroupCalls != nil {
if k, kerr := r.deps.GroupCalls.RtmpStreamKey(ctx, scope.channel.ID, false, now); kerr == nil {
key = k
url = r.rtmpIngestURL()
}
}
params, err := buildRtmpConnectionParams(url, key)
if err != nil {
return nil, internalErr()
}
channel := r.groupCallMutationFanout(ctx, scope.channel, mut)
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})
// updateGroupCall 必须先于 updateGroupCallConnection:TDesktop 按序 applyUpdates,
// 处理 connection 时若还没从 groupCall 读到 stream_dc_id 会打
// "Api Error: Empty stream_dc_id" 并 fallback 主 DC。
callUpdate := &tg.UpdateGroupCall{Call: tgGroupCall(mut.Call, scope.userID, scope.canManage())}
if channel.ID != 0 {
callUpdate.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
}
out.Updates = append(out.Updates, callUpdate)
out.Updates = append(out.Updates, &tg.UpdateGroupCallConnection{Params: tg.DataJSON{Data: params}})
return out, nil
}
// onPhoneGetGroupCallStreamRtmpURL 返回频道的 RTMP 推流 url/key(创建前预览、
// 直播设置页展示、revoke 轮换)。仅频道管理员可调;revoke=true 生成新 key,旧 key
// 立即失效并断开正在进行的推流。
func (r *Router) onPhoneGetGroupCallStreamRtmpURL(ctx context.Context, req *tg.PhoneGetGroupCallStreamRtmpURLRequest) (*tg.PhoneGroupCallStreamRtmpURL, 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
}
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")
}
now := int(r.clock.Now().Unix())
key, err := r.deps.GroupCalls.RtmpStreamKey(ctx, peer.ID, req.Revoke, now)
if err != nil {
return nil, groupCallErr(err)
}
if req.Revoke && r.deps.LiveStreams != nil {
// 旧 key 失效后仍在推的连接必须断开(否则用旧 key 的推流会继续被接收)。
r.deps.LiveStreams.DropChannel(peer.ID)
}
return &tg.PhoneGroupCallStreamRtmpURL{URL: r.rtmpIngestURL(), Key: key}, nil
}
// onPhoneGetGroupCallStreamChannels 返回 RTMP 直播的当前时间轴(unified:channel=1
// scale=0),供 tgcalls 决定从哪个 time_ms 起拉 chunk。无活跃推流时返回空列表,
// TDesktop 据此显示"等待推流"占位并循环重试。
func (r *Router) onPhoneGetGroupCallStreamChannels(ctx context.Context, call tg.InputGroupCallClass) (*tg.PhoneGroupCallStreamChannels, error) {
scope, err := r.groupCallScopeFrom(ctx, call)
if err != nil {
return nil, err
}
out := &tg.PhoneGroupCallStreamChannels{Channels: []tg.GroupCallStreamChannel{}}
if !scope.call.RtmpStream || r.deps.LiveStreams == nil || scope.channel.ID == 0 {
return out, nil
}
for _, ch := range r.deps.LiveStreams.StreamChannels(scope.channel.ID) {
out.Channels = append(out.Channels, tg.GroupCallStreamChannel{
Channel: ch.Channel,
Scale: ch.Scale,
LastTimestampMs: ch.LastTimestampMs,
})
}
return out, nil
}
// rtmpIngestURL 返回展示给推流端的 RTMP 服务器地址。
func (r *Router) rtmpIngestURL() string {
if r.cfg.RtmpIngestURL != "" {
return r.cfg.RtmpIngestURL
}
host := r.cfg.IP
if host == "" {
host = "127.0.0.1"
}
return "rtmp://" + host + ":2400/live"
}
func randomSSRC() int64 {
var buf [4]byte
if _, err := rand.Read(buf[:]); err != nil {
return 1
}
v := int64(binary.BigEndian.Uint32(buf[:]))
if v == 0 {
v = 1
}
return v
}

View file

@ -0,0 +1,267 @@
package rpc
import (
"encoding/json"
"testing"
"time"
"github.com/gotd/td/tg"
"go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels"
appgroupcalls "telesrv/internal/app/groupcalls"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
// fakeLiveStreams 是 LiveStreamsService 的测试替身,按 channelID 存可拉流段。
type fakeLiveStreams struct {
channels map[int64][]domain.LiveStreamChannel
parts map[int64]map[int64][]byte // channelID → time_ms → part
dropped map[int64]bool
}
func newFakeLiveStreams() *fakeLiveStreams {
return &fakeLiveStreams{
channels: map[int64][]domain.LiveStreamChannel{},
parts: map[int64]map[int64][]byte{},
dropped: map[int64]bool{},
}
}
func (f *fakeLiveStreams) StreamChannels(channelID int64) []domain.LiveStreamChannel {
return f.channels[channelID]
}
func (f *fakeLiveStreams) StreamPart(channelID int64, timeMs int64, scale int) ([]byte, error) {
if scale != 0 {
return nil, domain.ErrLiveStreamPartExpired
}
byTime, ok := f.parts[channelID]
if !ok {
return nil, domain.ErrLiveStreamNoStream
}
part, ok := byTime[timeMs]
if !ok {
return nil, domain.ErrLiveStreamPartNotReady
}
return part, nil
}
func (f *fakeLiveStreams) DropChannel(channelID int64) { f.dropped[channelID] = true }
type rtmpFixture struct {
*groupCallFixture
live *fakeLiveStreams
}
// newRtmpFixture 复制 newGroupCallFixture 的用户/频道搭建,但注入 LiveStreams 替身。
func newRtmpFixture(t *testing.T) *rtmpFixture {
t.Helper()
ctx := t.Context()
userStore := memory.NewUserStore()
channelStore := memory.NewChannelStore()
sessions := &groupCallSessions{}
clk := &phoneTestClock{now: time.Unix(1_700_000_000, 0)}
live := newFakeLiveStreams()
router := New(Config{GroupCallMaxParticipants: 8, IP: "203.0.113.7"}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
GroupCalls: appgroupcalls.NewService(memory.NewGroupCallStore()),
LiveStreams: live,
Sessions: sessions,
}, zaptest.NewLogger(t), clk)
f := &groupCallFixture{t: t, ctx: ctx, router: router, sessions: sessions, clk: clk}
mk := func(hash int64, phone, name string) domain.User {
u, err := userStore.Create(ctx, domain.User{AccessHash: hash, Phone: phone, FirstName: name})
if err != nil {
t.Fatalf("create user %s: %v", name, err)
}
return u
}
f.owner = mk(2001, "13900000001", "Owner")
f.member = mk(2002, "13900000002", "Member")
f.outsider = mk(2003, "13900000003", "Outsider")
created, err := router.onMessagesCreateChat(f.userCtx(f.owner, 11), &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{&tg.InputUser{UserID: f.member.ID, AccessHash: f.member.AccessHash}},
Title: "live room",
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
for _, chat := range created.Updates.(*tg.Updates).Chats {
if ch, ok := chat.(*tg.Channel); ok {
f.channel = ch
break
}
}
if f.channel == nil {
t.Fatalf("no channel in create chat result")
}
f.sessions.online = []int64{f.owner.ID, f.member.ID}
f.sessions.reset()
return &rtmpFixture{groupCallFixture: f, live: live}
}
func (f *rtmpFixture) createLive(t *testing.T) *tg.GroupCall {
t.Helper()
res, err := f.router.onPhoneCreateGroupCall(f.userCtx(f.owner, 11), &tg.PhoneCreateGroupCallRequest{
Peer: &tg.InputPeerChannel{ChannelID: f.channel.ID, AccessHash: f.channel.AccessHash},
RandomID: 1,
RtmpStream: true,
})
if err != nil {
t.Fatalf("createGroupCall(rtmp): %v", err)
}
call := findUpdate[*tg.UpdateGroupCall](t, res).Call.(*tg.GroupCall)
if !call.RtmpStream {
t.Fatalf("created group call missing rtmp_stream flag: %+v", call)
}
if _, ok := call.GetStreamDCID(); !ok {
t.Fatalf("rtmp group call missing stream_dc_id")
}
return call
}
// TestRtmpCreateAndAdminGetUrl 验证 RTMP 直播创建后管理员可取 url/key,revoke 轮换 key。
func TestRtmpCreateAndAdminGetUrl(t *testing.T) {
f := newRtmpFixture(t)
ownerCtx := f.userCtx(f.owner, 11)
f.createLive(t)
res, err := f.router.onPhoneGetGroupCallStreamRtmpURL(ownerCtx, &tg.PhoneGetGroupCallStreamRtmpURLRequest{
Peer: &tg.InputPeerChannel{ChannelID: f.channel.ID, AccessHash: f.channel.AccessHash},
})
if err != nil {
t.Fatalf("getGroupCallStreamRtmpUrl: %v", err)
}
if res.URL == "" || res.Key == "" {
t.Fatalf("empty rtmp url/key: %+v", res)
}
key1 := res.Key
// 非管理员不得取推流凭据。
if _, err := f.router.onPhoneGetGroupCallStreamRtmpURL(f.userCtx(f.member, 22), &tg.PhoneGetGroupCallStreamRtmpURLRequest{
Peer: &tg.InputPeerChannel{ChannelID: f.channel.ID, AccessHash: f.channel.AccessHash},
}); err == nil {
t.Fatalf("non-admin got rtmp url without error")
}
// revoke 轮换 key 并断开推流会话。
res2, err := f.router.onPhoneGetGroupCallStreamRtmpURL(ownerCtx, &tg.PhoneGetGroupCallStreamRtmpURLRequest{
Peer: &tg.InputPeerChannel{ChannelID: f.channel.ID, AccessHash: f.channel.AccessHash},
Revoke: true,
})
if err != nil {
t.Fatalf("getGroupCallStreamRtmpUrl(revoke): %v", err)
}
if res2.Key == key1 {
t.Fatalf("revoke did not rotate key")
}
if !f.live.dropped[f.channel.ID] {
t.Fatalf("revoke did not drop live stream channel")
}
}
// TestRtmpJoinReturnsStreamParams 验证 RTMP viewer join 返回 stream:true 的 connection params,
// 管理员附带 rtmp_stream_url/key,普通观众不下发凭据。
func TestRtmpJoinReturnsStreamParams(t *testing.T) {
f := newRtmpFixture(t)
call := f.createLive(t)
// 管理员 join:带 url/key。
ownerRes, err := f.router.onPhoneJoinGroupCall(f.userCtx(f.owner, 12), &tg.PhoneJoinGroupCallRequest{
Call: &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash},
JoinAs: &tg.InputPeerSelf{},
Params: groupCallJoinParams(t, 5001),
})
if err != nil {
t.Fatalf("owner joinGroupCall(rtmp): %v", err)
}
conn := findUpdate[*tg.UpdateGroupCallConnection](t, ownerRes)
var params rtmpConnectionParams
if err := json.Unmarshal([]byte(conn.Params.Data), &params); err != nil {
t.Fatalf("parse connection params: %v", err)
}
if !params.Stream || !params.Rtmp {
t.Fatalf("owner connection params not stream/rtmp: %+v", params)
}
if params.RtmpStreamURL == "" || params.RtmpStreamKey == "" {
t.Fatalf("admin join missing rtmp url/key: %+v", params)
}
// 普通成员 join:stream:true 但无凭据。
memberRes, err := f.router.onPhoneJoinGroupCall(f.userCtx(f.member, 22), &tg.PhoneJoinGroupCallRequest{
Call: &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash},
JoinAs: &tg.InputPeerSelf{},
Params: groupCallJoinParams(t, 5002),
})
if err != nil {
t.Fatalf("member joinGroupCall(rtmp): %v", err)
}
connM := findUpdate[*tg.UpdateGroupCallConnection](t, memberRes)
var paramsM rtmpConnectionParams
if err := json.Unmarshal([]byte(connM.Params.Data), &paramsM); err != nil {
t.Fatalf("parse member connection params: %v", err)
}
if !paramsM.Stream || !paramsM.Rtmp {
t.Fatalf("member connection params not stream/rtmp: %+v", paramsM)
}
if paramsM.RtmpStreamURL != "" || paramsM.RtmpStreamKey != "" {
t.Fatalf("non-admin join leaked rtmp url/key: %+v", paramsM)
}
}
// TestRtmpGetStreamPart 验证 upload.getFile(inputGroupCallStream) 的取段与错误映射。
func TestRtmpGetStreamPart(t *testing.T) {
f := newRtmpFixture(t)
call := f.createLive(t)
memberCtx := f.userCtx(f.member, 22)
// 观众须先 join(拉流校验 join 状态经 scope)。
if _, err := f.router.onPhoneJoinGroupCall(memberCtx, &tg.PhoneJoinGroupCallRequest{
Call: &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash},
JoinAs: &tg.InputPeerSelf{},
Params: groupCallJoinParams(t, 6001),
}); err != nil {
t.Fatalf("member join: %v", err)
}
// 备好一段可拉数据。
f.live.channels[f.channel.ID] = []domain.LiveStreamChannel{{Channel: 1, Scale: 0, LastTimestampMs: 3000}}
f.live.parts[f.channel.ID] = map[int64][]byte{3000: []byte("SEGMENT-BYTES-0123456789")}
loc := func(timeMs int64) *tg.InputGroupCallStream {
return &tg.InputGroupCallStream{Call: &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}, TimeMs: timeMs, Scale: 0}
}
// 命中:分片切片。
out, err := f.router.onUploadGetFile(memberCtx, &tg.UploadGetFileRequest{Location: loc(3000), Offset: 0, Limit: 8})
if err != nil {
t.Fatalf("getFile stream: %v", err)
}
uf := out.(*tg.UploadFile)
if string(uf.Bytes) != "SEGMENT-" {
t.Fatalf("stream chunk = %q, want first 8 bytes", uf.Bytes)
}
// 续段(offset 中段)。
out2, _ := f.router.onUploadGetFile(memberCtx, &tg.UploadGetFileRequest{Location: loc(3000), Offset: 8, Limit: 1 << 17})
if string(out2.(*tg.UploadFile).Bytes) != "BYTES-0123456789" {
t.Fatalf("stream chunk tail = %q", out2.(*tg.UploadFile).Bytes)
}
// 未就绪段 → TIME_TOO_BIG。
if _, err := f.router.onUploadGetFile(memberCtx, &tg.UploadGetFileRequest{Location: loc(4000), Offset: 0, Limit: 1024}); err == nil {
t.Fatalf("expected TIME_TOO_BIG for future segment")
} else {
assertPhoneRPCErr(t, err, "TIME_TOO_BIG")
}
// getGroupCallStreamChannels 返回时间轴。
chRes, err := f.router.onPhoneGetGroupCallStreamChannels(memberCtx, &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash})
if err != nil {
t.Fatalf("getGroupCallStreamChannels: %v", err)
}
if len(chRes.Channels) != 1 || chRes.Channels[0].LastTimestampMs != 3000 {
t.Fatalf("stream channels = %+v", chRes.Channels)
}
}

View file

@ -0,0 +1,112 @@
package rpc
import (
"context"
"github.com/gotd/td/tg"
"go.uber.org/zap"
"telesrv/internal/domain"
)
// Scheduled video chat(定时通话)RPC。客户端流程(TDesktop calls_group_call.cpp):
// createGroupCall(schedule_date) → State::Waiting 倒计时(不 join)→ 管理员
// startScheduledGroupCall 清 schedule_date → updateGroupCall(无 schedule_date)→
// 客户端 setScheduledDate(0) 触发 initialJoin 正式入会。开播提醒是 per-viewer 的
// schedule_start_subscribed flag(toggleGroupCallStartSubscription)。
// applyScheduleSubscription 为单个 viewer 回填 ScheduleStartSubscribed 投影字段。
func (r *Router) applyScheduleSubscription(ctx context.Context, call domain.GroupCall, viewerUserID int64) domain.GroupCall {
if call.ScheduleDate == 0 || viewerUserID == 0 {
return call
}
subs, err := r.deps.GroupCalls.ScheduleSubscriberIDs(ctx, call.ID)
if err != nil {
return call
}
for _, id := range subs {
if id == viewerUserID {
call.ScheduleStartSubscribed = true
break
}
}
return call
}
func (r *Router) onPhoneStartScheduledGroupCall(ctx context.Context, in tg.InputGroupCallClass) (tg.UpdatesClass, error) {
scope, err := r.groupCallScopeFrom(ctx, in)
if err != nil {
return nil, err
}
if scope.call.Conference() || scope.channel.ID == 0 {
return nil, groupCallInvalidErr()
}
if !scope.canManage() {
return nil, tgerr400("CHAT_ADMIN_REQUIRED")
}
call, changed, err := r.deps.GroupCalls.StartScheduled(ctx, scope.call.ID)
if err != nil {
return nil, groupCallErr(err)
}
channel := scope.channel
if !changed {
// 幂等:已开始,只回快照,不重复扇出/服务消息。
return r.groupCallUpdateContainer(ctx, scope.userID, channel,
groupCallUpdateFor(channel, call, scope.userID, true), nil), nil
}
now := int(r.clock.Now().Unix())
// started 服务消息(与即时创建的 started 同构)。
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,
}); err == nil {
serviceRes = res
_ = r.deps.GroupCalls.SetStartedMessageID(ctx, call.ID, res.Message.ID)
} else {
r.log.Warn("scheduled group call started service message", zap.Int64("channel_id", channel.ID), zap.Error(err))
}
// 扇出:updateGroupCall(schedule_date 已清,客户端据此自动入会)+ 服务消息。
// 订阅者与普通在线成员走同一在线扇出;离线订阅者的推送提醒(push notification)
// 属通知系统范围,当前不实现(记矩阵 todo)。
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) onPhoneToggleGroupCallStartSubscription(ctx context.Context, req *tg.PhoneToggleGroupCallStartSubscriptionRequest) (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()
}
if scope.call.ScheduleDate == 0 {
// 只有未开始的定时通话才有开播提醒可订。
return nil, groupCallInvalidErr()
}
if err := r.deps.GroupCalls.SetScheduleSubscription(ctx, scope.call.ID, scope.userID, req.Subscribed); err != nil {
return nil, groupCallErr(err)
}
call := scope.call
call.ScheduleStartSubscribed = req.Subscribed
// 订阅是 per-viewer 私有状态:响应给本设备,推送同步本人其它在线设备即可。
update := groupCallUpdateFor(scope.channel, call, scope.userID, scope.canManage())
r.pushUserMessage(ctx, scope.userID, "schedule subscription update",
r.groupCallUpdateContainer(ctx, scope.userID, scope.channel, update, nil))
return r.groupCallUpdateContainer(ctx, scope.userID, scope.channel, update, nil), nil
}

View file

@ -0,0 +1,151 @@
package rpc
import (
"testing"
"github.com/gotd/td/tg"
)
// TestScheduledGroupCallLifecycle 覆盖定时通话闭环:create(schedule_date) →
// scheduled 服务消息 + groupCall.schedule_date → 订阅开播提醒(per-viewer flag)→
// startScheduledGroupCall 清 schedule_date + started 服务消息 → join 正常入会。
func TestScheduledGroupCallLifecycle(t *testing.T) {
f := newGroupCallFixture(t)
ownerCtx := f.userCtx(f.owner, 11)
memberCtx := f.userCtx(f.member, 22)
scheduleDate := int(f.clk.Now().Unix()) + 3600
// --- 过去时间拒绝 ---
pastReq := &tg.PhoneCreateGroupCallRequest{
Peer: &tg.InputPeerChannel{ChannelID: f.channel.ID, AccessHash: f.channel.AccessHash},
RandomID: 1,
}
pastReq.SetScheduleDate(int(f.clk.Now().Unix()) - 10)
if _, err := f.router.onPhoneCreateGroupCall(ownerCtx, pastReq); err == nil {
t.Fatalf("past schedule_date accepted")
} else {
assertPhoneRPCErr(t, err, "SCHEDULE_DATE_INVALID")
}
// --- create scheduled ---
createReq := &tg.PhoneCreateGroupCallRequest{
Peer: &tg.InputPeerChannel{ChannelID: f.channel.ID, AccessHash: f.channel.AccessHash},
RandomID: 2,
}
createReq.SetScheduleDate(scheduleDate)
createRes, err := f.router.onPhoneCreateGroupCall(ownerCtx, createReq)
if err != nil {
t.Fatalf("create scheduled group call: %v", err)
}
call := findUpdate[*tg.UpdateGroupCall](t, createRes).Call.(*tg.GroupCall)
if got, ok := call.GetScheduleDate(); !ok || got != scheduleDate {
t.Fatalf("groupCall.schedule_date = %d ok=%v, want %d", got, ok, scheduleDate)
}
// scheduled 服务消息。
msgUpdate := findUpdate[*tg.UpdateNewChannelMessage](t, createRes)
svc, ok := msgUpdate.Message.(*tg.MessageService)
if !ok {
t.Fatalf("create response message = %T, want MessageService", msgUpdate.Message)
}
scheduledAction, ok := svc.Action.(*tg.MessageActionGroupCallScheduled)
if !ok {
t.Fatalf("service action = %T, want MessageActionGroupCallScheduled", svc.Action)
}
if scheduledAction.ScheduleDate != scheduleDate {
t.Fatalf("service action schedule_date = %d, want %d", scheduledAction.ScheduleDate, scheduleDate)
}
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
// --- member 订阅开播提醒 ---
subRes, err := f.router.onPhoneToggleGroupCallStartSubscription(memberCtx, &tg.PhoneToggleGroupCallStartSubscriptionRequest{
Call: input, Subscribed: true,
})
if err != nil {
t.Fatalf("toggle start subscription: %v", err)
}
subCall := findUpdate[*tg.UpdateGroupCall](t, subRes).Call.(*tg.GroupCall)
if !subCall.ScheduleStartSubscribed {
t.Fatalf("subscription response missing schedule_start_subscribed")
}
// getGroupCall 回填 per-viewer flag:member 已订阅、owner 未订阅。
memberView, err := f.router.onPhoneGetGroupCall(memberCtx, &tg.PhoneGetGroupCallRequest{Call: input, Limit: 10})
if err != nil {
t.Fatalf("member getGroupCall: %v", err)
}
if !memberView.Call.(*tg.GroupCall).ScheduleStartSubscribed {
t.Fatalf("member getGroupCall missing subscribed flag")
}
ownerView, err := f.router.onPhoneGetGroupCall(ownerCtx, &tg.PhoneGetGroupCallRequest{Call: input, Limit: 10})
if err != nil {
t.Fatalf("owner getGroupCall: %v", err)
}
if ownerView.Call.(*tg.GroupCall).ScheduleStartSubscribed {
t.Fatalf("owner getGroupCall unexpectedly subscribed")
}
// --- 非管理员不能开播 ---
if _, err := f.router.onPhoneStartScheduledGroupCall(memberCtx, input); err == nil {
t.Fatalf("non-admin started scheduled call")
} else {
assertPhoneRPCErr(t, err, "CHAT_ADMIN_REQUIRED")
}
// --- start ---
f.sessions.reset()
startRes, err := f.router.onPhoneStartScheduledGroupCall(ownerCtx, input)
if err != nil {
t.Fatalf("startScheduledGroupCall: %v", err)
}
started := findUpdate[*tg.UpdateGroupCall](t, startRes).Call.(*tg.GroupCall)
if _, ok := started.GetScheduleDate(); ok {
t.Fatalf("started call still has schedule_date")
}
// started 服务消息(messageActionGroupCall 无 duration)。
startMsg := findUpdate[*tg.UpdateNewChannelMessage](t, startRes)
startSvc := startMsg.Message.(*tg.MessageService)
if _, ok := startSvc.Action.(*tg.MessageActionGroupCall); !ok {
t.Fatalf("start service action = %T, want MessageActionGroupCall", startSvc.Action)
}
// 在线成员收到 schedule_date 已清的 updateGroupCall(客户端据此自动入会)。
memberGotStart := false
for _, rec := range f.sessions.records() {
if rec.userID != f.member.ID {
continue
}
if box, ok := rec.msg.(*tg.Updates); ok {
for _, u := range box.Updates {
if gc, ok := u.(*tg.UpdateGroupCall); ok {
if call, ok := gc.Call.(*tg.GroupCall); ok {
if _, has := call.GetScheduleDate(); !has {
memberGotStart = true
}
}
}
}
}
}
if !memberGotStart {
t.Fatalf("member did not receive started updateGroupCall: %+v", f.sessions.records())
}
// --- 幂等重复 start ---
if _, err := f.router.onPhoneStartScheduledGroupCall(ownerCtx, input); err != nil {
t.Fatalf("idempotent re-start: %v", err)
}
// --- 已开始后订阅提醒非法 ---
if _, err := f.router.onPhoneToggleGroupCallStartSubscription(memberCtx, &tg.PhoneToggleGroupCallStartSubscriptionRequest{
Call: input, Subscribed: true,
}); err == nil {
t.Fatalf("subscription toggle allowed after start")
}
// --- start 后正常 join ---
if _, err := f.router.onPhoneJoinGroupCall(memberCtx, &tg.PhoneJoinGroupCallRequest{
Call: input,
JoinAs: &tg.InputPeerSelf{},
Params: groupCallJoinParams(t, 7001),
}); err != nil {
t.Fatalf("join after start: %v", err)
}
}

View file

@ -53,14 +53,29 @@ func (r *Router) groupCallUpdateContainer(ctx context.Context, viewerUserID int6
}
// pushGroupCallUpdate 把 updateGroupCall(call 行变化)推给在线群成员。
// 定时通话需 per-viewer 回填 schedule_start_subscribed:TDesktop applyCallFields
// 无条件覆盖本地该 flag,漏填会把订阅者的"开播提醒"开关静默关掉。
func (r *Router) pushGroupCallUpdate(ctx context.Context, channel domain.Channel, call domain.GroupCall) {
if call.Conference() {
r.pushConferenceGroupCallUpdate(ctx, call)
return
}
var subscribed map[int64]struct{}
if call.ScheduleDate > 0 {
if ids, err := r.deps.GroupCalls.ScheduleSubscriberIDs(ctx, call.ID); err == nil {
subscribed = make(map[int64]struct{}, len(ids))
for _, id := range ids {
subscribed[id] = struct{}{}
}
}
}
recipients := r.groupCallOnlineRecipients(ctx, channel.ID)
for _, viewerID := range recipients {
update := &tg.UpdateGroupCall{Call: tgGroupCall(call, viewerID, false)}
viewerCall := call
if subscribed != nil {
_, viewerCall.ScheduleStartSubscribed = subscribed[viewerID]
}
update := &tg.UpdateGroupCall{Call: tgGroupCall(viewerCall, viewerID, false)}
update.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
r.pushUserMessage(ctx, viewerID, "group call update",
r.groupCallUpdateContainer(ctx, viewerID, channel, update, []int64{call.CreatorUserID}))

View file

@ -41,6 +41,9 @@ func (r *Router) registerPhone(d *tg.ServerDispatcher) {
d.OnPhoneEditGroupCallTitle(r.onPhoneEditGroupCallTitle)
d.OnPhoneToggleGroupCallSettings(r.onPhoneToggleGroupCallSettings)
d.OnPhoneInviteToGroupCall(r.onPhoneInviteToGroupCall)
// 定时通话(scheduled video chat)。
d.OnPhoneStartScheduledGroupCall(r.onPhoneStartScheduledGroupCall)
d.OnPhoneToggleGroupCallStartSubscription(r.onPhoneToggleGroupCallStartSubscription)
// Ad-hoc E2E conference call(P2P 通话升级/拉人路径)。
d.OnPhoneCreateConferenceCall(r.onPhoneCreateConferenceCall)
d.OnPhoneInviteConferenceCallParticipant(r.onPhoneInviteConferenceCallParticipant)

View file

@ -10,18 +10,9 @@ import (
// 通话内消息族 / scheduled / RTMP)走 router fallback:400/500 NOT_IMPLEMENTED +
// 兼容矩阵日志,客户端不断连。
func (r *Router) registerPhoneStubs(d *tg.ServerDispatcher) {
// 入会面板前置调用:返回 self 一个候选身份(空返回会卡 UI)。
d.OnPhoneGetGroupCallJoinAs(func(ctx context.Context, peer tg.InputPeerClass) (*tg.PhoneJoinAsPeers, error) {
userID, err := r.phoneRequireUser(ctx)
if err != nil {
return nil, err
}
return &tg.PhoneJoinAsPeers{
Peers: []tg.PeerClass{&tg.PeerUser{UserID: userID}},
Chats: []tg.ChatClass{},
Users: r.tgUsersForIDs(ctx, userID, []int64{userID}),
}, nil
})
// 入会身份候选:真实实现见 phone_group_call.go(self + admin 的频道身份)。
d.OnPhoneGetGroupCallJoinAs(r.onPhoneGetGroupCallJoinAs)
// default join-as 偏好持久化仍是 stub(chatFull.groupcall_default_join_as 不回填)。
d.OnPhoneSaveDefaultGroupCallJoinAs(func(ctx context.Context, req *tg.PhoneSaveDefaultGroupCallJoinAsRequest) (bool, error) {
return true, nil
})
@ -29,8 +20,7 @@ func (r *Router) registerPhoneStubs(d *tg.ServerDispatcher) {
d.OnPhoneToggleGroupCallRecord(func(ctx context.Context, req *tg.PhoneToggleGroupCallRecordRequest) (tg.UpdatesClass, error) {
return tgEmptyUpdates(int(r.clock.Now().Unix())), nil
})
// RTMP 直播范围外。
d.OnPhoneGetGroupCallStreamChannels(func(ctx context.Context, call tg.InputGroupCallClass) (*tg.PhoneGroupCallStreamChannels, error) {
return &tg.PhoneGroupCallStreamChannels{Channels: []tg.GroupCallStreamChannel{}}, nil
})
// RTMP 直播(Live Stream):真实 handler 见 phone_group_call_rtmp.go。
d.OnPhoneGetGroupCallStreamChannels(r.onPhoneGetGroupCallStreamChannels)
d.OnPhoneGetGroupCallStreamRtmpURL(r.onPhoneGetGroupCallStreamRtmpURL)
}

View file

@ -67,6 +67,9 @@ type Config struct {
CallForceRelay bool
// GroupCallMaxParticipants 是群通话单房间参与者上限;<=0 不限制。
GroupCallMaxParticipants int
// RtmpIngestURL 是 getGroupCallStreamRtmpUrl 返回给推流端(OBS)的服务器地址,
// 形如 "rtmp://<host>:<port>/live"。为空时回落 "rtmp://<AdvertiseIP>:2400/live"。
RtmpIngestURL string
// TempKeyResolveCacheTTL 是 PFS temp→perm auth key 解析的进程内缓存有效期。>0 时同一 temp key
// 在 TTL 内复用上次解析、跳过每帧 ResolveAuthKey 的 PG 查询;0(默认/测试)关闭=每帧重校验。
// 显式撤销会删除协议 auth key、清缓存并断开活跃连接;TTL 只影响自然过期或异常路径下的
@ -163,6 +166,9 @@ func New(cfg Config, deps Deps, log *zap.Logger, clk clock.Clock) *Router {
r := &Router{cfg: cfg, log: log, clock: clk, deps: deps, presence: newPresenceTracker(), callbacks: newCallbackRegistry(), inlines: newInlineRegistry(botInlineQueryTTL, deps.Inline), webviews: newWebViewRegistry(webViewSessionTTL, deps.Inline), loginTokens: newLoginTokenRegistry(), tempKeyResolveCache: newTempKeyResolveCache(cfg.TempKeyResolveCacheMaxEntries), storyProjectionCache: newStoryProjectionCache(clk.Now), storyPinnedCache: newStoryPinnedAvailableCache(clk.Now), storyPinnedListCache: newStoryPinnedStoriesCache(clk.Now), channelFullBotCache: newChannelFullBotInfoCache(clk.Now), userFullProjectionCache: newUserFullProjectionCache(clk.Now), peerSettingsProjectionCache: newPeerSettingsProjectionCache(clk.Now), channelFullProjectionCache: newChannelFullProjectionCache(clk.Now), emojiStickers: newEmojiStickerIndex(clk.Now), notifySettings: newNotifySettingsCache(clk.Now), stickerCatalog: newStickerCatalogCache(clk.Now), accountSettings: newAccountSettingsCache(clk.Now), instanceID: instanceID}
r.channelFanout = newChannelFanoutDispatcher(r, defaultChannelFanoutShards, defaultChannelFanoutBuffer)
r.webPageResolveSem = make(chan struct{}, webPageResolveConcurrency)
if cfg.DC > 0 {
groupCallStreamDCID = cfg.DC
}
d := tg.NewServerDispatcher(r.fallback)
r.registerHelp(d)

View file

@ -201,7 +201,7 @@ func TestStickersBotCreatePackLinkInstallIsolationSmoke(t *testing.T) {
sendStickersBotText(t, r, alice, "Alice Bot Pack", 9102)
waitForStickersReply(t, messageStore, alice.ID, "Lottie JSON")
sendStickersBotDocument(t, r, alice, 401, 4401, 9103)
waitForStickersReply(t, messageStore, alice.ID, "emoji")
waitForStickersReply(t, messageStore, alice.ID, "Now send the emoji")
sendStickersBotText(t, r, alice, "🙂", 9104)
waitForStickersReply(t, messageStore, alice.ID, "Added")
sendStickersBotText(t, r, alice, "/publish", 9105)

View file

@ -7,6 +7,7 @@ import (
"strings"
"github.com/gotd/td/tg"
"go.uber.org/zap"
"telesrv/internal/domain"
)
@ -67,12 +68,17 @@ func (r *Router) onUploadSaveBigFilePart(ctx context.Context, req *tg.UploadSave
}
func (r *Router) onUploadGetFile(ctx context.Context, req *tg.UploadGetFileRequest) (tg.UploadFileClass, error) {
if r.deps.Files == nil {
return nil, notImplementedErr()
}
if req.Offset < 0 || req.Limit <= 0 || req.Limit > maxUploadGetFileChunkLimit {
return nil, limitInvalidErr()
}
// RTMP 直播拉流:inputGroupCallStream 不落 file_blobs、不依赖 Files 服务,
// 直连 livestream 媒体面(须先于 Files nil 检查)。
if loc, ok := req.Location.(*tg.InputGroupCallStream); ok {
return r.onUploadGetGroupCallStream(ctx, loc, req.Offset, req.Limit)
}
if r.deps.Files == nil {
return nil, notImplementedErr()
}
key, ok := fileLocationKey(req.Location)
if !ok {
return nil, locationInvalidErr()
@ -95,6 +101,54 @@ func (r *Router) onUploadGetFile(ctx context.Context, req *tg.UploadGetFileReque
return nil, locationInvalidErr()
}
// onUploadGetGroupCallStream 处理 RTMP 直播观众拉流:按 time_ms/scale 取一段打包好的
// tgcalls broadcast part,再按 offset/limit 切片返回。错误语义对齐 TDesktop 消费点
// (calls_group_call.cpp broadcastPartStart):
// - 段未就绪(时间轴还没走到)→ TIME_TOO_BIG(客户端 100ms 后原样重试)
// - 段已过期/无流/未加入 → GROUPCALL_JOIN_MISSING(触发客户端 rejoin 重新对时)
func (r *Router) onUploadGetGroupCallStream(ctx context.Context, loc *tg.InputGroupCallStream, offset int64, limit int) (tg.UploadFileClass, error) {
if r.deps.LiveStreams == nil {
return nil, notImplementedErr()
}
scope, err := r.groupCallScopeFrom(ctx, loc.Call)
if err != nil {
return nil, err
}
if !scope.call.RtmpStream || scope.channel.ID == 0 {
return nil, groupCallInvalidErr()
}
// RTMP 观众在 stream 模式不发 checkGroupCall 心跳、也无 SFU 媒体面活性,
// 拉流请求(~1/s/观众)就是它的保活信号——不刷会被 sweeper 置 left,
// 客户端每 ~50s 报 "Rejoin after got 'left' with my ssrc" 循环重进。
if _, _, err := r.deps.GroupCalls.Touch(ctx, scope.call.ID, scope.userID, int(r.clock.Now().Unix())); err != nil {
r.log.Debug("live stream viewer touch", zap.Int64("call_id", scope.call.ID), zap.Error(err))
}
part, err := r.deps.LiveStreams.StreamPart(scope.channel.ID, loc.TimeMs, loc.Scale)
switch {
case errors.Is(err, domain.ErrLiveStreamPartNotReady):
// 时间轴尚未走到该段:客户端 100ms 后原样重试(Status::NotReady)。
return nil, tgerr400("TIME_TOO_BIG")
case errors.Is(err, domain.ErrLiveStreamPartExpired), errors.Is(err, domain.ErrLiveStreamNoStream):
// 段已淘汰/无流:客户端重新对时后 resync(Status::ResyncNeeded)。
return nil, tgerr400("STREAM_TIMESTAMP_EXPIRED")
case err != nil:
return nil, internalErr()
}
// offset 越界返回空 bytes(客户端已读完该段即停止续读,limit=128KiB 单次到底)。
if offset >= int64(len(part)) {
return &tg.UploadFile{Type: &tg.StorageFileUnknown{}, Bytes: []byte{}}, nil
}
end := offset + int64(limit)
if end > int64(len(part)) {
end = int64(len(part))
}
return &tg.UploadFile{
Type: &tg.StorageFileUnknown{},
Mtime: 0,
Bytes: part[offset:end],
}, nil
}
// onUploadGetFileHashes 返回空 hash 列表:本阶段不做 CDN/分片完整性校验,客户端据空列表直接信任数据。
func (r *Router) onUploadGetFileHashes(ctx context.Context, req *tg.UploadGetFileHashesRequest) ([]tg.FileHash, error) {
return []tg.FileHash{}, nil