378 lines
15 KiB
Go
378 lines
15 KiB
Go
package rpc
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"strconv"
|
||
|
||
"github.com/gotd/td/tg"
|
||
|
||
"telesrv/internal/domain"
|
||
"telesrv/internal/sfu"
|
||
)
|
||
|
||
// ---- TL 转换 ----
|
||
|
||
// groupCallUnmutedVideoLimit 是同时开视频的参与者上限(官方经 appConfig
|
||
// groupcall_video_participants_max 下发,缺省 30)。
|
||
// ⚠ unmuted_video_limit 是 TL 非可选 int:留 0 会让两端的视频/屏幕共享闸门
|
||
// `activeVideoSendersCount() >= limit` 恒真——「You can't share your screen in
|
||
// 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, publicBaseURL ...string) tg.GroupCallClass {
|
||
if !call.Active() {
|
||
return &tg.GroupCallDiscarded{ID: call.ID, AccessHash: call.AccessHash, Duration: call.Duration}
|
||
}
|
||
out := &tg.GroupCall{
|
||
JoinMuted: call.JoinMuted,
|
||
CanChangeJoinMuted: canManage,
|
||
Creator: call.CreatorUserID == viewerUserID && viewerUserID != 0,
|
||
Conference: call.Conference(),
|
||
// can_start_video:TDesktop 不读;DrKLO 用它喂入会前 dummy self 行的
|
||
// video_joined。RTC 通话一律放行。
|
||
CanStartVideo: true,
|
||
ID: call.ID,
|
||
AccessHash: call.AccessHash,
|
||
ParticipantsCount: call.ParticipantsCount,
|
||
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)
|
||
}
|
||
if call.Conference() {
|
||
baseURL := ""
|
||
if len(publicBaseURL) > 0 {
|
||
baseURL = publicBaseURL[0]
|
||
}
|
||
if link := conferenceCanonicalInviteLink(call.InviteSlug, baseURL); link != "" {
|
||
out.SetInviteLink(link)
|
||
} else if call.InviteLink != "" {
|
||
out.SetInviteLink(call.InviteLink)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// 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: peer,
|
||
Date: p.JoinDate,
|
||
Source: int(int32(uint32(p.SSRC))), // uint32 按位转 int32(join JSON 同款语义)
|
||
}
|
||
if p.Left {
|
||
// left 行不再携带 can_self_unmute 语义。
|
||
out.CanSelfUnmute = false
|
||
} else {
|
||
// video_joined:RTC 入会一律置位。self 行缺它会让 TDesktop 置
|
||
// _videoIsWorking=false 并强制关掉本端摄像头/屏幕共享。
|
||
out.VideoJoined = true
|
||
}
|
||
if p.ActiveDate > 0 {
|
||
out.SetActiveDate(p.ActiveDate)
|
||
}
|
||
if p.VolumeByAdmin > 0 {
|
||
out.VolumeByAdmin = true
|
||
out.SetVolume(p.VolumeByAdmin)
|
||
}
|
||
if p.RaiseHandRating > 0 {
|
||
out.SetRaiseHandRating(p.RaiseHandRating)
|
||
}
|
||
// 视频/屏幕共享:Active 且未离会才输出字段(video_stopped ⇒ 字段消失;
|
||
// paused ⇒ 字段保留但置 paused flag)。min 行(overrides 推送)不会走到
|
||
// 这里改写 videoParams——applyParticipantOverride 只动 muted/volume。
|
||
if !p.Left {
|
||
if st, ok := decodeVideoState(p.VideoJSON); ok && st.Active {
|
||
out.SetVideo(*tgParticipantVideo(st, false))
|
||
}
|
||
if st, ok := decodeVideoState(p.PresentationJSON); ok && st.Active {
|
||
out.SetPresentation(*tgParticipantVideo(st, true))
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func tgGroupCallParticipants(rows []domain.GroupCallParticipant, viewerUserID int64) []tg.GroupCallParticipant {
|
||
out := make([]tg.GroupCallParticipant, 0, len(rows))
|
||
for _, p := range rows {
|
||
out = append(out, tgGroupCallParticipant(p, viewerUserID))
|
||
}
|
||
return out
|
||
}
|
||
|
||
// applyParticipantOverride 把 setter 的 per-viewer 覆盖(本地静音/音量)叠加到推给
|
||
// 该 setter 的 participant 行上,并置 min flag——min 告诉客户端「本行的部分字段
|
||
// 不可全信,按本地状态保留」,防止 setter 其它设备用此行覆盖本地 muted/volume。
|
||
func applyParticipantOverride(p tg.GroupCallParticipant, ov domain.GroupCallParticipantOverride) tg.GroupCallParticipant {
|
||
p.SetMin(true)
|
||
if ov.MutedByYou {
|
||
p.SetMutedByYou(true)
|
||
}
|
||
if ov.Volume > 0 {
|
||
p.SetVolume(ov.Volume)
|
||
}
|
||
return p
|
||
}
|
||
|
||
// inputGroupCallRef 解析 InputGroupCallClass 的三个变体。
|
||
// slug / inviteMessage 变体属 conference 路径(范围外),返回 GROUPCALL_INVALID
|
||
// 而非类型断言 panic——TDesktop conference 迁移会真实发出 inputGroupCallSlug。
|
||
func inputGroupCallRef(call tg.InputGroupCallClass) (callID, accessHash int64, err error) {
|
||
switch v := call.(type) {
|
||
case *tg.InputGroupCall:
|
||
return v.ID, v.AccessHash, nil
|
||
default:
|
||
return 0, 0, groupCallInvalidErr()
|
||
}
|
||
}
|
||
|
||
// ---- join JSON(上行)----
|
||
|
||
// groupCallJoinPayload 是 phone.joinGroupCall.params 的上行 JSON
|
||
// (tgcalls GroupJoinPayloadInternal::serialize 的产物,逐字段对应)。
|
||
type groupCallJoinPayload struct {
|
||
SSRC int32 `json:"ssrc"`
|
||
Ufrag string `json:"ufrag"`
|
||
Pwd string `json:"pwd"`
|
||
Fingerprints []groupCallFingerprint `json:"fingerprints"`
|
||
SsrcGroups []groupCallSsrcGroup `json:"ssrc-groups,omitempty"`
|
||
}
|
||
|
||
type groupCallFingerprint struct {
|
||
Hash string `json:"hash"`
|
||
Fingerprint string `json:"fingerprint"`
|
||
Setup string `json:"setup"`
|
||
}
|
||
|
||
type groupCallSsrcGroup struct {
|
||
Semantics string `json:"semantics"`
|
||
Sources []int64 `json:"sources"`
|
||
}
|
||
|
||
// parseGroupCallJoinPayload 解析并校验上行 JSON;解析必须容忍多余字段。
|
||
// ssrc-groups(视频 simulcast/RTX 源组)无论摄像头开关都会随主 join 携带,
|
||
// 必须存档:等 video_stopped=false 时原样回放进 participant.video。
|
||
func parseGroupCallJoinPayload(data string) (sfu.ClientOffer, int64, error) {
|
||
var payload groupCallJoinPayload
|
||
if err := json.Unmarshal([]byte(data), &payload); err != nil {
|
||
return sfu.ClientOffer{}, 0, fmt.Errorf("parse join payload: %w", err)
|
||
}
|
||
if payload.Ufrag == "" || payload.Pwd == "" || payload.SSRC == 0 {
|
||
return sfu.ClientOffer{}, 0, fmt.Errorf("join payload missing ssrc/ufrag/pwd")
|
||
}
|
||
offer := sfu.ClientOffer{
|
||
// ssrc 是把 uint32 按位转 int32 的有符号值,还原为 uint32。
|
||
AudioSSRC: uint32(payload.SSRC),
|
||
Ufrag: payload.Ufrag,
|
||
Pwd: payload.Pwd,
|
||
}
|
||
for _, fp := range payload.Fingerprints {
|
||
if fp.Hash == "sha-256" {
|
||
offer.FingerprintSHA256 = fp.Fingerprint
|
||
break
|
||
}
|
||
}
|
||
if offer.FingerprintSHA256 == "" {
|
||
return sfu.ClientOffer{}, 0, fmt.Errorf("join payload missing sha-256 fingerprint")
|
||
}
|
||
for _, g := range payload.SsrcGroups {
|
||
sg := sfu.SsrcGroup{Semantics: g.Semantics, Sources: make([]uint32, 0, len(g.Sources))}
|
||
for _, src := range g.Sources {
|
||
// sources 同样是 int32 位重解释。
|
||
sg.Sources = append(sg.Sources, uint32(int32(src)))
|
||
}
|
||
offer.SsrcGroups = append(offer.SsrcGroups, sg)
|
||
}
|
||
return offer, int64(offer.AudioSSRC), nil
|
||
}
|
||
|
||
// ---- participant 视频内部状态(video_json / presentation_json 列)----
|
||
|
||
// participantVideoState 是存进 video_json/presentation_json 的内部快照(非 wire
|
||
// 格式):endpoint 由服务端铸造且必须与 join 响应 video.endpoint 逐字节一致;
|
||
// source_groups 是上行 ssrc-groups 的原样存档;Active 决定 TL 字段是否出现。
|
||
type participantVideoState struct {
|
||
Endpoint string `json:"endpoint"`
|
||
SourceGroups []groupCallSsrcGroup `json:"source_groups,omitempty"`
|
||
Active bool `json:"active"`
|
||
Paused bool `json:"paused,omitempty"`
|
||
// AudioSource 仅 presentation:屏幕实例自己的音频 ssrc(int32 位重解释存原值)。
|
||
AudioSource int64 `json:"audio_source,omitempty"`
|
||
}
|
||
|
||
func encodeVideoState(st participantVideoState) []byte {
|
||
out, err := json.Marshal(st)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
return out
|
||
}
|
||
|
||
func decodeVideoState(raw []byte) (participantVideoState, bool) {
|
||
if len(raw) == 0 {
|
||
return participantVideoState{}, false
|
||
}
|
||
var st participantVideoState
|
||
if err := json.Unmarshal(raw, &st); err != nil || st.Endpoint == "" {
|
||
return participantVideoState{}, false
|
||
}
|
||
return st, true
|
||
}
|
||
|
||
// tgParticipantVideo 把内部状态转为 TL groupCallParticipantVideo(Active 才输出)。
|
||
// withAudioSource 仅 presentation 置 true(DrKLO 用 audio_source 做屏幕 mySource,
|
||
// 缺失会退化取首个视频 ssrc 致 checkGroupCall 死循环重建)。
|
||
func tgParticipantVideo(st participantVideoState, withAudioSource bool) *tg.GroupCallParticipantVideo {
|
||
out := &tg.GroupCallParticipantVideo{
|
||
Paused: st.Paused,
|
||
Endpoint: st.Endpoint,
|
||
}
|
||
for _, g := range st.SourceGroups {
|
||
sg := tg.GroupCallParticipantVideoSourceGroup{Semantics: g.Semantics}
|
||
for _, src := range g.Sources {
|
||
sg.Sources = append(sg.Sources, int(int32(uint32(src))))
|
||
}
|
||
out.SourceGroups = append(out.SourceGroups, sg)
|
||
}
|
||
if withAudioSource && st.AudioSource != 0 {
|
||
out.SetAudioSource(int(int32(uint32(st.AudioSource))))
|
||
}
|
||
return out
|
||
}
|
||
|
||
// groupCallSsrcGroupsFromOffer 把解析后的源组转回存档形态(保持 int32 位重解释
|
||
// 的有符号写法,与上行 JSON 一致)。
|
||
func groupCallSsrcGroupsFromOffer(offer sfu.ClientOffer) []groupCallSsrcGroup {
|
||
out := make([]groupCallSsrcGroup, 0, len(offer.SsrcGroups))
|
||
for _, g := range offer.SsrcGroups {
|
||
sg := groupCallSsrcGroup{Semantics: g.Semantics, Sources: make([]int64, 0, len(g.Sources))}
|
||
for _, src := range g.Sources {
|
||
sg.Sources = append(sg.Sources, int64(int32(src)))
|
||
}
|
||
out = append(out, sg)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// groupCallEndpointID 铸造 camera/presentation 的 endpoint 串:call 内唯一
|
||
// (ssrc 有活跃唯一索引兜底)、同 participant 两路互异、rejoin 换 ssrc 自然换串。
|
||
func groupCallEndpointID(kind sfu.EndpointKind, audioSSRC uint32) string {
|
||
if kind == sfu.EndpointPresentation {
|
||
return fmt.Sprintf("presentation-%d", audioSSRC)
|
||
}
|
||
return fmt.Sprintf("audio-%d", audioSSRC)
|
||
}
|
||
|
||
// ---- 下行 JSON(updateGroupCallConnection.params)----
|
||
|
||
// videoRtcpFbs 是视频 codec 的反馈能力表(与客户端本地 addDefaultFeedbackParams
|
||
// 一致:goog-remb/transport-cc/ccm fir/nack/nack pli)。
|
||
func videoRtcpFbs() []map[string]any {
|
||
return []map[string]any{
|
||
{"type": "goog-remb"},
|
||
{"type": "transport-cc"},
|
||
{"type": "ccm", "subtype": "fir"},
|
||
{"type": "nack"},
|
||
{"type": "nack", "subtype": "pli"},
|
||
}
|
||
}
|
||
|
||
// buildGroupCallConnectionParams 组装下行 transport JSON(契约 schema 逐字段照抄,
|
||
// 解析方为 tgcalls GroupJoinPayloadInternal/GroupNetworkManager)。要点:
|
||
// - transport.fingerprints.setup="active":SFU 是 DTLS 主动握手方;
|
||
// - candidates 全部字段都是字符串(数值也要写成字符串);
|
||
// - video 节即使纯收看也必须存在(缺失则客户端不建任何视频通道);
|
||
// - video.endpoint 是**本参与者自己**的视频 endpoint 标识,必须与日后写进其
|
||
// participant.video.endpoint 的串逐字节一致(客户端靠它做自我过滤);
|
||
// - payload-types 照抄客户端本地确定性分配(VP8=100/rtx101、VP9=102/rtx103,
|
||
// PT 由客户端自配,本表主要给其它平台客户端消费);不含 H264 ⇒ 全网 VP8 缺省;
|
||
// - rtp-hdrexts 照抄客户端硬编码 id 表(1=audio-level、2=abs-send-time、
|
||
// 3=transport-wide-cc、13=video-orientation)。
|
||
func buildGroupCallConnectionParams(answer sfu.ServerAnswer, endpoint string) (string, error) {
|
||
candidates := make([]map[string]any, 0, len(answer.Candidates))
|
||
for i, c := range answer.Candidates {
|
||
candidates = append(candidates, map[string]any{
|
||
"component": "1",
|
||
"protocol": c.Protocol,
|
||
"port": strconv.Itoa(c.Port),
|
||
"ip": c.IP,
|
||
"type": c.Type,
|
||
"priority": "2130706431",
|
||
"foundation": "1",
|
||
"generation": "0",
|
||
"network": "1",
|
||
"id": fmt.Sprintf("c%d", i+1),
|
||
})
|
||
}
|
||
params := map[string]any{
|
||
"transport": map[string]any{
|
||
"ufrag": answer.Ufrag,
|
||
"pwd": answer.Pwd,
|
||
"fingerprints": []map[string]any{{
|
||
"hash": "sha-256",
|
||
"fingerprint": answer.FingerprintSHA256,
|
||
"setup": "active",
|
||
}},
|
||
"candidates": candidates,
|
||
},
|
||
"video": map[string]any{
|
||
"endpoint": endpoint,
|
||
"payload-types": []map[string]any{
|
||
{"id": 100, "name": "VP8", "clockrate": 90000, "channels": 0,
|
||
"parameters": map[string]any{}, "rtcp-fbs": videoRtcpFbs()},
|
||
{"id": 101, "name": "rtx", "clockrate": 90000, "channels": 0,
|
||
"parameters": map[string]any{"apt": "100"}, "rtcp-fbs": []map[string]any{}},
|
||
{"id": 102, "name": "VP9", "clockrate": 90000, "channels": 0,
|
||
"parameters": map[string]any{}, "rtcp-fbs": videoRtcpFbs()},
|
||
{"id": 103, "name": "rtx", "clockrate": 90000, "channels": 0,
|
||
"parameters": map[string]any{"apt": "102"}, "rtcp-fbs": []map[string]any{}},
|
||
},
|
||
"rtp-hdrexts": []map[string]any{
|
||
{"id": 1, "uri": "urn:ietf:params:rtp-hdrext:ssrc-audio-level"},
|
||
{"id": 2, "uri": "http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time"},
|
||
{"id": 3, "uri": "http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01"},
|
||
{"id": 13, "uri": "urn:3gpp:video-orientation"},
|
||
},
|
||
"server_sources": []int64{},
|
||
},
|
||
}
|
||
out, err := json.Marshal(params)
|
||
if err != nil {
|
||
return "", fmt.Errorf("marshal connection params: %w", err)
|
||
}
|
||
return string(out), nil
|
||
}
|