feat: sync group call livestream support
This commit is contained in:
parent
56d995474c
commit
f1a27996d3
37 changed files with 2219 additions and 83 deletions
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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 同款语义)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
113
internal/rpc/phone_group_call_joinas_test.go
Normal file
113
internal/rpc/phone_group_call_joinas_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
192
internal/rpc/phone_group_call_rtmp.go
Normal file
192
internal/rpc/phone_group_call_rtmp.go
Normal 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
|
||||
}
|
||||
267
internal/rpc/phone_group_call_rtmp_test.go
Normal file
267
internal/rpc/phone_group_call_rtmp_test.go
Normal 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), ¶ms); 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), ¶msM); 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)
|
||||
}
|
||||
}
|
||||
112
internal/rpc/phone_group_call_scheduled.go
Normal file
112
internal/rpc/phone_group_call_scheduled.go
Normal 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
|
||||
}
|
||||
151
internal/rpc/phone_group_call_scheduled_test.go
Normal file
151
internal/rpc/phone_group_call_scheduled_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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}))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue