import type { DraftyContent, StoredMessage, Topic } from 'tinode-sdk'; import { newClientMid } from '@/lib/tinode/send'; /** * Call signaling send-side helpers. Confirmed shape (docs/IM-前后端对接文档.md §0.5.4.7/§8, lines * 424-435/1945-2076): the invite is a plain-JSON `{pub}` (NOT Drafty -- `topic.createMessage` only * auto-Drafty-wraps string/Drafty-shaped content, a plain `{callId,...}` object passes through as * literal `content`), `head:{mime:"application/json", webrtc:"started", "x-im-type":"call"}`. * Ringing/accept/hang-up then go over `topic.videoCall()` (thin wrapper around the SDK's native * `{note what:"call"}`, see tinode-sdk.d.ts's own comment) -- NOT offer/answer/ice-candidate, which * this app doesn't use (see calls.ts's own header comment in lib/api on the Agora/rtc-join media * path this backend actually implements instead of raw SDP/ICE over the IM WebSocket). */ export interface CallContent { callId: string; callType: 'audio' | 'video'; audio: boolean; video: boolean; status?: string; } /** `head.webrtc` values a call message cycles through, replacing the SAME `seq` in place (doc line * 1953) -- `started` is the live invite; the rest are terminal results the server writes over it. */ export type CallWebrtcState = 'started' | 'accepted' | 'finished' | 'disconnected' | 'missed' | 'declined'; export function callContentOf(msg: StoredMessage): CallContent | null { const webrtc = msg.head?.webrtc; if (typeof webrtc !== 'string' || !msg.content || typeof msg.content !== 'object') return null; const content = msg.content as unknown as CallContent; if (typeof content.callId !== 'string') return null; return content; } export function callStateOf(msg: StoredMessage): CallWebrtcState | null { const webrtc = msg.head?.webrtc; return typeof webrtc === 'string' ? (webrtc as CallWebrtcState) : null; } export function callDurationMsOf(msg: StoredMessage): number { const duration = msg.head?.['webrtc-duration']; return typeof duration === 'number' ? duration : 0; } /** * Sends the call invite. `noEcho: false` (matches send.ts's sendText) so the caller's own UI gets an * immediate optimistic bubble via `publishDraft`, same as any other message. * * Confirmed live: an audio call's outbound packet never actually carries `video:false` on the wire * -- the SDK's own `#send()` runs every outbound packet through a generic `simplify()` (tinode.dev. * js:5881-5903) that recursively deletes any falsy field, with no way to mark one as intentional. * This isn't fixable from the call site (it strips ANY false-valued content field this app might * ever send this way, not just this one) and doesn't matter in practice here -- neither this file's * own `callContentOf` nor CallRoot.tsx reads `content.audio`/`content.video`, only `content. * callType` (always a non-empty string, never stripped), so a missing boolean never surfaces as a * real bug -- but a future call site that DID need to trust an explicit `false` over "field absent" * would silently lose that distinction. */ export function sendCallInvite(topic: Topic, callId: string, callType: 'audio' | 'video'): Promise<{ code: number; params?: { seq: number } }> { const content: CallContent = { callId, callType, audio: true, video: callType === 'video' }; // `createMessage`'s own type only admits Drafty content, but its real implementation (tinode.dev. // js:7170-7182) passes any non-string content through untouched -- verified reading that source, // not guessed. This cast is deliberately lying to the type system for that one confirmed reason. const msg = topic.createMessage(content as unknown as DraftyContent, false); msg.head = Object.assign(msg.head || {}, { mime: 'application/json', webrtc: 'started', 'x-im-type': 'call', 'x-im-client-mid': newClientMid() }); return topic.publishDraft(msg, Promise.resolve()) as Promise<{ code: number; params?: { seq: number } }>; } export function sendCallRinging(topic: Topic, seq: number, callId: string) { return topic.videoCall('ringing', seq, { callId }); } export function sendCallAccept(topic: Topic, seq: number, callId: string) { return topic.videoCall('accept', seq, { callId }); } export function sendCallHangup(topic: Topic, seq: number, callId: string, reason?: string) { return topic.videoCall('hang-up', seq, reason ? { callId, reason } : { callId }); }