@quitting/core 1.0.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"im-core.es.js","names":[],"sources":["../src/events.ts","../src/api-error.ts","../src/transport.ts","../src/auth.ts","../src/realtime.ts","../src/call.ts","../src/meeting.ts","../src/message.ts","../src/media.ts","../src/index.ts"],"sourcesContent":["import type { ImEventName, ImEventMap } from '@quitting/contract'\n\n// 简易 EventEmitter(不引第三方库)\n// 用法:client.on('call:invite', (e: CallInviteEvent) => {...})\nexport class EventEmitter {\n private listeners = new Map<ImEventName, Set<Function>>()\n\n on<K extends ImEventName>(event: K, handler: (payload: ImEventMap[K]) => void): () => void {\n if (!this.listeners.has(event)) {\n this.listeners.set(event, new Set())\n }\n this.listeners.get(event)!.add(handler as Function)\n // 返回 unsubscribe 函数\n return () => this.off(event, handler)\n }\n\n off<K extends ImEventName>(event: K, handler: (payload: ImEventMap[K]) => void): void {\n this.listeners.get(event)?.delete(handler as Function)\n }\n\n protected emit<K extends ImEventName>(event: K, payload: ImEventMap[K]): void {\n const set = this.listeners.get(event)\n if (!set) return\n for (const fn of set) {\n try {\n fn(payload)\n } catch (err) {\n console.error(`[im] event handler error: ${event}`, err)\n }\n }\n }\n\n // 清空所有监听器(disconnect 时调用)\n removeAllListeners(): void {\n this.listeners.clear()\n }\n}\n","// ApiError:SDK 对外抛出的结构化错误,携带业务码供调用方精确分支\n//\n// 错误响应规约(与 server/internal/api/respond.go 对齐):\n// HTTP 4xx/5xx,body = {code: <业务码>, message: <友好提示>}\n//\n// 调用方用法:\n// try {\n// await client.call.init(...)\n// } catch (e) {\n// if (e instanceof ApiError) {\n// if (e.code === ApiErrorCode.Busy) { ... }\n// }\n// }\nexport class ApiError extends Error {\n /** 业务错误码(5 位数字,见 @quitting/contract ApiErrorCode) */\n readonly code: number\n /** HTTP 状态码(如 409/401/500) */\n readonly httpStatus: number\n\n constructor(code: number, httpStatus: number, message: string) {\n super(message)\n this.name = 'ApiError'\n this.code = code\n this.httpStatus = httpStatus\n }\n}\n","import type {\n SendMessageReq,\n SendMessageResp,\n CallInitResp,\n CallAcceptResp,\n CreateMeetingReq,\n CreateMeetingResp,\n JoinMeetingResp,\n ListMeetingMembersResp,\n TransferHostReq,\n TransferHostResp,\n DissolveResp,\n ParticipantCountResp,\n ListPendingInvitesResp,\n SessionState,\n UploadUrlReq,\n UploadUrlResp,\n CheckMd5Resp,\n ClientConfig,\n} from '@quitting/contract'\nimport { ApiError } from './api-error'\n\n// Transport:REST API 客户端封装\n// 所有 HTTP 调用统一走这里,自动注入 Authorization 头 + trace_id\nexport class Transport {\n private apiBase: string\n private tokenGetter: () => string | null\n\n constructor(apiBase: string, tokenGetter: () => string | null) {\n this.apiBase = apiBase.replace(/\\/$/, '')\n this.tokenGetter = tokenGetter\n }\n\n private async request<T>(path: string, opts: RequestInit = {}): Promise<T> {\n const url = `${this.apiBase}${path}`\n const headers: Record<string, string> = {\n 'Content-Type': 'application/json',\n ...(opts.headers as Record<string, string>),\n }\n const token = this.tokenGetter()\n if (token) {\n headers['Authorization'] = `Bearer ${token}`\n }\n const traceId = crypto.randomUUID?.() || Math.random().toString(36).slice(2)\n headers['X-Trace-Id'] = traceId\n\n const resp = await fetch(url, { ...opts, headers })\n if (!resp.ok) {\n // 解析业务码 + 友好文案,抛 ApiError 供调用方精确分支\n let code = 0\n let msg = `HTTP ${resp.status}`\n try {\n const body = await resp.json()\n if (body?.code) code = Number(body.code)\n if (body?.message) msg = body.message\n } catch {\n // 非 JSON 响应(如网关 502),保留默认 msg\n }\n throw new ApiError(code, resp.status, msg)\n }\n return resp.json() as Promise<T>\n }\n\n // fire-and-forget 风格的请求:用于 beforeunload 等浏览器即将卸载场景\n // keepalive: true 让浏览器在页面 unload 后仍能完成请求(≤ 64KB)\n // 不返回响应——失败也无法感知,调用方必须能容忍丢失\n async sendBeacon(path: string, body: unknown): Promise<void> {\n const url = `${this.apiBase}${path}`\n const headers: Record<string, string> = { 'Content-Type': 'application/json' }\n const token = this.tokenGetter()\n if (token) headers['Authorization'] = `Bearer ${token}`\n try {\n await fetch(url, {\n method: 'POST',\n headers,\n body: JSON.stringify(body),\n keepalive: true,\n })\n } catch (err) {\n console.warn('[im] sendBeacon 失败(页面卸载中可能正常)', err)\n }\n }\n\n // ===== 鉴权 =====\n // 客户端运行时配置(登录前拉,无鉴权)\n // SDK 在 connect 时调一次,结果全局缓存\n getClientConfig() {\n return this.request<ClientConfig>('/client/config')\n }\n\n login(appId: string, externalId: string, clientType: number = 4, deviceName?: string) {\n return this.request<{\n token: string\n uid: string\n name: string\n avatarUrl: string\n expiresAt: number\n }>('/auth/login', {\n method: 'POST',\n body: JSON.stringify({ appId, externalId, clientType, deviceName }),\n })\n }\n\n refresh() {\n return this.request<{ token: string }>('/auth/refresh', { method: 'POST' })\n }\n\n getCentrifugoToken(name?: string, avatar?: string) {\n const qs = new URLSearchParams()\n if (name) qs.set('name', name)\n if (avatar) qs.set('avatar', avatar)\n return this.request<{ token: string }>(`/auth/centrifugo-token?${qs.toString()}`)\n }\n\n // ===== 会话状态 =====\n // uid 不传时查自己(后端从 JWT sub 解析);传 externalId 时查对方\n getSessionState(uid?: string) {\n const qs = uid ? `?uid=${encodeURIComponent(uid)}` : ''\n return this.request<{ state: SessionState; type?: string; id?: string; roomName?: string }>(\n `/session/state${qs}`,\n )\n }\n\n // 按 externalId 批量反查用户信息(主要拿 UUID 拼 dialog# 通道)\n usersByExternal(externalIds: string[]) {\n return this.request<{\n users: Record<string, { uid: string; name: string; deptName?: string; avatarUrl?: string }>\n }>(`/users/by-external?ids=${externalIds.map(encodeURIComponent).join(',')}`)\n }\n\n // ===== 1:1 通话 =====\n callInit(calleeUid: string, type: 'audio' | 'video') {\n return this.request<CallInitResp>('/call/init', {\n method: 'POST',\n body: JSON.stringify({ calleeUid, type }),\n })\n }\n callAccept(callId: string) {\n return this.request<CallAcceptResp>('/call/accept', {\n method: 'POST',\n body: JSON.stringify({ callId }),\n })\n }\n callEnd(callId: string, reason: string) {\n return this.request<{ ok: boolean }>('/call/end', {\n method: 'POST',\n body: JSON.stringify({ callId, reason }),\n })\n }\n\n // ===== 会议 =====\n meetingCreate(req: CreateMeetingReq) {\n return this.request<CreateMeetingResp>('/meeting/create', {\n method: 'POST',\n body: JSON.stringify(req),\n })\n }\n meetingJoin(meetingId: string) {\n return this.request<JoinMeetingResp>('/meeting/join', {\n method: 'POST',\n body: JSON.stringify({ meetingId }),\n })\n }\n // accept 时返回 {roomName, token, title?, hostUid?}——SDK 拿 token 直接连 LiveKit,不再调 join\n // reject 时返回 {ok: true}\n meetingRespond(meetingId: string, action: 'accept' | 'reject') {\n return this.request<{\n ok?: boolean\n roomName?: string\n token?: string\n title?: string\n hostUid?: string\n }>(`/meeting/${meetingId}/respond`, {\n method: 'POST',\n body: JSON.stringify({ action }),\n })\n }\n meetingKick(meetingId: string, uid: string) {\n return this.request<{ ok: boolean }>(`/meeting/${meetingId}/kick`, {\n method: 'POST',\n body: JSON.stringify({ uid }),\n })\n }\n meetingReinvite(meetingId: string, uid: string) {\n return this.request<{ ok: boolean }>(`/meeting/${meetingId}/reinvite`, {\n method: 'POST',\n body: JSON.stringify({ uid }),\n })\n }\n meetingLeave(meetingId: string) {\n return this.request<{ ok: boolean }>(`/meeting/${meetingId}/leave`, { method: 'POST' })\n }\n // 主持人解散会议:所有参与者强制离会 + 会议状态置 ended\n meetingDissolve(meetingId: string) {\n return this.request<DissolveResp>(`/meeting/${meetingId}/dissolve`, { method: 'POST' })\n }\n // 查 LiveKit 房间内当前参与者数量——host 离开前判断走 Leave 还是 Dissolve\n meetingParticipantCount(meetingId: string) {\n return this.request<ParticipantCountResp>(`/meeting/${meetingId}/participant-count`)\n }\n // 拉取当前 uid 的待处理会议邀请——SDK 在 connect 成功后调一次\n meetingPendingInvites() {\n return this.request<ListPendingInvitesResp>(`/meetings/pending-invites`)\n }\n // 列出会议全量成员(含已邀请未加入者)+ 当前主持人 uid\n meetingMembers(meetingId: string) {\n return this.request<ListMeetingMembersResp>(`/meeting/${meetingId}/members`)\n }\n // 主持人转让:仅当前主持人可调,后端会广播给所有 invitee\n meetingTransferHost(meetingId: string, newHostUid: string) {\n return this.request<TransferHostResp>(`/meeting/${meetingId}/transfer-host`, {\n method: 'POST',\n body: JSON.stringify({ newHostUid } satisfies TransferHostReq),\n })\n }\n\n // ===== 消息 =====\n sendMessage(req: SendMessageReq) {\n return this.request<SendMessageResp>('/messages', {\n method: 'POST',\n body: JSON.stringify(req),\n })\n }\n ackMessage(messageId: string) {\n return this.request<{ ok: boolean }>(`/messages/${messageId}/ack`, { method: 'POST' })\n }\n revokeMessage(messageId: string) {\n return this.request<{ ok: boolean }>(`/messages/${messageId}/revoke`, { method: 'POST' })\n }\n listMessages(conversationId: string, sinceSeq = 0, limit = 50) {\n return this.request<{ messages: any[] }>(\n `/conversations/${conversationId}/messages?sinceSeq=${sinceSeq}&limit=${limit}`,\n )\n }\n markRead(conversationId: string, lastReadSeq: number) {\n return this.request<{ ok: boolean }>(`/conversations/${conversationId}/read`, {\n method: 'POST',\n body: JSON.stringify({ lastReadSeq }),\n })\n }\n listConversations() {\n return this.request<{ conversations: any[] }>('/conversations')\n }\n\n // ===== 媒体 =====\n getUploadUrl(req: UploadUrlReq) {\n return this.request<UploadUrlResp>('/media/upload-url', {\n method: 'POST',\n body: JSON.stringify(req),\n })\n }\n checkMd5(md5: string) {\n return this.request<CheckMd5Resp>('/media/check-md5', {\n method: 'POST',\n body: JSON.stringify({ md5 }),\n })\n }\n}\n","import { Transport } from './transport'\n\n// AuthManager:管理会话 JWT 的获取与刷新\n// SDK 内部自动刷新,业务方只感知 connected/disconnected\nexport class AuthManager {\n private transport: Transport\n private sessionToken: string | null = null\n private centrifugoToken: string | null = null\n private expiresAt = 0\n private refreshTimer: ReturnType<typeof setTimeout> | null = null\n\n // 自动刷新:过期前 1 分钟换新 token\n private readonly refreshAheadMs = 60 * 1000\n\n constructor(transport: Transport) {\n this.transport = transport\n }\n\n getToken(): string | null {\n return this.sessionToken\n }\n\n isExpired(): boolean {\n return Date.now() >= this.expiresAt - this.refreshAheadMs\n }\n\n // 登录\n async login(appId: string, externalId: string, clientType: number = 4, deviceName?: string) {\n const resp = await this.transport.login(appId, externalId, clientType, deviceName)\n this.sessionToken = resp.token\n this.expiresAt = resp.expiresAt * 1000\n this.scheduleRefresh()\n return resp\n }\n\n // 外部注入会话 JWT(connectWithExternalJwt 用)。刷新走标准 /auth/refresh。\n setExternalToken(token: string, expiresAt: number) {\n this.sessionToken = token\n this.expiresAt = expiresAt * 1000\n this.scheduleRefresh()\n }\n\n // 获取 Centrifugo 连接 JWT(5 分钟有效,SDK 自动换新)\n async getCentrifugoToken(name?: string, avatar?: string): Promise<string> {\n if (this.centrifugoToken) {\n return this.centrifugoToken\n }\n const resp = await this.transport.getCentrifugoToken(name, avatar)\n this.centrifugoToken = resp.token\n // Centrifugo JWT 5 分钟过期,4 分钟时清空让 centrifuge-js 自动调 getToken 换新\n setTimeout(\n () => {\n this.centrifugoToken = null\n },\n 4 * 60 * 1000,\n )\n return resp.token\n }\n\n // 主动刷新\n // 失败重试 3 次(间隔 5s);仍失败则清 token(业务方需重新登录)\n async refresh() {\n for (let i = 0; i < 3; i++) {\n try {\n const resp = await this.transport.refresh()\n this.sessionToken = resp.token\n this.expiresAt = Date.now() + 24 * 60 * 60 * 1000\n this.scheduleRefresh()\n return\n } catch (err: any) {\n const msg = String(err?.message || err)\n // 401 = 被踢或彻底过期,不再重试,清 token 让业务方重新登录\n if (msg.includes('401') || msg.includes('kicked') || msg.includes('expired')) {\n console.error('[im] refresh 失败(session 失效),需重新登录', err)\n this.logout()\n return\n }\n console.warn(`[im] refresh token 失败(第 ${i + 1} 次),5s 后重试`, err)\n await new Promise((r) => setTimeout(r, 5000))\n }\n }\n console.error('[im] refresh token 重试 3 次仍失败,清 token')\n this.logout()\n }\n\n // 自动安排下次刷新\n private scheduleRefresh() {\n if (this.refreshTimer) {\n clearTimeout(this.refreshTimer)\n }\n const delay = Math.max(this.expiresAt - Date.now() - this.refreshAheadMs, 60 * 1000)\n this.refreshTimer = setTimeout(() => this.refresh(), delay)\n }\n\n logout() {\n if (this.refreshTimer) {\n clearTimeout(this.refreshTimer)\n this.refreshTimer = null\n }\n this.sessionToken = null\n this.centrifugoToken = null\n }\n}\n","import { Centrifuge, type PublicationContext, type Subscription } from 'centrifuge'\n\nimport { EventEmitter } from './events'\nimport type { ConnectionState, ImEventName, ImEventMap } from '@quitting/contract'\n\n// RealtimeConnection:Centrifuge-js 封装\n// 职责:建立 WebSocket 长连接、订阅 personal#<uid> 通道、把 publication 分发到 EventEmitter\n//\n// 关键设计(docs/06 §2.3):\n// - centrifuge-js v5.7.0 与 Centrifugo v6 协议对齐\n// - getToken 回调:过期前自动向后端换新 JWT(5 分钟有效)\n// - personal#<uid> 通道:所有邀请/通知的入口\nexport class RealtimeConnection extends EventEmitter {\n private client: Centrifuge | null = null\n private subscriptions = new Map<string, Subscription>()\n // 引用计数:同一 channel 多次 subscribe 时只首次创建 Subscription,归零才真 unsubscribe\n // 规避 centrifuge-js v5 unsubscribe 后 newSubscription 抛 \"already exists\" 的内部 map bug\n // 也支撑 SDK 高层 API openDialog/closeDialog 的多面板共享订阅语义\n private refCount = new Map<string, number>()\n private wsUrl: string\n private uid = ''\n private tokenGetter: () => Promise<string>\n private currentState: ConnectionState = 'disconnected'\n\n constructor(wsUrl: string, tokenGetter: () => Promise<string>) {\n super()\n this.wsUrl = wsUrl\n this.tokenGetter = tokenGetter\n }\n\n get state(): ConnectionState {\n return this.currentState\n }\n\n async connect(uid: string) {\n this.uid = uid\n this.client = new Centrifuge(this.wsUrl, {\n getToken: async () => {\n return await this.tokenGetter()\n },\n })\n\n this.client.on('connected', () => {\n this.currentState = 'connected'\n this.emit(\n 'connection:state' as ImEventName,\n 'connected' as unknown as ImEventMap['connection:state'],\n )\n // 自动订阅 personal#<uid> 通道\n this.subscribe(`personal#${uid}`)\n })\n\n this.client.on('disconnected', (ctx: any) => {\n this.currentState = 'disconnected'\n this.emit(\n 'connection:state' as ImEventName,\n 'disconnected' as unknown as ImEventMap['connection:state'],\n )\n console.warn('[im] centrifugo disconnected', ctx?.reason)\n })\n\n this.client.on('error', (ctx: any) => {\n console.error('[im] centrifugo error', ctx)\n })\n\n this.client.connect()\n }\n\n // 订阅指定通道(dialog/group/personal)\n // 引用计数语义:多次调用同 channel 只首次创建 Subscription,对应 unsubscribe 多次才真退订\n async subscribe(channel: string): Promise<Subscription | null> {\n if (!this.client) return null\n // 引用计数 +1\n const cur = this.refCount.get(channel) || 0\n this.refCount.set(channel, cur + 1)\n // 已订阅:直接返回 cached Subscription\n if (this.subscriptions.has(channel)) {\n return this.subscriptions.get(channel)!\n }\n const sub = this.client.newSubscription(channel)\n sub.on('publication', (ctx: PublicationContext) => {\n this.handlePublication(channel, ctx)\n })\n sub.subscribe()\n this.subscriptions.set(channel, sub)\n return sub\n }\n\n unsubscribe(channel: string) {\n // 引用计数 -1;refCount 归零时**不调** centrifuge-js 的 unsubscribe\n // centrifuge-js v5 的 unsubscribe 后内部 channel→Subscription map 不会立即清理,\n // 下次 newSubscription 同 channel 会抛 \"Subscription already exists\"\n // 代价:归零后仍订阅(centrifugo 会继续 push publication,但无 SDK listener 监听,进真空)\n // 收益:彻底规避 v5 map bug;demo 4 人场景订阅开销可忽略\n const cur = this.refCount.get(channel) || 0\n if (cur <= 1) {\n this.refCount.delete(channel)\n } else {\n this.refCount.set(channel, cur - 1)\n }\n }\n\n // publish 输入指示器等瞬态信号(dialog 通道 allow_publish_for_subscriber)\n async publish(channel: string, data: unknown): Promise<void> {\n const sub = this.subscriptions.get(channel)\n if (!sub) {\n throw new Error(`未订阅通道: ${channel}`)\n }\n await sub.publish(data as Record<string, unknown>)\n }\n\n // 处理 publication,按 type 字段分发到对应事件\n private handlePublication(channel: string, ctx: PublicationContext) {\n const data = ctx.data as Record<string, unknown>\n const type = data.type as string\n switch (type) {\n case 'call_invite':\n this.emit('call:invite', data as unknown as ImEventMap['call:invite'])\n break\n case 'call_accepted':\n // 被叫接听,主叫感知后开始 connect\n this.emit('call:accepted', data as unknown as ImEventMap['call:accepted'])\n break\n case 'call_rejected':\n // 被叫拒接,主叫 CallOutgoing 显示\"对方拒接\"\n this.emit('call:rejected', data as unknown as ImEventMap['call:rejected'])\n break\n case 'call_cancelled':\n // 主叫取消,被叫 IncomingRing 自动关\n this.emit('call:cancelled', data as unknown as ImEventMap['call:cancelled'])\n break\n case 'call_busy':\n this.emit('call:busy', data as unknown as ImEventMap['call:busy'])\n break\n case 'call_no_answer':\n this.emit('call:no_answer', data as unknown as ImEventMap['call:no_answer'])\n break\n case 'call_ended':\n // 通话中挂断,对端 CallWindow 自动关\n this.emit('call:ended', data as unknown as ImEventMap['call:ended'])\n break\n case 'meeting_invite':\n this.emit('meeting:invite', data as unknown as ImEventMap['meeting:invite'])\n break\n case 'meeting_kicked':\n this.emit('meeting:kicked', data as unknown as ImEventMap['meeting:kicked'])\n break\n case 'meeting_host_changed':\n // 主持人转让:所有 invitee 收到,更新 currentSession.hostIdentity + emit session 内部事件\n // SDK 高层 forward 到外层 EventEmitter 供 UI 监听\n this.emit('meeting:hostChanged', data as unknown as ImEventMap['meeting:hostChanged'])\n break\n case 'meeting_dissolved':\n // 会议解散:所有 invitee 收到,触发 currentSession.end\n this.emit('meeting:dissolved', data as unknown as ImEventMap['meeting:dissolved'])\n break\n case 'message':\n this.emit('message', data as unknown as ImEventMap['message'])\n break\n case 'message_preview':\n // 两层推送的轻量层(docs/03 §1.5.4):personal# 通道推送,会话列表未读数 + 预览刷新\n this.emit('message_preview', data as unknown as ImEventMap['message_preview'])\n break\n case 'message_revoked':\n this.emit('message:revoked', data as unknown as ImEventMap['message:revoked'])\n break\n case 'read_receipt':\n this.emit('read_receipt', data as unknown as ImEventMap['read_receipt'])\n break\n case 'presence:update':\n // presence 聚合推送:后端每 10s 扫描 channels 算 diff 后广播\n // changes 是 [{uid(externalId), online}] 数组,逐条 emit(业务方按 uid 更新在线状态)\n {\n const changes = (data.changes as Array<{ uid: string; online: boolean }>) || []\n for (const ch of changes) {\n this.emit('presence:update', {\n uid: ch.uid,\n online: ch.online,\n } as unknown as ImEventMap['presence:update'])\n }\n }\n break\n case 'session:kicked':\n this.emit('session:kicked', data as unknown as ImEventMap['session:kicked'])\n break\n case 'group:memberJoined':\n this.emit('group:memberJoined', data as unknown as ImEventMap['group:memberJoined'])\n break\n case 'group:kicked':\n this.emit('group:kicked', data as unknown as ImEventMap['group:kicked'])\n break\n case 'group:memberLeft':\n this.emit('group:memberLeft', data as unknown as ImEventMap['group:memberLeft'])\n break\n case 'group:profileChanged':\n this.emit('group:profileChanged', data as unknown as ImEventMap['group:profileChanged'])\n break\n default:\n console.debug('[im] 未知 publication type', type, channel)\n }\n }\n\n disconnect() {\n if (this.client) {\n this.client.disconnect()\n this.client = null\n }\n this.subscriptions.clear()\n this.currentState = 'disconnected'\n }\n}\n","import { Room, Track, setLogLevel, type Participant, type TrackPublication } from 'livekit-client'\n\nimport { EventEmitter } from './events'\nimport type { Transport } from './transport'\nimport type { CallType, CallEndReason, ClientConfig } from '@quitting/contract'\n\n// ICE 服务器条目类型(与 RTCIceServer 对齐,但简化为 SDK 实际使用的字段)\ntype IceServer = ClientConfig['iceServers'][number]\n\n// CallSession:单次通话会话,封装 livekit-client Room\n// 事件:\n// - connected:本地入房成功\n// - remoteConnected:远端参与者入房\n// - remoteDisconnected:远端离开\n// - ended:通话结束(含 reason)\nexport class CallSession extends EventEmitter {\n public callId: string\n public callType: CallType\n public roomName: string\n public identity: string\n public peerIdentity?: string\n\n private room: Room\n private livekitWs: string\n private iceServers: IceServer[]\n private ended = false\n\n // 自定义事件类型(与 ImEventMap 解耦,CallSession 私有)\n private sessionListeners = new Map<string, Set<Function>>()\n\n constructor(opts: {\n callId: string\n callType: CallType\n roomName: string\n identity: string\n token: string\n livekitWs: string\n iceServers: IceServer[]\n }) {\n super()\n this.callId = opts.callId\n this.callType = opts.callType\n this.roomName = opts.roomName\n this.identity = opts.identity\n this.token = opts.token\n this.livekitWs = opts.livekitWs\n this.iceServers = opts.iceServers\n // Room 构造只放 RoomOptions 字段(adaptiveStream / dynacast 等)。\n // rtcConfig 属于 RoomConnectOptions,要传给 room.connect(),不能放构造参数——\n // livekit-client@2.20 起 TS 类型严格校验,构造里写 rtcConfig 会报 TS2353。\n this.room = new Room({\n adaptiveStream: true,\n dynacast: true,\n })\n }\n\n // 入房(不开设备):仅建立 LiveKit 信令连接 + 注册参与者事件\n // 与 enableDevices 分离,让主叫方在\"呼叫等待\"阶段可以提前入房但不打开摄像头\n async connect() {\n this.room\n .on('participantConnected', (p: Participant) => {\n this.peerIdentity = p.identity\n this.emitSession('remoteConnected', { identity: p.identity })\n })\n .on('participantDisconnected', (p: Participant) => {\n this.emitSession('remoteDisconnected', { identity: p.identity })\n this.end('user_left')\n })\n .on('disconnected', () => {\n if (!this.ended) {\n this.end('user_left')\n }\n })\n // 关键:对端 track 异步流入触发——attachRemoteVideo 必须等此事件才能成功 attach\n // participantConnected 只表示对端入房,track 还在路上\n .on('trackSubscribed', (track: Track, _pub: TrackPublication, p: Participant) => {\n // audio track 显式 attach 确保播放(与会议一致;LiveKit 默认自动播放不可靠)\n if (track.kind === Track.Kind.Audio) {\n track.attach()\n }\n this.emitSession('remoteTrackSubscribed', {\n kind: track.kind,\n identity: p.identity,\n })\n })\n\n // rtcConfig.iceServers:Firefox 默认 mDNS 混淆 host 候选(.local),\n // LiveKit 容器内 DNS 解析不到,导致双向 connectivity check 失败 → ICE failed。\n // 配 STUN 让客户端生成 srflx 候选(真实 IP)兜底,Chrome/Edge 不受影响。\n //\n // iceServers 由 ImClient 从 /client/config 接口拉取后透传,避免硬编码\n // TURN 不在此声明——LiveKit 服务端 turn.enabled=true 时,会在 signaling 阶段\n // 自动下发含凭证的 TURN 服务器(与 LiveKit token 集成认证),SDK 自动合并使用。\n await this.room.connect(this.livekitWs, this.token, {\n rtcConfig: {\n iceServers: this.iceServers,\n },\n })\n this.emitSession('connected', {})\n }\n\n // 开启本地 mic + camera(视频通话时)\n // 关键:每个设备独立 try/catch——摄像头被占用(NotReadableError)或权限拒绝(NotAllowedError)\n // 时只 emit 'devices' 事件携带错误清单,不阻塞通话——音频 + 单向视频仍可用\n async enableDevices(): Promise<{ mic: boolean; camera: boolean; errors: string[] }> {\n const errors: string[] = []\n let micOk = false\n let cameraOk = false\n\n try {\n await this.room.localParticipant.setMicrophoneEnabled(true)\n micOk = true\n } catch (err: any) {\n const msg = err?.message || String(err)\n errors.push(`mic: ${msg}`)\n console.warn('[im] 麦克风开启失败,通话继续(音频不可用)', err)\n }\n\n if (this.callType === 'video') {\n try {\n await this.room.localParticipant.setCameraEnabled(true)\n cameraOk = true\n } catch (err: any) {\n const msg = err?.message || String(err)\n errors.push(`camera: ${msg}`)\n console.warn('[im] 摄像头开启失败,通话继续(视频不可用)', err)\n }\n }\n\n this.emitSession('devices', { mic: micOk, camera: cameraOk, errors })\n return { mic: micOk, camera: cameraOk, errors }\n }\n\n // 把本地视频 track 挂载到 DOM 元素\n attachLocalVideo(el: HTMLVideoElement) {\n const pub = this.room.localParticipant.getTrackPublication(Track.Source.Camera)\n if (pub && pub.track) {\n pub.track.attach(el)\n }\n }\n\n // 把远端视频 track 挂载到 DOM 元素\n // 如果对端已 publish 但本端未订阅(默认自动订阅,但网络抖动可能延迟),触发订阅;\n // 真正订阅完成后通过 'trackSubscribed' 事件触发再次 attach\n attachRemoteVideo(el: HTMLVideoElement) {\n for (const [, p] of this.room.remoteParticipants) {\n const pub = p.getTrackPublication(Track.Source.Camera)\n if (!pub) continue\n if (pub.track) {\n // track 已流入:直接 attach\n pub.track.attach(el)\n return\n }\n // track 未流入:触发订阅,等 trackSubscribed 事件再次 attach\n if (!pub.isSubscribed) {\n pub.setSubscribed(true)\n }\n }\n }\n\n // 从指定元素上 detach 所有 video track(local + remote)\n // 切换大小视频时必须先调此方法,否则 LiveKit 的 track 会同时挂在多个元素上导致画面重复\n detachAllVideoFromEl(el: HTMLVideoElement) {\n if (!el) return\n // local\n const localPub = this.room.localParticipant.getTrackPublication(Track.Source.Camera)\n if (localPub && localPub.track) {\n localPub.track.detach(el)\n }\n // remote\n for (const [, p] of this.room.remoteParticipants) {\n const pub = p.getTrackPublication(Track.Source.Camera)\n if (pub && pub.track) {\n pub.track.detach(el)\n }\n }\n }\n\n // 静音/取消静音本地 mic\n async setMicEnabled(enabled: boolean) {\n await this.room.localParticipant.setMicrophoneEnabled(enabled)\n }\n\n // 关/开本地摄像头\n async setCameraEnabled(enabled: boolean) {\n await this.room.localParticipant.setCameraEnabled(enabled)\n }\n\n // 主动挂断(调后端 /call/end + 断 LiveKit)\n async hangup(transport: Transport, reason: CallEndReason = 'user_left') {\n if (this.ended) return\n this.ended = true\n try {\n await transport.callEnd(this.callId, reason)\n } catch (err) {\n console.error('[im] callEnd 失败', err)\n }\n // 静音断开:抑制 DataChannel/ICE 正常关闭时的 error/warn 噪音(meeting.ts 顶层已设 warn)\n setLogLevel('silent')\n try {\n await this.room.disconnect()\n } finally {\n setLogLevel('warn')\n }\n this.emitSession('ended', { reason })\n }\n\n // 远端挂断或网络断开触发的结束(不调后端 /call/end,由 webhook 兜底)\n async end(reason: CallEndReason) {\n if (this.ended) return\n this.ended = true\n setLogLevel('silent')\n try {\n await this.room.disconnect()\n } finally {\n setLogLevel('warn')\n }\n this.emitSession('ended', { reason })\n }\n\n // 注册 LiveKit RPC 方法(用于房内实时控制:hangup/ring/mute/switch-audio)\n // v2.20 registerRpcMethod 回调签名是 RpcInvocationData(含 payload/requestId/callerIdentity),\n // 这里只透传 payload 字符串,对调用方屏蔽 SDK 内部类型\n registerRpcMethod(method: string, handler: (payload: string) => Promise<string>) {\n this.room.localParticipant.registerRpcMethod(method, (data: { payload: string }) =>\n handler(data.payload),\n )\n }\n\n // 调远端 RPC(如 hangup 通知对方)\n async performRpc(destinationIdentity: string, method: string, payload: string): Promise<string> {\n return await this.room.localParticipant.performRpc({\n destinationIdentity,\n method,\n payload,\n })\n }\n\n // 私有事件分发\n private emitSession(event: string, payload: unknown) {\n const set = this.sessionListeners.get(event)\n if (!set) return\n for (const fn of set) {\n try {\n fn(payload)\n } catch (err) {\n console.error(`[im] session handler error: ${event}`, err)\n }\n }\n }\n\n onSession(\n event:\n | 'connected'\n | 'remoteConnected'\n | 'remoteDisconnected'\n | 'remoteTrackSubscribed'\n | 'ended'\n | 'devices',\n handler: (payload: any) => void,\n ) {\n if (!this.sessionListeners.has(event)) {\n this.sessionListeners.set(event, new Set())\n }\n this.sessionListeners.get(event)!.add(handler)\n }\n\n // 当前 LiveKit room 是否已连接(用于 CallWindow 类组件 onMounted 时判断是否错过了 connected 事件)\n get isConnected(): boolean {\n return this.room.state === 'connected'\n }\n\n getToken(): string {\n return this.token\n }\n private token!: string\n}\n\n// CallController:1:1 通话编排\nexport class CallController {\n private transport: Transport\n private livekitWs: string\n private identity: string\n private iceServers: IceServer[]\n currentSession: CallSession | null = null\n\n constructor(transport: Transport, livekitWs: string, identity: string, iceServers: IceServer[]) {\n this.transport = transport\n this.livekitWs = livekitWs\n this.identity = identity\n this.iceServers = iceServers\n }\n\n // 主叫发起:调后端 /call/init + 创建 session(不 connect)\n // 主叫方此时显示\"呼叫等待\"弹窗(CallOutgoing),收到 call:accepted 事件后再 connect + enableDevices\n // 参数 to 为被叫 externalId(业务方用户 ID),透传给后端\n async init(to: string, type: CallType): Promise<CallSession> {\n const resp = await this.transport.callInit(to, type)\n const session = new CallSession({\n callId: resp.callId,\n callType: type,\n roomName: resp.roomName,\n identity: this.identity,\n token: resp.token,\n livekitWs: this.livekitWs,\n iceServers: this.iceServers,\n })\n this.currentSession = session\n return session\n }\n\n // 被叫接听:调后端 /call/accept + 入房 + 开设备\n // 被叫在收到 call:invite 后用户点接听时调用,立即入房(不需要等主叫感知)\n // 失败处理:connect 异常时清 currentSession + 调后端 end(user_left) 兜底 + 重新抛错给调用方\n async accept(callId: string, callType: CallType): Promise<CallSession> {\n const resp = await this.transport.callAccept(callId)\n const session = new CallSession({\n callId,\n callType,\n roomName: resp.roomName,\n identity: this.identity,\n token: resp.token,\n livekitWs: this.livekitWs,\n iceServers: this.iceServers,\n })\n this.currentSession = session\n try {\n await session.connect()\n } catch (err) {\n // connect 失败:清本地状态 + 通知后端,避免僵尸会话\n this.currentSession = null\n this.transport.callEnd(callId, 'user_left').catch(() => {})\n throw err\n }\n // 设备开启异步进行——失败不阻塞 accept 返回,UI 通过 onSession('devices') 收到错误清单\n session.enableDevices().catch((err) => {\n console.warn('[im] enableDevices 异常', err)\n })\n return session\n }\n\n // 结束通话\n // reason: 挂断原因;callId: 可选,用于 currentSession 不存在时的拒接/取消场景\n // 关键:被叫拒接(reject)和主叫取消(cancel)时 currentSession 还是 null,\n // 但仍需调后端 /call/end 通知对方——所以 callId 必须显式传\n async end(reason: CallEndReason = 'user_left', callId?: string): Promise<void> {\n if (this.currentSession) {\n await this.currentSession.hangup(this.transport, reason)\n this.currentSession = null\n } else if (callId) {\n // currentSession 不存在(被叫拒接 / 主叫响铃中取消):直接调后端\n try {\n await this.transport.callEnd(callId, reason)\n } catch (err) {\n console.warn('[im] callEnd 失败(不阻塞)', err)\n }\n }\n }\n}\n","import {\n Room,\n Track,\n setLogLevel,\n type Participant,\n type TrackPublication,\n type RemoteTrackPublication,\n} from 'livekit-client'\n\nimport { EventEmitter } from './events'\nimport type { Transport } from './transport'\nimport type {\n CreateMeetingResp,\n MeetingMember,\n ListMeetingMembersResp,\n PendingInvite,\n ClientConfig,\n} from '@quitting/contract'\n\n// LiveKit 日志级别:默认 warn,抑制 trace/debug/info 噪音(ICE 候选、信令重连等)\n// DataChannel 在 room 断开时打印的 \"User-Initiated Abort\" 是 LiveKit 正常行为,\n// 断开前临时设 silent 抑制(见 disconnectSilently),断开后恢复\nsetLogLevel('warn')\n\n// 临时静音 LiveKit 日志并断开 room——抑制 DataChannel/ICE 正常断开时的大量 error/warn 噪音\nasync function disconnectSilently(room: Room): Promise<void> {\n setLogLevel('silent')\n try {\n await room.disconnect()\n } finally {\n setLogLevel('warn')\n }\n}\n\n// ICE 服务器条目类型(与 RTCIceServer 对齐,但简化为 SDK 实际使用的字段)\ntype IceServer = ClientConfig['iceServers'][number]\n\n// MeetingSession:单次会议会话,封装 livekit-client Room(多人)\n// 与 CallSession 同样的范式:connect 只入房,enableDevices 单独管理,设备失败不阻塞\nexport class MeetingSession extends EventEmitter {\n public meetingId: string\n public title: string\n public roomName: string\n public identity: string\n public isHost: boolean\n // 当前主持人 uid——主视频画面据此切换;host_changed 事件触发时由 SDK 更新\n public hostIdentity: string\n\n private room: Room\n private livekitWs: string\n private token: string\n private iceServers: IceServer[]\n private ended = false\n private sessionListeners = new Map<string, Set<Function>>()\n // 成员列表缓存:8 秒内复用,避免 UI 高频刷新打爆后端\n // MeetWindow 定时器每 10 秒拉一次,8 秒 TTL 保证轮询时缓存已过期、真正发请求;\n // 事件触发的 force refresh 不受此 TTL 限制\n private membersCache: MeetingMember[] = []\n private membersCacheAt = 0\n private static MEMBERS_CACHE_TTL = 8000\n // 待 attach 的元素缓存:identity → video 元素\n // 解决竞态:UI 元素就绪时远端 track 还没到,attachParticipantVideo 触发订阅后 return;\n // track 到达时从 pendingAttach 取出元素直接 attach,不依赖 UI 重试\n private pendingAttach = new Map<string, HTMLVideoElement>()\n\n constructor(opts: {\n meetingId: string\n title: string\n roomName: string\n identity: string\n token: string\n isHost: boolean\n hostIdentity: string\n livekitWs: string\n iceServers: IceServer[]\n }) {\n super()\n this.meetingId = opts.meetingId\n this.title = opts.title\n this.roomName = opts.roomName\n this.identity = opts.identity\n this.token = opts.token\n this.isHost = opts.isHost\n this.hostIdentity = opts.hostIdentity\n this.livekitWs = opts.livekitWs\n this.iceServers = opts.iceServers\n // Room 构造只放 RoomOptions 字段(adaptiveStream / dynacast 等)。\n // rtcConfig 属于 RoomConnectOptions,要传给 room.connect(),不能放构造参数——\n // livekit-client@2.20 起 TS 类型严格校验,构造里写 rtcConfig 会报 TS2353。\n this.room = new Room({\n adaptiveStream: true,\n dynacast: true,\n })\n }\n\n // 当前 room 是否已连接(UI 判断 connect 是否在 mount 之前完成)\n get isConnected(): boolean {\n return this.room.state === 'connected'\n }\n\n // 入房(不开设备):仅建立 LiveKit 信令 + 注册事件\n async connect() {\n this.room\n .on('participantConnected', (p: Participant) => {\n // 远端入会:若 UI 早已把元素存入 pendingAttach,触发 camera 订阅,\n // 真正 attach 由 trackSubscribed 处理器完成\n if (this.pendingAttach.has(p.identity)) {\n const pub = p.getTrackPublication(Track.Source.Camera) as\n | RemoteTrackPublication\n | undefined\n if (pub && !pub.isSubscribed) pub.setSubscribed(true)\n }\n this.emitSession('participantJoined', { identity: p.identity })\n })\n .on('participantDisconnected', (p: Participant) => {\n this.emitSession('participantLeft', { identity: p.identity, reason: 'leave' })\n })\n .on('disconnected', () => {\n if (!this.ended) this.end()\n })\n // 关键:对端 track 异步流入——participantConnected 时 track 还没到,必须等此事件才能 attach\n .on('trackSubscribed', (track: Track, _pub: TrackPublication, p: Participant) => {\n if (track.kind === 'video') {\n // video track:检查 pendingAttach 缓存,元素早就绪但 track 没到的场景在此补 attach\n const el = this.pendingAttach.get(p.identity)\n if (el) {\n track.attach(el)\n this.pendingAttach.delete(p.identity)\n }\n } else if (track.kind === Track.Kind.Audio) {\n // audio track:显式 attach 到隐藏元素,确保播放(LiveKit 默认自动播放,\n // 但多 Room 场景或浏览器自动播放策略可能阻止,显式 attach 最可靠)\n track.attach()\n }\n this.emitSession('participantTrackSubscribed', {\n kind: track.kind,\n identity: p.identity,\n })\n })\n // 发言检测:LiveKit 给当前正在发言的全量参与者列表(含本地,如本端在说话)\n // 后端 ListMembers 的 speaking 永远 false,UI 端靠此事件维护本地态\n .on('activeSpeakersChanged', (speakers: Participant[]) => {\n const identities = speakers.map((p) => p.identity)\n this.emitSession('activeSpeakersChanged', { identities })\n })\n\n // rtcConfig.iceServers:Firefox 默认 mDNS 混淆 host 候选(.local),\n // LiveKit 容器内 DNS 解析不到,导致双向 connectivity check 失败 → ICE failed。\n // 配 STUN 让客户端生成 srflx 候选(真实 IP)兜底,Chrome/Edge 不受影响。\n //\n // iceServers 由 ImClient 从 /client/config 接口拉取后透传,避免硬编码\n // TURN 不在此声明——LiveKit 服务端 turn.enabled=true 时,会在 signaling 阶段\n // 自动下发含凭证的 TURN 服务器(与 LiveKit token 集成认证),SDK 自动合并使用。\n await this.room.connect(this.livekitWs, this.token, {\n rtcConfig: {\n iceServers: this.iceServers,\n },\n })\n // connect 后用 LiveKit 的真实 identity 覆盖构造传入值\n // (token 里用的是 externalId,room.localParticipant.identity 即 externalId)\n // 这样后续匹配 participant identity、hostIdentity 全部统一在 externalId 体系\n const realIdentity = this.room.localParticipant.identity\n if (realIdentity) {\n this.identity = realIdentity\n if (!this.hostIdentity) this.hostIdentity = realIdentity\n }\n this.emitSession('connected', {})\n }\n\n // 开启本地 mic + camera,每个设备独立 try/catch——失败 emit 'devices' 携带错误清单,不阻塞会议\n async enableDevices(): Promise<{ mic: boolean; camera: boolean; errors: string[] }> {\n const errors: string[] = []\n let micOk = false\n let cameraOk = false\n\n try {\n await this.room.localParticipant.setMicrophoneEnabled(true)\n micOk = true\n } catch (err: any) {\n errors.push(`mic: ${err?.message || String(err)}`)\n console.warn('[im] 麦克风开启失败,会议继续(音频不可用)', err)\n }\n try {\n await this.room.localParticipant.setCameraEnabled(true)\n cameraOk = true\n } catch (err: any) {\n errors.push(`camera: ${err?.message || String(err)}`)\n console.warn('[im] 摄像头开启失败,会议继续(视频不可用)', err)\n }\n\n this.emitSession('devices', { mic: micOk, camera: cameraOk, errors })\n return { mic: micOk, camera: cameraOk, errors }\n }\n\n // 主持人踢人(调后端 API,后端调 LiveKit RemoveParticipant)\n // 参数 uid 为被踢者 externalId(业务方用户 ID),透传给后端\n async kick(transport: Transport, uid: string) {\n await transport.meetingKick(this.meetingId, uid)\n }\n\n // 离开会议\n async leave(transport: Transport) {\n if (this.ended) return\n this.ended = true\n try {\n await transport.meetingLeave(this.meetingId)\n } catch (err) {\n console.error('[im] meetingLeave 失败', err)\n }\n await disconnectSilently(this.room)\n this.emitSession('ended', {})\n }\n\n // 房间被关闭/远端断开触发的结束(不调后端,靠 webhook 兜底)\n async end() {\n if (this.ended) return\n this.ended = true\n await disconnectSilently(this.room)\n this.emitSession('ended', {})\n }\n\n // 列出当前在线参与者(实时态)\n getRemoteParticipants(): RemoteParticipantInfo[] {\n const list: RemoteParticipantInfo[] = []\n for (const [, p] of this.room.remoteParticipants) {\n list.push({\n identity: p.identity,\n audioEnabled: p.isMicrophoneEnabled,\n videoEnabled: p.isCameraEnabled,\n isSpeaking: p.isSpeaking,\n })\n }\n return list\n }\n\n // 把本地视频 track 挂载到 DOM 元素\n // 如果 track 还没 publish(enableDevices 是异步的),注册一次性 LocalParticipant.on('localTrackPublished')\n // 监听,等 Camera track publish 后再 attach——避免 host 挂载主视频位时因竞态显示黑屏\n attachLocalVideo(el: HTMLVideoElement) {\n const tryAttach = () => {\n const pub = this.room.localParticipant.getTrackPublication(Track.Source.Camera)\n if (pub && pub.track) {\n pub.track.attach(el)\n return true\n }\n return false\n }\n if (tryAttach()) return\n // track 未就绪:等 localTrackPublished 事件\n const handler = (pub: TrackPublication) => {\n if (pub.source === Track.Source.Camera && pub.track) {\n pub.track.attach(el)\n this.room.localParticipant.off('localTrackPublished', handler)\n }\n }\n this.room.localParticipant.on('localTrackPublished', handler)\n }\n\n // 把指定参与者的视频 track 挂载到 DOM 元素\n // 解竞态:远端未入会 / 没有 publication / track 未流入 时,缓存元素到 pendingAttach;\n // track 到达时(trackSubscribed 或 participantConnected 后)补 attach,不依赖 UI 重试\n attachParticipantVideo(identity: string, el: HTMLVideoElement) {\n const p = this.room.remoteParticipants.get(identity)\n if (!p) {\n // 远端还没入会:缓存元素,等 participantConnected + trackSubscribed\n this.pendingAttach.set(identity, el)\n return\n }\n const pub = p.getTrackPublication(Track.Source.Camera) as RemoteTrackPublication | undefined\n if (!pub) {\n // 已入会但还没 publish camera:缓存元素,等 trackSubscribed\n this.pendingAttach.set(identity, el)\n return\n }\n if (pub.track) {\n pub.track.attach(el)\n this.pendingAttach.delete(identity)\n return\n }\n // publication 存在但 track 未流入(订阅中):缓存元素 + 确保订阅\n this.pendingAttach.set(identity, el)\n if (!pub.isSubscribed) {\n pub.setSubscribed(true)\n }\n }\n\n async setMicEnabled(enabled: boolean) {\n await this.room.localParticipant.setMicrophoneEnabled(enabled)\n }\n async setCameraEnabled(enabled: boolean) {\n await this.room.localParticipant.setCameraEnabled(enabled)\n }\n // 扬声器静音:对当前所有远端 audio track 设 volume=0;新入会成员需重新调用\n // LiveKit 没有\"主静音\"API,这是最贴近的实现;UI 端记本地态即可\n async setSpeakerMuted(muted: boolean) {\n for (const [, p] of this.room.remoteParticipants) {\n for (const [, pub] of p.audioTrackPublications) {\n const track = pub.track\n // 只对 RemoteAudioTrack 调 setVolume;其它 track 类型跳过\n if (track && track.kind === Track.Kind.Audio) {\n ;(track as any).setVolume(muted ? 0 : 1)\n }\n }\n }\n }\n\n // 拉取会议全量成员(含已邀请未加入者)+ 更新当前 hostIdentity\n // 2 秒缓存避免 UI 高频刷新打爆后端;强制刷新时 force=true 跳过缓存\n async refreshMembers(transport: Transport, force = false): Promise<MeetingMember[]> {\n const now = Date.now()\n if (\n !force &&\n now - this.membersCacheAt < MeetingSession.MEMBERS_CACHE_TTL &&\n this.membersCache.length > 0\n ) {\n return this.membersCache\n }\n const resp: ListMeetingMembersResp = await transport.meetingMembers(this.meetingId)\n this.membersCache = resp.members || []\n this.membersCacheAt = now\n if (resp.hostUid && resp.hostUid !== this.hostIdentity) {\n this.hostIdentity = resp.hostUid\n }\n return this.membersCache\n }\n\n // 同步返回缓存中的成员列表(无网络请求);UI 高频刷新用这个\n // 缓存过期时返回空数组,UI 不应该 crash——上层应同时调 refreshMembers 拉新数据\n getMembers(): MeetingMember[] {\n return this.membersCache\n }\n\n // 主持人转让:仅当前 isHost=true 可调;后端广播后所有客户端通过 hostChanged 事件感知\n // 参数 newHostUid 为新主持人 externalId(业务方用户 ID),透传给后端\n async transferHost(transport: Transport, newHostUid: string): Promise<void> {\n await transport.meetingTransferHost(this.meetingId, newHostUid)\n // 乐观本地更新——broadcast 可能延迟 1-2 秒\n this.hostIdentity = newHostUid\n this.emitSession('hostChanged', { hostUid: newHostUid })\n }\n\n onSession(\n event:\n | 'connected'\n | 'ended'\n | 'participantJoined'\n | 'participantLeft'\n | 'participantTrackSubscribed'\n | 'activeSpeakersChanged'\n | 'devices'\n | 'hostChanged'\n | 'membersChanged',\n handler: (payload: any) => void,\n ) {\n if (!this.sessionListeners.has(event)) {\n this.sessionListeners.set(event, new Set())\n }\n this.sessionListeners.get(event)!.add(handler)\n }\n\n private emitSession(event: string, payload: unknown) {\n const set = this.sessionListeners.get(event)\n if (!set) return\n for (const fn of set) {\n try {\n fn(payload)\n } catch (err) {\n console.error(`[im] session handler error: ${event}`, err)\n }\n }\n }\n}\n\nexport interface RemoteParticipantInfo {\n identity: string\n audioEnabled: boolean\n videoEnabled: boolean\n isSpeaking: boolean\n}\n\n// MeetingController:会议编排\nexport class MeetingController {\n private transport: Transport\n private livekitWs: string\n private identity: string\n private iceServers: IceServer[]\n currentSession: MeetingSession | null = null\n\n constructor(transport: Transport, livekitWs: string, identity: string, iceServers: IceServer[]) {\n this.transport = transport\n this.livekitWs = livekitWs\n this.identity = identity\n this.iceServers = iceServers\n }\n\n // 主持人创建会议\n // inviteeUids 为被邀请者 externalId 数组(业务方用户 ID),透传给后端\n // connect 失败时清 currentSession + 调后端 leave 兜底,避免僵尸会议\n async create(\n inviteeUids: string[],\n title: string,\n ): Promise<{ meetingId: string; session: MeetingSession; busyInvitees?: string[] }> {\n const resp: CreateMeetingResp = await this.transport.meetingCreate({ inviteeUids, title })\n const session = new MeetingSession({\n meetingId: resp.meetingId,\n title,\n roomName: resp.roomName,\n identity: this.identity, // 占位,connect 后会被 room.localParticipant.identity(externalId)覆盖\n token: resp.token,\n isHost: true,\n hostIdentity: resp.hostUid || this.identity, // hostUid 是 externalId(后端已转)\n livekitWs: this.livekitWs,\n iceServers: this.iceServers,\n })\n this.currentSession = session\n try {\n await session.connect()\n session.enableDevices().catch((err) => console.warn('[im] enableDevices 异常', err))\n } catch (err) {\n this.currentSession = null\n this.transport.meetingLeave(resp.meetingId).catch(() => {})\n throw err\n }\n return { meetingId: resp.meetingId, session, busyInvitees: resp.busyInvitees }\n }\n\n // 普通参与者入会\n async join(meetingId: string, title: string = ''): Promise<MeetingSession> {\n const resp = await this.transport.meetingJoin(meetingId)\n const session = new MeetingSession({\n meetingId,\n title,\n roomName: resp.roomName,\n identity: this.identity,\n token: resp.token,\n isHost: false,\n hostIdentity: resp.hostUid || '',\n livekitWs: this.livekitWs,\n iceServers: this.iceServers,\n })\n this.currentSession = session\n try {\n await session.connect()\n session.enableDevices().catch((err) => console.warn('[im] enableDevices 异常', err))\n } catch (err) {\n this.currentSession = null\n this.transport.meetingLeave(meetingId).catch(() => {})\n throw err\n }\n return session\n }\n\n // 接受/拒绝邀请\n // accept 时后端 Respond 内部完成 Join 流程并返回 token,SDK 直接建 session 连 LiveKit\n // 不再让调用方显式调 join(之前 respond 内部已调 + SDK 再调 join 导致双重 Join + RowsAffected=0 bug)\n // reject 时返回 null\n async respond(\n meetingId: string,\n action: 'accept' | 'reject',\n title?: string,\n ): Promise<MeetingSession | null> {\n const resp = await this.transport.meetingRespond(meetingId, action)\n if (action !== 'accept' || !resp?.token || !resp?.roomName) {\n return null\n }\n const session = new MeetingSession({\n meetingId,\n title: resp.title || title || '',\n roomName: resp.roomName,\n identity: this.identity,\n token: resp.token,\n isHost: false,\n hostIdentity: resp.hostUid || '',\n livekitWs: this.livekitWs,\n iceServers: this.iceServers,\n })\n this.currentSession = session\n try {\n await session.connect()\n session.enableDevices().catch((err) => console.warn('[im] enableDevices 异常', err))\n } catch (err) {\n this.currentSession = null\n this.transport.meetingLeave(meetingId).catch(() => {})\n throw err\n }\n return session\n }\n\n async leave() {\n if (this.currentSession) {\n await this.currentSession.leave(this.transport)\n this.currentSession = null\n }\n }\n\n // 解散会议:仅当前 host 可调;后端广播后所有客户端通过 meeting:dissolved 事件感知\n // 返回 true 表示已解散;false 表示未触发(如非 host)\n async dissolve(): Promise<boolean> {\n if (!this.currentSession) return false\n const meetingId = this.currentSession.meetingId\n await this.transport.meetingDissolve(meetingId)\n // 本地立即清理 currentSession(不等 broadcast 回来)\n this.currentSession = null\n return true\n }\n\n // 查 LiveKit 房间内当前参与者数量\n async getParticipantCount(): Promise<number> {\n if (!this.currentSession) return 0\n const resp = await this.transport.meetingParticipantCount(this.currentSession.meetingId)\n return resp.count\n }\n\n // 拉取当前 uid 的待处理邀请——SDK 在 connect 末尾调一次\n // 每条 PendingInvite 由调用方转成 meeting:invite 事件 emit 出去,UI 收到后弹邀请窗\n async fetchPendingInvites(): Promise<PendingInvite[]> {\n try {\n const resp = await this.transport.meetingPendingInvites()\n return resp.invites || []\n } catch (err) {\n console.warn('[im] fetchPendingInvites 失败', err)\n return []\n }\n }\n\n // 主持人邀请参会成员:无邀请记录的新成员走首次邀请;已有记录的(invited/left/removed)走重邀\n // 参数 uid 为目标用户 externalId(业务方用户 ID),透传给后端\n async reinvite(meetingId: string, uid: string): Promise<void> {\n await this.transport.meetingReinvite(meetingId, uid)\n }\n\n // 拉取成员列表——转发到 currentSession,挂载时调用方应主动 refresh\n async getMembers(force = false): Promise<MeetingMember[]> {\n if (!this.currentSession) return []\n return this.currentSession.refreshMembers(this.transport, force)\n }\n}\n","import { Transport } from './transport'\nimport { RealtimeConnection } from './realtime'\nimport type { Message, SendMessageReq, SendMessageResp, Conversation } from '@quitting/contract'\n\n// MessageController:消息发送/历史/已读/撤回\n//\n// ID 体系说明(externalId 改造后):\n// - 业务方调 send({to})/openDialog(peerUid) 传的都是 externalId\n// - SDK 内部拼 dialog#<a>,<b> 通道必须用 UUID(与 server 端一致,且 Centrifugo user_boundary 校验)\n// - 因此 SDK 维护 externalId → UUID 缓存,首次用某 peer 时查询后端 /users/by-external\nexport class MessageController {\n private transport: Transport\n private realtime: RealtimeConnection\n private uid: string // 自己的 UUID(订阅 personal# / dialog# 用)\n // externalId → UUID 缓存(避免每次发消息都查后端)\n private peerUidCache = new Map<string, string>()\n\n constructor(transport: Transport, realtime: RealtimeConnection, uid: string) {\n this.transport = transport\n this.realtime = realtime\n this.uid = uid\n }\n\n // resolvePeerUUID 把 peer 的 externalId 解析成 UUID(拼 dialog# 通道用)\n // 命中缓存直接返回;否则批量查后端\n private async resolvePeerUUID(peerExternalId: string): Promise<string> {\n const cached = this.peerUidCache.get(peerExternalId)\n if (cached) return cached\n const resp = await this.transport.usersByExternal([peerExternalId])\n const u = resp.users[peerExternalId]\n if (!u || !u.uid) {\n throw new Error(`peer not found: ${peerExternalId}`)\n }\n this.peerUidCache.set(peerExternalId, u.uid)\n return u.uid\n }\n\n // 发送文字消息\n // 参数 params.to 为收件人 externalId(业务方用户 ID),透传给后端\n async send(params: {\n to: string\n type: Message['type']\n body?: string\n mediaId?: string\n replyToId?: string\n }): Promise<SendMessageResp> {\n const req: SendMessageReq = {\n to: params.to,\n type: params.type,\n body: params.body,\n clientMid: crypto.randomUUID(),\n mediaId: params.mediaId,\n replyToId: params.replyToId,\n }\n const resp = await this.transport.sendMessage(req)\n // 立即 ACK 自己(消息已写入 MySQL 即视为送达)\n await this.transport.ackMessage(resp.msgId).catch(() => {})\n return resp\n }\n\n // 发送媒体消息(内部:获取预签名 URL → 直传 → 发消息)\n async sendMedia(\n params: {\n to: string\n file: File\n type: 'image' | 'voice' | 'video' | 'file'\n },\n uploader: { upload: (file: File, contentType: string) => Promise<{ mediaId: string }> },\n ): Promise<SendMessageResp> {\n const { mediaId } = await uploader.upload(params.file, params.file.type)\n return await this.send({\n to: params.to,\n type: params.type,\n mediaId,\n })\n }\n\n // 拉历史(按 seq 增量)\n async history(conversationId: string, sinceSeq = 0, limit = 50): Promise<Message[]> {\n const resp = await this.transport.listMessages(conversationId, sinceSeq, limit)\n return resp.messages as Message[]\n }\n\n // 客户端 ACK(消息必达三层保证)\n async ack(messageId: string) {\n await this.transport.ackMessage(messageId)\n }\n\n // 标记已读(按 seq 范围)\n async markRead(conversationId: string, lastReadSeq: number) {\n await this.transport.markRead(conversationId, lastReadSeq)\n }\n\n // 撤回消息(限时 2 分钟)\n async revoke(messageId: string) {\n await this.transport.revokeMessage(messageId)\n }\n\n // 输入指示器(走 Centrifugo 客户端直接 publish,allow_publish_for_subscriber)\n // to 为 peer externalId,内部解析成 UUID 拼通道;from 用 externalId(与 server payload 一致)\n async typing(to: string, isTyping: boolean) {\n const peerUUID = await this.resolvePeerUUID(to)\n const channel = this.dialogChannel(peerUUID)\n // from 应为 externalId 让对端识别;但 SDK 自己只有 UUID,用 dialogChannel 里 peerUUID 反查不到自己 externalId\n // 简化:from 留空或用 UUID,typing 是辅助功能,对端可忽略 from\n await this.realtime.publish(channel, { type: 'typing', from: '', isTyping })\n }\n\n // 会话列表\n async listConversations(): Promise<Conversation[]> {\n const resp = await this.transport.listConversations()\n return resp.conversations as Conversation[]\n }\n\n // 打开与 peer 的 1:1 会话通道(订阅 dialog#<UUID_A>,<UUID_B>,UUID 升序)\n // 业务方传 peer externalId,SDK 内部解析成 UUID 拼 channel(与 server dialogChannel 一致)\n // 内部走 realtime 引用计数:同一 peer 多次 openDialog 只首次订阅,closeDialog 归零才退订\n // 业务方在打开聊天面板时调用、关闭时调 closeDialog\n async openDialog(peerExternalId: string): Promise<void> {\n const peerUUID = await this.resolvePeerUUID(peerExternalId)\n const channel = this.dialogChannel(peerUUID)\n await this.realtime.subscribe(channel)\n }\n\n // 关闭与 peer 的 1:1 会话通道(引用计数 -1,归零退订)\n closeDialog(peerExternalId: string): void {\n // 异步解析但不阻塞退订;命中缓存则精准退订,未命中(极少见)忽略\n const peerUUID = this.peerUidCache.get(peerExternalId)\n if (!peerUUID) return\n const channel = this.dialogChannel(peerUUID)\n this.realtime.unsubscribe(channel)\n }\n\n // 计算 dialog 通道名(UUID 升序),与后端 signaling.message.dialogChannel 一致\n // 必须用 UUID 拼(Centrifugo user_boundary 校验 + 与 server 通道名一致)\n private dialogChannel(peerUUID: string): string {\n const a = [this.uid, peerUUID].sort()[0]\n const b = [this.uid, peerUUID].sort()[1]\n return `dialog#${a},${b}`\n }\n}\n","import { Transport } from './transport'\n\n// MediaUploader:媒体上传(预签名直传 + MD5 秒传)\n// 阶段 5 简化版:后端 /media/upload-url 返回占位 uploadUrl\n// 真实接入 MinIO 后会返回真正的预签名 PUT URL\nexport class MediaUploader {\n private transport: Transport\n\n constructor(transport: Transport) {\n this.transport = transport\n }\n\n // 计算文件 MD5(浏览器 SubtleCrypto)\n async computeMD5(file: File | Blob): Promise<string> {\n // SubtleCrypto 只支持 SHA-1/256/384/512,不支持 MD5\n // 这里用 SHA-256 作为秒传指纹(后端 media_objects.md5 字段语义改为\"任意 hash\")\n const buf = await file.arrayBuffer()\n const hash = await crypto.subtle.digest('SHA-256', buf)\n return Array.from(new Uint8Array(hash))\n .map((b) => b.toString(16).padStart(2, '0'))\n .join('')\n }\n\n // 上传文件:1) 检查秒传 2) 申请预签名 URL 3) 直传对象存储\n async upload(file: File, contentType?: string): Promise<{ mediaId: string }> {\n const md5 = await this.computeMD5(file)\n\n // 1. 秒传检查\n const checkResp = await this.transport.checkMd5(md5)\n if (checkResp.hit && checkResp.mediaId) {\n return { mediaId: checkResp.mediaId }\n }\n\n // 2. 申请 uploadUrl\n const uploadResp = await this.transport.getUploadUrl({\n fileName: file.name,\n contentType: contentType || file.type,\n fileSize: file.size,\n md5,\n })\n\n // 3. 直传对象存储(PUT 预签名 URL)\n // 阶段 5 后端返回的是占位 URL(\"/media/upload/<mediaId>\"),实际不可用\n // TODO phase-6:后端接入 MinIO 后会返回真实预签名 PUT URL\n if (uploadResp.uploadUrl && uploadResp.uploadUrl.startsWith('http')) {\n const resp = await fetch(uploadResp.uploadUrl, {\n method: 'PUT',\n body: file,\n headers: { 'Content-Type': contentType || file.type },\n })\n if (!resp.ok) {\n throw new Error(`上传对象存储失败: HTTP ${resp.status}`)\n }\n }\n\n return { mediaId: uploadResp.mediaId }\n }\n}\n","import type { ImEventName, ClientConfig } from '@quitting/contract'\n\n// realtime → ImClient 事件转发清单\n// RealtimeConnection 是独立 emitter;业务方调 client.on(...) 注册到 ImClient.emitter\n// 不转发的话 cb 永远不触发——这是 SDK 早期实现的疏漏\nconst REALTIME_FORWARDED_EVENTS: ImEventName[] = [\n 'message',\n 'message_preview',\n 'message:revoked',\n 'read_receipt',\n 'typing',\n 'call:invite',\n 'call:accepted',\n 'call:rejected',\n 'call:cancelled',\n 'call:busy',\n 'call:no_answer',\n 'call:ended',\n 'meeting:invite',\n 'meeting:kicked',\n 'meeting:hostChanged',\n 'meeting:dissolved',\n 'group:memberJoined',\n 'group:memberLeft',\n 'group:kicked',\n 'group:announcement',\n 'group:profileChanged',\n 'session:kicked',\n 'connection:state',\n // presence 聚合推送:后端每 10s 扫描在线状态变化,diff 拆成单用户事件逐条转发\n // 业务方 client.on('presence:update', e => { onlineMap[e.uid] = e.online })\n 'presence:update',\n]\n\n// @quitting/core 主入口\n// 业务方使用方式:\n// import { ImClient } from '@quitting/core'\n// const client = new ImClient({ appId, apiBase, centrifugoWs, livekitWs })\n// await client.auth.login(appId, externalId, ClientType.Web)\n// await client.connect()\n// client.on('call:invite', e => { ... })\n// client.call.init(to, 'video').then(session => session.attachLocalVideo(el))\n\nimport { EventEmitter } from './events'\nimport { Transport } from './transport'\nimport { AuthManager } from './auth'\nimport { RealtimeConnection } from './realtime'\nimport { CallController } from './call'\nimport { MeetingController } from './meeting'\nimport { MessageController } from './message'\nimport { MediaUploader } from './media'\nimport type { SessionState } from '@quitting/contract'\n\nexport interface ImClientOptions {\n appId: string\n apiBase: string // https://api.example.com\n centrifugoWs: string // wss://rt.example.com/connection/websocket\n livekitWs: string // wss://media.example.com 或 ws://localhost:7880\n}\n\n// ImClient 主类(业务方唯一入口)\n// 继承 EventEmitter,所有事件通过 client.on('event', handler) 订阅\nexport class ImClient extends EventEmitter {\n public auth!: AuthManager\n public transport!: Transport\n public realtime!: RealtimeConnection\n public call!: CallController\n public meeting!: MeetingController\n public message!: MessageController\n public media!: MediaUploader\n\n public uid = ''\n public appId: string\n public sessionState: SessionState = 'idle'\n\n // 客户端运行时配置(/client/config 接口下发),含 iceServers 等\n // SDK 内部通过 getConfig() 读取;拉取失败时为 null,调用方需兜底\n private clientConfig: ClientConfig | null = null\n\n private livekitWs: string\n private apiBase: string\n private name = ''\n private avatar = ''\n\n constructor(opts: ImClientOptions) {\n super()\n this.appId = opts.appId\n this.apiBase = opts.apiBase\n this.livekitWs = opts.livekitWs\n\n // 依赖注入链条\n this.auth = new AuthManager(null as any) // 占位,下面设 transport 后再重建\n this.transport = new Transport(opts.apiBase, () => this.auth.getToken())\n this.auth = new AuthManager(this.transport)\n this.realtime = new RealtimeConnection(opts.centrifugoWs, async () => {\n return await this.auth.getCentrifugoToken(this.name, this.avatar)\n })\n }\n\n // 登录 + 建立 Centrifugo 长连接 + 同步会话状态\n async connect(loginOpts: {\n externalId: string\n clientType?: number\n deviceName?: string\n name?: string\n avatar?: string\n }) {\n if (loginOpts.name) this.name = loginOpts.name\n if (loginOpts.avatar) this.avatar = loginOpts.avatar\n\n // 1. 登录拿 session JWT\n const loginResp = await this.auth.login(\n this.appId,\n loginOpts.externalId,\n loginOpts.clientType ?? 4,\n loginOpts.deviceName,\n )\n this.uid = loginResp.uid\n if (loginResp.name) this.name = loginResp.name\n if (loginResp.avatarUrl) this.avatar = loginResp.avatarUrl\n\n // 2-7. 公共初始化(loadClientConfig + Controller + realtime + sessionState + 事件)\n await this.initAfterAuth()\n\n return loginResp\n }\n\n /**\n * 业务方后端已调过 /auth/login 拿到 IM 会话 JWT,前端直接注入。\n * 适用于业务系统有自己的鉴权体系(SSO/OIDC),通过签名后端代理调用 IM 登录的场景。\n *\n * 与 connect() 的区别:跳过 SDK 内部的 auth.login(因为 JWT 已由业务后端换取),\n * 直接用注入的 JWT 初始化后续链路(clientConfig + Controller + Centrifugo + sessionState)。\n *\n * @param opts.jwt IM 会话 JWT(业务后端从 /auth/login 拿到)\n * @param opts.uid 用户 IM 内部 UUID(业务后端从 /auth/login 响应拿到,透传即可)\n * SDK 内部用于订阅 personal#<UUID> / dialog#<UUID> 通道及 getSessionState,\n * 业务方无需解析其语义,原样透传 SDK\n * @param opts.expiresAt JWT 过期时间(Unix 秒)\n * @param opts.name 可选展示名\n * @param opts.avatar 可选头像\n */\n async connectWithExternalJwt(opts: {\n jwt: string\n uid: string\n expiresAt: number\n name?: string\n avatar?: string\n }) {\n if (opts.name) this.name = opts.name\n if (opts.avatar) this.avatar = opts.avatar\n this.uid = opts.uid\n this.auth.setExternalToken(opts.jwt, opts.expiresAt)\n\n // 公共初始化(loadClientConfig + Controller + realtime + sessionState + 事件)\n await this.initAfterAuth()\n }\n\n // connect / connectWithExternalJwt 共用的第 2-7 步初始化\n // 1. loadClientConfig\n // 2. 初始化 4 个 Controller(依赖 uid + iceServers)\n // 3. 连 Centrifugo + 订阅 personal#<uid>\n // 4. 同步 sessionState(被叫会话冲突检测用)\n // 5. 注册 phase-2 业务监听(call:busy/no_answer、meeting:hostChanged/dissolved)\n // 6. 转发 realtime 事件到 ImClient\n // 7. 主动拉取待处理会议邀请\n private async initAfterAuth() {\n // 1. 拉取客户端运行时配置(无鉴权接口,登录前后都能调)\n // 失败时 fallback 空 iceServers——SDK 用浏览器默认 host 候选兜底\n await this.loadClientConfig()\n\n // 2. 初始化各 Controller(依赖 uid + iceServers)\n const iceServers = this.clientConfig?.iceServers ?? []\n this.call = new CallController(this.transport, this.livekitWs, this.uid, iceServers)\n this.meeting = new MeetingController(this.transport, this.livekitWs, this.uid, iceServers)\n this.message = new MessageController(this.transport, this.realtime, this.uid)\n this.media = new MediaUploader(this.transport)\n\n // 3. 连 Centrifugo + 订阅 personal#<uid>\n await this.realtime.connect(this.uid)\n\n // 4. 同步 sessionState(被叫会话冲突检测用)\n // 不传 uid —— 查自己的状态(后端从 JWT 解析),避免 UUID/externalId 语义混淆\n try {\n const stateResp = await this.transport.getSessionState()\n this.sessionState = stateResp.state\n this.emit('session:state', {\n state: stateResp.state,\n callId: stateResp.id,\n meetingId: stateResp.id,\n })\n } catch (err) {\n console.warn('[im] 同步 sessionState 失败', err)\n }\n\n // 5. 监听邀请事件,自动维护 sessionState\n this.realtime.on('call:invite', () => {\n /* 半 UI 层处理弹窗 */\n })\n this.realtime.on('call:busy', () => {\n this.call.currentSession = null\n })\n this.realtime.on('call:no_answer', () => {\n this.call.currentSession = null\n })\n // 主持人转让广播:更新 currentSession.hostIdentity,UI 据此切换主视频\n this.realtime.on('meeting:hostChanged', (e: any) => {\n const sess = this.meeting?.currentSession\n if (sess && e?.meetingId === sess.meetingId && e?.hostUid) {\n sess.hostIdentity = e.hostUid\n }\n })\n // 会议解散广播:触发 currentSession.end,UI 显示「会议已结束」蒙层\n this.realtime.on('meeting:dissolved', (e: any) => {\n const sess = this.meeting?.currentSession\n if (sess && e?.meetingId === sess.meetingId) {\n sess.end().catch((err: any) => console.warn('[im] dissolved end session 失败', err))\n }\n })\n // 被主持人踢出:必须主动断开 LiveKit Room,否则窗口虽关但远端 audio track\n // 仍 attach 在隐藏元素上播放——用户会\"还能听到会议声音\"。\n // 与 dissolved 对称:SDK 层兜底断连,业务方只需处理 UI 关窗。\n this.realtime.on('meeting:kicked', (e: any) => {\n const sess = this.meeting?.currentSession\n if (sess && e?.meetingId === sess.meetingId) {\n sess.end().catch((err: any) => console.warn('[im] kicked end session 失败', err))\n }\n })\n // 同端互踢:另一端用同 client_type 登录,本端被动下线\n // 断开 realtime + 清理本地状态,业务方监听 session:kicked 提示用户\"已在别处登录\"\n this.realtime.on('session:kicked', () => {\n console.warn('[im] 会话被踢(同端新登录),断开连接')\n try {\n this.realtime.disconnect()\n } catch (e) {\n // ignore\n }\n this.auth.logout()\n this.uid = ''\n })\n\n // 6. 转发 realtime 其余事件到 ImClient(业务方 client.on('message', cb) 才能收到)\n this.forwardRealtimeEvents()\n\n // 7. 主动拉取待处理会议邀请——用户离线期间收到的邀请/重邀都在这里取回\n // 业务方收到 meeting:invite 时自行异步校验后端 pending(应对 recovery 重放 stale publication)\n this.fetchAndEmitPendingInvites()\n }\n\n // 拉取客户端运行时配置(无鉴权)\n // 失败时 clientConfig 置 null,SDK 用浏览器默认 ICE 候选兜底(仅 host 候选,无 srflx)\n // 时序:connect 早期阶段调一次,之后 getConfig() 同步返回\n private async loadClientConfig() {\n if (this.clientConfig) return\n try {\n this.clientConfig = await this.transport.getClientConfig()\n } catch (err) {\n console.warn('[im] 拉取 client/config 失败,使用空 iceServers 兜底', err)\n this.clientConfig = { iceServers: [] }\n }\n }\n\n // 读取已缓存的客户端配置(loadClientConfig 后才有值)\n // 业务方一般用不到——SDK 内部 CallSession/MeetingSession 已消费 iceServers\n public getConfig(): ClientConfig | null {\n return this.clientConfig\n }\n\n // 拉取待处理会议邀请,把每条转成 meeting:invite 事件 emit\n // 由调用方 UI(MeetingInvite 组件)接管展示\n // 业务方收到 meeting:invite 时应异步校验后端 pending(应对 recovery 重放 stale publication)\n private async fetchAndEmitPendingInvites() {\n if (!this.meeting) return\n try {\n const invites = await this.meeting.fetchPendingInvites()\n const currentMeetingId = this.meeting.currentSession?.meetingId\n for (const inv of invites) {\n if (inv.meetingId === currentMeetingId) continue\n ;(this.emit as (event: ImEventName, payload: unknown) => void)('meeting:invite', {\n meetingId: inv.meetingId,\n from: inv.from,\n title: inv.title,\n ts: inv.ts,\n variant: inv.variant,\n reinvite: inv.variant === 'reinvite',\n })\n }\n } catch (err) {\n console.warn('[im] fetchAndEmitPendingInvites 失败', err)\n }\n }\n\n // 把 RealtimeConnection 的所有事件透传到 ImClient 自身的 emitter\n // 业务方调 client.on('message', cb) 时 cb 注册在 ImClient.emitter;realtime.emit 不会触发它\n // 必须显式转发。connect 都调一次(重复注册由 EventEmitter 内部 Set 去重)\n // 注意:call:busy / call:no_answer 在 connect() 里有额外业务处理(清 currentSession),不在该方法内\n private forwardRealtimeEvents() {\n for (const name of REALTIME_FORWARDED_EVENTS) {\n this.realtime.on(name, (payload: unknown) => {\n ;(this.emit as (event: ImEventName, payload: unknown) => void)(name, payload)\n })\n }\n }\n\n // 主动断开\n async disconnect() {\n if (this.call?.currentSession) {\n await this.call.end('user_left').catch(() => {})\n }\n if (this.meeting?.currentSession) {\n await this.meeting.leave().catch(() => {})\n }\n this.realtime.disconnect()\n this.auth.logout()\n this.removeAllListeners()\n }\n}\n\n// 重新导出所有公共类型与子模块\nexport { EventEmitter } from './events'\nexport { Transport } from './transport'\nexport { AuthManager } from './auth'\nexport { RealtimeConnection } from './realtime'\nexport { CallController, CallSession } from './call'\nexport { MeetingController, MeetingSession } from './meeting'\nexport { MessageController } from './message'\nexport { MediaUploader } from './media'\nexport { ApiError } from './api-error'\nexport * from '@quitting/contract'\n"],"mappings":"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"}
1
+ {"version":3,"file":"im-core.es.js","names":[],"sources":["../src/events.ts","../src/api-error.ts","../src/transport.ts","../src/auth.ts","../src/realtime.ts","../src/livekit-quiet.ts","../src/call.ts","../src/meeting.ts","../src/message.ts","../src/media.ts","../src/users.ts","../src/index.ts"],"sourcesContent":["import type { ImEventName, ImEventMap } from '@quitting/contract'\n\n// 简易 EventEmitter(不引第三方库)\n// 用法:client.on('call:invite', (e: CallInviteEvent) => {...})\nexport class EventEmitter {\n private listeners = new Map<ImEventName, Set<Function>>()\n\n on<K extends ImEventName>(event: K, handler: (payload: ImEventMap[K]) => void): () => void {\n if (!this.listeners.has(event)) {\n this.listeners.set(event, new Set())\n }\n this.listeners.get(event)!.add(handler as Function)\n // 返回 unsubscribe 函数\n return () => this.off(event, handler)\n }\n\n off<K extends ImEventName>(event: K, handler: (payload: ImEventMap[K]) => void): void {\n this.listeners.get(event)?.delete(handler as Function)\n }\n\n protected emit<K extends ImEventName>(event: K, payload: ImEventMap[K]): void {\n const set = this.listeners.get(event)\n if (!set) return\n for (const fn of set) {\n try {\n fn(payload)\n } catch (err) {\n console.error(`[im] event handler error: ${event}`, err)\n }\n }\n }\n\n // 清空所有监听器(disconnect 时调用)\n removeAllListeners(): void {\n this.listeners.clear()\n }\n}\n","// ApiError:SDK 对外抛出的结构化错误,携带业务码供调用方精确分支\n//\n// 错误响应规约(与 server/internal/api/respond.go 对齐):\n// HTTP 4xx/5xx,body = {code: <业务码>, message: <友好提示>}\n//\n// 调用方用法:\n// try {\n// await client.call.init(...)\n// } catch (e) {\n// if (e instanceof ApiError) {\n// if (e.code === ApiErrorCode.Busy) { ... }\n// }\n// }\nexport class ApiError extends Error {\n /** 业务错误码(5 位数字,见 @quitting/contract ApiErrorCode) */\n readonly code: number\n /** HTTP 状态码(如 409/401/500) */\n readonly httpStatus: number\n\n constructor(code: number, httpStatus: number, message: string) {\n super(message)\n this.name = 'ApiError'\n this.code = code\n this.httpStatus = httpStatus\n }\n}\n","import type {\n SendMessageReq,\n SendMessageResp,\n CallInitResp,\n CallAcceptResp,\n CreateMeetingReq,\n CreateMeetingResp,\n JoinMeetingResp,\n ListMeetingMembersResp,\n TransferHostReq,\n TransferHostResp,\n DissolveResp,\n ParticipantCountResp,\n ListPendingInvitesResp,\n SessionState,\n UploadUrlReq,\n UploadUrlResp,\n CheckMd5Resp,\n ClientConfig,\n} from '@quitting/contract'\nimport { ApiError } from './api-error'\n\n// Transport:REST API 客户端封装\n// 所有 HTTP 调用统一走这里,自动注入 Authorization 头 + trace_id\nexport class Transport {\n private apiBase: string\n private tokenGetter: () => string | null\n\n constructor(apiBase: string, tokenGetter: () => string | null) {\n this.apiBase = apiBase.replace(/\\/$/, '')\n this.tokenGetter = tokenGetter\n }\n\n private async request<T>(path: string, opts: RequestInit = {}): Promise<T> {\n const url = `${this.apiBase}${path}`\n const headers: Record<string, string> = {\n 'Content-Type': 'application/json',\n ...(opts.headers as Record<string, string>),\n }\n const token = this.tokenGetter()\n if (token) {\n headers['Authorization'] = `Bearer ${token}`\n }\n const traceId = crypto.randomUUID?.() || Math.random().toString(36).slice(2)\n headers['X-Trace-Id'] = traceId\n\n const resp = await fetch(url, { ...opts, headers })\n if (!resp.ok) {\n // 解析业务码 + 友好文案,抛 ApiError 供调用方精确分支\n let code = 0\n let msg = `HTTP ${resp.status}`\n try {\n const body = await resp.json()\n if (body?.code) code = Number(body.code)\n if (body?.message) msg = body.message\n } catch {\n // 非 JSON 响应(如网关 502),保留默认 msg\n }\n throw new ApiError(code, resp.status, msg)\n }\n return resp.json() as Promise<T>\n }\n\n // fire-and-forget 风格的请求:用于 beforeunload 等浏览器即将卸载场景\n // keepalive: true 让浏览器在页面 unload 后仍能完成请求(≤ 64KB)\n // 不返回响应——失败也无法感知,调用方必须能容忍丢失\n async sendBeacon(path: string, body: unknown): Promise<void> {\n const url = `${this.apiBase}${path}`\n const headers: Record<string, string> = { 'Content-Type': 'application/json' }\n const token = this.tokenGetter()\n if (token) headers['Authorization'] = `Bearer ${token}`\n try {\n await fetch(url, {\n method: 'POST',\n headers,\n body: JSON.stringify(body),\n keepalive: true,\n })\n } catch (err) {\n console.warn('[im] sendBeacon 失败(页面卸载中可能正常)', err)\n }\n }\n\n // ===== 鉴权 =====\n // 客户端运行时配置(登录前拉,无鉴权)\n // SDK 在 connect 时调一次,结果全局缓存\n getClientConfig() {\n return this.request<ClientConfig>('/client/config')\n }\n\n login(appId: string, externalId: string, clientType: number = 4, deviceName?: string) {\n return this.request<{\n token: string\n uid: string\n name: string\n avatarUrl: string\n expiresAt: number\n }>('/auth/login', {\n method: 'POST',\n body: JSON.stringify({ appId, externalId, clientType, deviceName }),\n })\n }\n\n refresh() {\n return this.request<{ token: string }>('/auth/refresh', { method: 'POST' })\n }\n\n getCentrifugoToken(name?: string, avatar?: string) {\n const qs = new URLSearchParams()\n if (name) qs.set('name', name)\n if (avatar) qs.set('avatar', avatar)\n return this.request<{ token: string }>(`/auth/centrifugo-token?${qs.toString()}`)\n }\n\n // ===== 会话状态 =====\n // uid 不传时查自己(后端从 JWT sub 解析);传 externalId 时查对方\n getSessionState(uid?: string) {\n const qs = uid ? `?uid=${encodeURIComponent(uid)}` : ''\n return this.request<{ state: SessionState; type?: string; id?: string; roomName?: string }>(\n `/session/state${qs}`,\n )\n }\n\n // 按 externalId 批量反查用户信息(主要拿 UUID 拼 dialog# 通道)\n usersByExternal(externalIds: string[]) {\n return this.request<{\n users: Record<string, { uid: string; name: string; deptName?: string; avatarUrl?: string }>\n }>(`/users/by-external?ids=${externalIds.map(encodeURIComponent).join(',')}`)\n }\n\n // 全量用户列表(含在线状态)——SDK 用户目录(UserDirectory)与 UI 选人弹窗的默认数据源\n listUsers() {\n return this.request<{\n users: Array<{\n uid: string\n externalId: string\n name: string\n deptName?: string\n avatarUrl?: string\n online?: boolean\n }>\n }>('/users')\n }\n\n // ===== 1:1 通话 =====\n callInit(calleeUid: string, type: 'audio' | 'video') {\n return this.request<CallInitResp>('/call/init', {\n method: 'POST',\n body: JSON.stringify({ calleeUid, type }),\n })\n }\n callAccept(callId: string) {\n return this.request<CallAcceptResp>('/call/accept', {\n method: 'POST',\n body: JSON.stringify({ callId }),\n })\n }\n callEnd(callId: string, reason: string) {\n return this.request<{ ok: boolean }>('/call/end', {\n method: 'POST',\n body: JSON.stringify({ callId, reason }),\n })\n }\n\n // ===== 会议 =====\n meetingCreate(req: CreateMeetingReq) {\n return this.request<CreateMeetingResp>('/meeting/create', {\n method: 'POST',\n body: JSON.stringify(req),\n })\n }\n meetingJoin(meetingId: string) {\n return this.request<JoinMeetingResp>('/meeting/join', {\n method: 'POST',\n body: JSON.stringify({ meetingId }),\n })\n }\n // accept 时返回 {roomName, token, title?, hostUid?}——SDK 拿 token 直接连 LiveKit,不再调 join\n // reject 时返回 {ok: true}\n meetingRespond(meetingId: string, action: 'accept' | 'reject') {\n return this.request<{\n ok?: boolean\n roomName?: string\n token?: string\n title?: string\n hostUid?: string\n }>(`/meeting/${meetingId}/respond`, {\n method: 'POST',\n body: JSON.stringify({ action }),\n })\n }\n meetingKick(meetingId: string, uid: string) {\n return this.request<{ ok: boolean }>(`/meeting/${meetingId}/kick`, {\n method: 'POST',\n body: JSON.stringify({ uid }),\n })\n }\n meetingReinvite(meetingId: string, uid: string) {\n return this.request<{ ok: boolean }>(`/meeting/${meetingId}/reinvite`, {\n method: 'POST',\n body: JSON.stringify({ uid }),\n })\n }\n meetingLeave(meetingId: string) {\n return this.request<{ ok: boolean }>(`/meeting/${meetingId}/leave`, { method: 'POST' })\n }\n // 主持人解散会议:所有参与者强制离会 + 会议状态置 ended\n meetingDissolve(meetingId: string) {\n return this.request<DissolveResp>(`/meeting/${meetingId}/dissolve`, { method: 'POST' })\n }\n // 查 LiveKit 房间内当前参与者数量——host 离开前判断走 Leave 还是 Dissolve\n meetingParticipantCount(meetingId: string) {\n return this.request<ParticipantCountResp>(`/meeting/${meetingId}/participant-count`)\n }\n // 拉取当前 uid 的待处理会议邀请——SDK 在 connect 成功后调一次\n meetingPendingInvites() {\n return this.request<ListPendingInvitesResp>(`/meetings/pending-invites`)\n }\n // 列出会议全量成员(含已邀请未加入者)+ 当前主持人 uid\n meetingMembers(meetingId: string) {\n return this.request<ListMeetingMembersResp>(`/meeting/${meetingId}/members`)\n }\n // 主持人转让:仅当前主持人可调,后端会广播给所有 invitee\n meetingTransferHost(meetingId: string, newHostUid: string) {\n return this.request<TransferHostResp>(`/meeting/${meetingId}/transfer-host`, {\n method: 'POST',\n body: JSON.stringify({ newHostUid } satisfies TransferHostReq),\n })\n }\n\n // ===== 消息 =====\n sendMessage(req: SendMessageReq) {\n return this.request<SendMessageResp>('/messages', {\n method: 'POST',\n body: JSON.stringify(req),\n })\n }\n ackMessage(messageId: string) {\n return this.request<{ ok: boolean }>(`/messages/${messageId}/ack`, { method: 'POST' })\n }\n revokeMessage(messageId: string) {\n return this.request<{ ok: boolean }>(`/messages/${messageId}/revoke`, { method: 'POST' })\n }\n listMessages(conversationId: string, sinceSeq = 0, limit = 50) {\n return this.request<{ messages: any[] }>(\n `/conversations/${conversationId}/messages?sinceSeq=${sinceSeq}&limit=${limit}`,\n )\n }\n markRead(conversationId: string, lastReadSeq: number) {\n return this.request<{ ok: boolean }>(`/conversations/${conversationId}/read`, {\n method: 'POST',\n body: JSON.stringify({ lastReadSeq }),\n })\n }\n listConversations() {\n return this.request<{ conversations: any[] }>('/conversations')\n }\n\n // ===== 媒体 =====\n getUploadUrl(req: UploadUrlReq) {\n return this.request<UploadUrlResp>('/media/upload-url', {\n method: 'POST',\n body: JSON.stringify(req),\n })\n }\n checkMd5(md5: string) {\n return this.request<CheckMd5Resp>('/media/check-md5', {\n method: 'POST',\n body: JSON.stringify({ md5 }),\n })\n }\n}\n","import { Transport } from './transport'\n\n// AuthManager:管理会话 JWT 的获取与刷新\n// SDK 内部自动刷新,业务方只感知 connected/disconnected\nexport class AuthManager {\n private transport: Transport\n private sessionToken: string | null = null\n private centrifugoToken: string | null = null\n private expiresAt = 0\n private refreshTimer: ReturnType<typeof setTimeout> | null = null\n\n // 自动刷新:过期前 1 分钟换新 token\n private readonly refreshAheadMs = 60 * 1000\n\n constructor(transport: Transport) {\n this.transport = transport\n }\n\n getToken(): string | null {\n return this.sessionToken\n }\n\n isExpired(): boolean {\n return Date.now() >= this.expiresAt - this.refreshAheadMs\n }\n\n // 登录\n async login(appId: string, externalId: string, clientType: number = 4, deviceName?: string) {\n const resp = await this.transport.login(appId, externalId, clientType, deviceName)\n this.sessionToken = resp.token\n this.expiresAt = resp.expiresAt * 1000\n this.scheduleRefresh()\n return resp\n }\n\n // 外部注入会话 JWT(connectWithExternalJwt 用)。刷新走标准 /auth/refresh。\n setExternalToken(token: string, expiresAt: number) {\n this.sessionToken = token\n this.expiresAt = expiresAt * 1000\n this.scheduleRefresh()\n }\n\n // 获取 Centrifugo 连接 JWT(5 分钟有效,SDK 自动换新)\n async getCentrifugoToken(name?: string, avatar?: string): Promise<string> {\n if (this.centrifugoToken) {\n return this.centrifugoToken\n }\n const resp = await this.transport.getCentrifugoToken(name, avatar)\n this.centrifugoToken = resp.token\n // Centrifugo JWT 5 分钟过期,4 分钟时清空让 centrifuge-js 自动调 getToken 换新\n setTimeout(\n () => {\n this.centrifugoToken = null\n },\n 4 * 60 * 1000,\n )\n return resp.token\n }\n\n // 主动刷新\n // 失败重试 3 次(间隔 5s);仍失败则清 token(业务方需重新登录)\n async refresh() {\n for (let i = 0; i < 3; i++) {\n try {\n const resp = await this.transport.refresh()\n this.sessionToken = resp.token\n this.expiresAt = Date.now() + 24 * 60 * 60 * 1000\n this.scheduleRefresh()\n return\n } catch (err: any) {\n const msg = String(err?.message || err)\n // 401 = 被踢或彻底过期,不再重试,清 token 让业务方重新登录\n if (msg.includes('401') || msg.includes('kicked') || msg.includes('expired')) {\n console.error('[im] refresh 失败(session 失效),需重新登录', err)\n this.logout()\n return\n }\n console.warn(`[im] refresh token 失败(第 ${i + 1} 次),5s 后重试`, err)\n await new Promise((r) => setTimeout(r, 5000))\n }\n }\n console.error('[im] refresh token 重试 3 次仍失败,清 token')\n this.logout()\n }\n\n // 自动安排下次刷新\n private scheduleRefresh() {\n if (this.refreshTimer) {\n clearTimeout(this.refreshTimer)\n }\n const delay = Math.max(this.expiresAt - Date.now() - this.refreshAheadMs, 60 * 1000)\n this.refreshTimer = setTimeout(() => this.refresh(), delay)\n }\n\n logout() {\n if (this.refreshTimer) {\n clearTimeout(this.refreshTimer)\n this.refreshTimer = null\n }\n this.sessionToken = null\n this.centrifugoToken = null\n }\n}\n","import { Centrifuge, type PublicationContext, type Subscription } from 'centrifuge'\n\nimport { EventEmitter } from './events'\nimport type { ConnectionState, ImEventName, ImEventMap } from '@quitting/contract'\n\n// RealtimeConnection:Centrifuge-js 封装\n// 职责:建立 WebSocket 长连接、订阅 personal#<uid> 通道、把 publication 分发到 EventEmitter\n//\n// 关键设计(docs/06 §2.3):\n// - centrifuge-js v5.7.0 与 Centrifugo v6 协议对齐\n// - getToken 回调:过期前自动向后端换新 JWT(5 分钟有效)\n// - personal#<uid> 通道:所有邀请/通知的入口\nexport class RealtimeConnection extends EventEmitter {\n private client: Centrifuge | null = null\n private subscriptions = new Map<string, Subscription>()\n // 引用计数:同一 channel 多次 subscribe 时只首次创建 Subscription,归零才真 unsubscribe\n // 规避 centrifuge-js v5 unsubscribe 后 newSubscription 抛 \"already exists\" 的内部 map bug\n // 也支撑 SDK 高层 API openDialog/closeDialog 的多面板共享订阅语义\n private refCount = new Map<string, number>()\n private wsUrl: string\n private uid = ''\n private tokenGetter: () => Promise<string>\n private currentState: ConnectionState = 'disconnected'\n\n constructor(wsUrl: string, tokenGetter: () => Promise<string>) {\n super()\n this.wsUrl = wsUrl\n this.tokenGetter = tokenGetter\n }\n\n get state(): ConnectionState {\n return this.currentState\n }\n\n async connect(uid: string) {\n this.uid = uid\n // 已有连接先断开:直接 new 会把旧 Centrifuge 实例孤儿化(WebSocket 泄漏),\n // 且 subscriptions 里的旧 Subscription 挂在旧 client 上,新连接永远收不到推送\n if (this.client) {\n this.disconnect()\n }\n this.client = new Centrifuge(this.wsUrl, {\n getToken: async () => {\n return await this.tokenGetter()\n },\n })\n\n this.client.on('connected', () => {\n this.currentState = 'connected'\n this.emit(\n 'connection:state' as ImEventName,\n 'connected' as unknown as ImEventMap['connection:state'],\n )\n // 自动订阅 personal#<uid> 通道\n this.subscribe(`personal#${uid}`)\n })\n\n this.client.on('disconnected', (ctx: any) => {\n this.currentState = 'disconnected'\n this.emit(\n 'connection:state' as ImEventName,\n 'disconnected' as unknown as ImEventMap['connection:state'],\n )\n console.warn('[im] centrifugo disconnected', ctx?.reason)\n })\n\n this.client.on('error', (ctx: any) => {\n console.error('[im] centrifugo error', ctx)\n })\n\n this.client.connect()\n }\n\n // 订阅指定通道(dialog/group/personal)\n // 引用计数语义:多次调用同 channel 只首次创建 Subscription,对应 unsubscribe 多次才真退订\n async subscribe(channel: string): Promise<Subscription | null> {\n if (!this.client) return null\n // 引用计数 +1\n const cur = this.refCount.get(channel) || 0\n this.refCount.set(channel, cur + 1)\n // 已订阅:直接返回 cached Subscription\n if (this.subscriptions.has(channel)) {\n return this.subscriptions.get(channel)!\n }\n const sub = this.client.newSubscription(channel)\n sub.on('publication', (ctx: PublicationContext) => {\n this.handlePublication(channel, ctx)\n })\n sub.subscribe()\n this.subscriptions.set(channel, sub)\n return sub\n }\n\n unsubscribe(channel: string) {\n // 引用计数 -1;refCount 归零时**不调** centrifuge-js 的 unsubscribe\n // centrifuge-js v5 的 unsubscribe 后内部 channel→Subscription map 不会立即清理,\n // 下次 newSubscription 同 channel 会抛 \"Subscription already exists\"\n // 代价:归零后仍订阅(centrifugo 会继续 push publication,但无 SDK listener 监听,进真空)\n // 收益:彻底规避 v5 map bug;demo 4 人场景订阅开销可忽略\n const cur = this.refCount.get(channel) || 0\n if (cur <= 1) {\n this.refCount.delete(channel)\n } else {\n this.refCount.set(channel, cur - 1)\n }\n }\n\n // publish 输入指示器等瞬态信号(dialog 通道 allow_publish_for_subscriber)\n async publish(channel: string, data: unknown): Promise<void> {\n const sub = this.subscriptions.get(channel)\n if (!sub) {\n throw new Error(`未订阅通道: ${channel}`)\n }\n await sub.publish(data as Record<string, unknown>)\n }\n\n // 处理 publication,按 type 字段分发到对应事件\n private handlePublication(channel: string, ctx: PublicationContext) {\n const data = ctx.data as Record<string, unknown>\n const type = data.type as string\n switch (type) {\n case 'call_invite':\n this.emit('call:invite', data as unknown as ImEventMap['call:invite'])\n break\n case 'call_accepted':\n // 被叫接听,主叫感知后开始 connect\n this.emit('call:accepted', data as unknown as ImEventMap['call:accepted'])\n break\n case 'call_rejected':\n // 被叫拒接,主叫 CallOutgoing 显示\"对方拒接\"\n this.emit('call:rejected', data as unknown as ImEventMap['call:rejected'])\n break\n case 'call_cancelled':\n // 主叫取消,被叫 IncomingRing 自动关\n this.emit('call:cancelled', data as unknown as ImEventMap['call:cancelled'])\n break\n case 'call_busy':\n this.emit('call:busy', data as unknown as ImEventMap['call:busy'])\n break\n case 'call_no_answer':\n this.emit('call:no_answer', data as unknown as ImEventMap['call:no_answer'])\n break\n case 'call_ended':\n // 通话中挂断,对端 CallWindow 自动关\n this.emit('call:ended', data as unknown as ImEventMap['call:ended'])\n break\n case 'meeting_invite':\n this.emit('meeting:invite', data as unknown as ImEventMap['meeting:invite'])\n break\n case 'meeting_kicked':\n this.emit('meeting:kicked', data as unknown as ImEventMap['meeting:kicked'])\n break\n case 'meeting_host_changed':\n // 主持人转让:所有 invitee 收到,更新 currentSession.hostIdentity + emit session 内部事件\n // SDK 高层 forward 到外层 EventEmitter 供 UI 监听\n this.emit('meeting:hostChanged', data as unknown as ImEventMap['meeting:hostChanged'])\n break\n case 'meeting_dissolved':\n // 会议解散:所有 invitee 收到,触发 currentSession.end\n this.emit('meeting:dissolved', data as unknown as ImEventMap['meeting:dissolved'])\n break\n case 'message':\n this.emit('message', data as unknown as ImEventMap['message'])\n break\n case 'message_preview':\n // 两层推送的轻量层(docs/03 §1.5.4):personal# 通道推送,会话列表未读数 + 预览刷新\n this.emit('message_preview', data as unknown as ImEventMap['message_preview'])\n break\n case 'message_revoked':\n this.emit('message:revoked', data as unknown as ImEventMap['message:revoked'])\n break\n case 'read_receipt':\n this.emit('read_receipt', data as unknown as ImEventMap['read_receipt'])\n break\n case 'typing':\n // 输入指示器:对端在 dialog 通道直发(不经服务端),SDK 是唯一分发点\n // 之前缺此分支,typing 落 default 被丢弃——输入指示器端到端失效\n this.emit('typing', {\n from: (data.from as string) || '',\n isTyping: !!data.isTyping,\n } as ImEventMap['typing'])\n break\n case 'presence:update':\n // presence 聚合推送:后端每 10s 扫描 channels 算 diff 后广播\n // changes 是 [{uid(externalId), online}] 数组,逐条 emit(业务方按 uid 更新在线状态)\n {\n const changes = (data.changes as Array<{ uid: string; online: boolean }>) || []\n for (const ch of changes) {\n this.emit('presence:update', {\n uid: ch.uid,\n online: ch.online,\n } as unknown as ImEventMap['presence:update'])\n }\n }\n break\n case 'session:kicked':\n this.emit('session:kicked', data as unknown as ImEventMap['session:kicked'])\n break\n case 'group:memberJoined':\n this.emit('group:memberJoined', data as unknown as ImEventMap['group:memberJoined'])\n break\n case 'group:kicked':\n this.emit('group:kicked', data as unknown as ImEventMap['group:kicked'])\n break\n case 'group:memberLeft':\n this.emit('group:memberLeft', data as unknown as ImEventMap['group:memberLeft'])\n break\n case 'group:profileChanged':\n this.emit('group:profileChanged', data as unknown as ImEventMap['group:profileChanged'])\n break\n default:\n console.debug('[im] 未知 publication type', type, channel)\n }\n }\n\n disconnect() {\n if (this.client) {\n this.client.disconnect()\n this.client = null\n }\n // refCount 一并清空:连接已断,所有 channel 订阅随之消失,\n // 残留计数会让重连后的 unsubscribe 语义错乱\n this.subscriptions.clear()\n this.refCount.clear()\n this.currentState = 'disconnected'\n }\n}\n","import { setLogLevel, type Room } from 'livekit-client'\n\n// 静默断开 LiveKit room:抑制 disconnect 时 DataChannel/ICE 的正常关闭日志。\n// (meeting.ts 顶层已 setLogLevel('warn'),这里断开窗口内临时降到 silent)\n//\n// 恢复必须延迟:room.disconnect() resolve 后 LiveKit 内部仍有异步收尾\n// (publisher renegotiate、RTCPeerConnection/DataChannel close 回调),\n// 立即恢复 warn 会漏出 \"User-Initiated Abort\" / \"could not createOffer with\n// closed peer connection\" 等噪音日志。\n// 多个 room 相继断开(通话+会议同挂)时重置计时器,保持窗口连续静默。\nlet restoreTimer: ReturnType<typeof setTimeout> | null = null\n\n// 立即静默 LiveKit 日志(重置恢复计时器,窗口重新起算)。\n// 供「服务端会抢先断开本端连接」的场景提前静默:会议 leave 先调后端\n// meetingLeave,后端 host 兜底 auto-dissolve 会 DeleteRoom → 服务端主动断开\n// 本端 → DataChannel abort 日志赶在 disconnectSilently 之前漏出。\nexport function silenceLiveKit(): void {\n setLogLevel('silent')\n if (restoreTimer) {\n clearTimeout(restoreTimer)\n restoreTimer = null\n }\n}\n\nexport async function disconnectSilently(room: Room): Promise<void> {\n silenceLiveKit()\n try {\n await room.disconnect()\n } finally {\n restoreTimer = setTimeout(() => {\n setLogLevel('warn')\n restoreTimer = null\n }, 1500)\n }\n}\n","import { Room, Track, type Participant, type TrackPublication } from 'livekit-client'\n\nimport { EventEmitter } from './events'\nimport { disconnectSilently } from './livekit-quiet'\nimport type { Transport } from './transport'\nimport type { CallType, CallEndReason, ClientConfig } from '@quitting/contract'\n\n// ICE 服务器条目类型(与 RTCIceServer 对齐,但简化为 SDK 实际使用的字段)\ntype IceServer = ClientConfig['iceServers'][number]\n\n// CallSession:单次通话会话,封装 livekit-client Room\n// 事件:\n// - connected:本地入房成功\n// - remoteConnected:远端参与者入房\n// - remoteDisconnected:远端离开\n// - ended:通话结束(含 reason)\nexport class CallSession extends EventEmitter {\n public callId: string\n public callType: CallType\n public roomName: string\n public identity: string\n public peerIdentity?: string\n\n private room: Room\n private livekitWs: string\n private iceServers: IceServer[]\n private ended = false\n\n // 自定义事件类型(与 ImEventMap 解耦,CallSession 私有)\n private sessionListeners = new Map<string, Set<Function>>()\n\n constructor(opts: {\n callId: string\n callType: CallType\n roomName: string\n identity: string\n token: string\n livekitWs: string\n iceServers: IceServer[]\n }) {\n super()\n this.callId = opts.callId\n this.callType = opts.callType\n this.roomName = opts.roomName\n this.identity = opts.identity\n this.token = opts.token\n this.livekitWs = opts.livekitWs\n this.iceServers = opts.iceServers\n // Room 构造只放 RoomOptions 字段(adaptiveStream / dynacast 等)。\n // rtcConfig 属于 RoomConnectOptions,要传给 room.connect(),不能放构造参数——\n // livekit-client@2.20 起 TS 类型严格校验,构造里写 rtcConfig 会报 TS2353。\n this.room = new Room({\n adaptiveStream: true,\n dynacast: true,\n })\n }\n\n // 入房(不开设备):仅建立 LiveKit 信令连接 + 注册参与者事件\n // 与 enableDevices 分离,让主叫方在\"呼叫等待\"阶段可以提前入房但不打开摄像头\n async connect() {\n this.room\n .on('participantConnected', (p: Participant) => {\n this.peerIdentity = p.identity\n this.emitSession('remoteConnected', { identity: p.identity })\n })\n .on('participantDisconnected', (p: Participant) => {\n this.emitSession('remoteDisconnected', { identity: p.identity })\n this.end('user_left')\n })\n .on('disconnected', () => {\n if (!this.ended) {\n this.end('user_left')\n }\n })\n // 关键:对端 track 异步流入触发——attachRemoteVideo 必须等此事件才能成功 attach\n // participantConnected 只表示对端入房,track 还在路上\n .on('trackSubscribed', (track: Track, _pub: TrackPublication, p: Participant) => {\n // audio track 显式 attach 确保播放(与会议一致;LiveKit 默认自动播放不可靠)\n if (track.kind === Track.Kind.Audio) {\n track.attach()\n }\n this.emitSession('remoteTrackSubscribed', {\n kind: track.kind,\n identity: p.identity,\n })\n })\n\n // rtcConfig.iceServers:Firefox 默认 mDNS 混淆 host 候选(.local),\n // LiveKit 容器内 DNS 解析不到,导致双向 connectivity check 失败 → ICE failed。\n // 配 STUN 让客户端生成 srflx 候选(真实 IP)兜底,Chrome/Edge 不受影响。\n //\n // iceServers 由 ImClient 从 /client/config 接口拉取后透传,避免硬编码\n // TURN 不在此声明——LiveKit 服务端 turn.enabled=true 时,会在 signaling 阶段\n // 自动下发含凭证的 TURN 服务器(与 LiveKit token 集成认证),SDK 自动合并使用。\n await this.room.connect(this.livekitWs, this.token, {\n rtcConfig: {\n iceServers: this.iceServers,\n },\n })\n this.emitSession('connected', {})\n }\n\n // 开启本地 mic + camera(视频通话时)\n // 关键:每个设备独立 try/catch——摄像头被占用(NotReadableError)或权限拒绝(NotAllowedError)\n // 时只 emit 'devices' 事件携带错误清单,不阻塞通话——音频 + 单向视频仍可用\n async enableDevices(): Promise<{ mic: boolean; camera: boolean; errors: string[] }> {\n const errors: string[] = []\n let micOk = false\n let cameraOk = false\n\n try {\n await this.room.localParticipant.setMicrophoneEnabled(true)\n micOk = true\n } catch (err: any) {\n const msg = err?.message || String(err)\n errors.push(`mic: ${msg}`)\n console.warn('[im] 麦克风开启失败,通话继续(音频不可用)', err)\n }\n\n if (this.callType === 'video') {\n try {\n await this.room.localParticipant.setCameraEnabled(true)\n cameraOk = true\n } catch (err: any) {\n const msg = err?.message || String(err)\n errors.push(`camera: ${msg}`)\n console.warn('[im] 摄像头开启失败,通话继续(视频不可用)', err)\n }\n }\n\n this.emitSession('devices', { mic: micOk, camera: cameraOk, errors })\n return { mic: micOk, camera: cameraOk, errors }\n }\n\n // 把本地视频 track 挂载到 DOM 元素\n attachLocalVideo(el: HTMLVideoElement) {\n const pub = this.room.localParticipant.getTrackPublication(Track.Source.Camera)\n if (pub && pub.track) {\n pub.track.attach(el)\n }\n }\n\n // 把远端视频 track 挂载到 DOM 元素\n // 如果对端已 publish 但本端未订阅(默认自动订阅,但网络抖动可能延迟),触发订阅;\n // 真正订阅完成后通过 'trackSubscribed' 事件触发再次 attach\n attachRemoteVideo(el: HTMLVideoElement) {\n for (const [, p] of this.room.remoteParticipants) {\n const pub = p.getTrackPublication(Track.Source.Camera)\n if (!pub) continue\n if (pub.track) {\n // track 已流入:直接 attach\n pub.track.attach(el)\n return\n }\n // track 未流入:触发订阅,等 trackSubscribed 事件再次 attach\n if (!pub.isSubscribed) {\n pub.setSubscribed(true)\n }\n }\n }\n\n // 从指定元素上 detach 所有 video track(local + remote)\n // 切换大小视频时必须先调此方法,否则 LiveKit 的 track 会同时挂在多个元素上导致画面重复\n detachAllVideoFromEl(el: HTMLVideoElement) {\n if (!el) return\n // local\n const localPub = this.room.localParticipant.getTrackPublication(Track.Source.Camera)\n if (localPub && localPub.track) {\n localPub.track.detach(el)\n }\n // remote\n for (const [, p] of this.room.remoteParticipants) {\n const pub = p.getTrackPublication(Track.Source.Camera)\n if (pub && pub.track) {\n pub.track.detach(el)\n }\n }\n }\n\n // 静音/取消静音本地 mic\n async setMicEnabled(enabled: boolean) {\n await this.room.localParticipant.setMicrophoneEnabled(enabled)\n }\n\n // 关/开本地摄像头\n async setCameraEnabled(enabled: boolean) {\n await this.room.localParticipant.setCameraEnabled(enabled)\n }\n\n // 主动挂断(调后端 /call/end + 断 LiveKit)\n async hangup(transport: Transport, reason: CallEndReason = 'user_left') {\n if (this.ended) return\n this.ended = true\n try {\n await transport.callEnd(this.callId, reason)\n } catch (err) {\n console.error('[im] callEnd 失败', err)\n }\n // 静默断开:抑制 DataChannel/ICE 正常关闭时的 error/warn 噪音(延迟恢复见 livekit-quiet.ts)\n await disconnectSilently(this.room)\n this.emitSession('ended', { reason })\n }\n\n // 远端挂断或网络断开触发的结束(不调后端 /call/end,由 webhook 兜底)\n async end(reason: CallEndReason) {\n if (this.ended) return\n this.ended = true\n await disconnectSilently(this.room)\n this.emitSession('ended', { reason })\n }\n\n // 注册 LiveKit RPC 方法(用于房内实时控制:hangup/ring/mute/switch-audio)\n // v2.20 registerRpcMethod 回调签名是 RpcInvocationData(含 payload/requestId/callerIdentity),\n // 这里只透传 payload 字符串,对调用方屏蔽 SDK 内部类型\n registerRpcMethod(method: string, handler: (payload: string) => Promise<string>) {\n this.room.localParticipant.registerRpcMethod(method, (data: { payload: string }) =>\n handler(data.payload),\n )\n }\n\n // 调远端 RPC(如 hangup 通知对方)\n async performRpc(destinationIdentity: string, method: string, payload: string): Promise<string> {\n return await this.room.localParticipant.performRpc({\n destinationIdentity,\n method,\n payload,\n })\n }\n\n // 私有事件分发\n private emitSession(event: string, payload: unknown) {\n const set = this.sessionListeners.get(event)\n if (!set) return\n for (const fn of set) {\n try {\n fn(payload)\n } catch (err) {\n console.error(`[im] session handler error: ${event}`, err)\n }\n }\n }\n\n onSession(\n event:\n | 'connected'\n | 'remoteConnected'\n | 'remoteDisconnected'\n | 'remoteTrackSubscribed'\n | 'ended'\n | 'devices',\n handler: (payload: any) => void,\n ) {\n if (!this.sessionListeners.has(event)) {\n this.sessionListeners.set(event, new Set())\n }\n this.sessionListeners.get(event)!.add(handler)\n }\n\n // 当前 LiveKit room 是否已连接(用于 CallWindow 类组件 onMounted 时判断是否错过了 connected 事件)\n get isConnected(): boolean {\n return this.room.state === 'connected'\n }\n\n getToken(): string {\n return this.token\n }\n private token!: string\n}\n\n// CallController:1:1 通话编排\nexport class CallController {\n private transport: Transport\n private livekitWs: string\n private identity: string\n private iceServers: IceServer[]\n currentSession: CallSession | null = null\n\n constructor(transport: Transport, livekitWs: string, identity: string, iceServers: IceServer[]) {\n this.transport = transport\n this.livekitWs = livekitWs\n this.identity = identity\n this.iceServers = iceServers\n }\n\n // 主叫发起:调后端 /call/init + 创建 session(不 connect)\n // 主叫方此时显示\"呼叫等待\"弹窗(CallOutgoing),收到 call:accepted 事件后再 connect + enableDevices\n // 参数 to 为被叫 externalId(业务方用户 ID),透传给后端\n async init(to: string, type: CallType): Promise<CallSession> {\n const resp = await this.transport.callInit(to, type)\n const session = new CallSession({\n callId: resp.callId,\n callType: type,\n roomName: resp.roomName,\n identity: this.identity,\n token: resp.token,\n livekitWs: this.livekitWs,\n iceServers: this.iceServers,\n })\n this.currentSession = session\n return session\n }\n\n // 被叫接听:调后端 /call/accept + 入房 + 开设备\n // 被叫在收到 call:invite 后用户点接听时调用,立即入房(不需要等主叫感知)\n // 失败处理:connect 异常时清 currentSession + 调后端 end(user_left) 兜底 + 重新抛错给调用方\n async accept(callId: string, callType: CallType): Promise<CallSession> {\n const resp = await this.transport.callAccept(callId)\n const session = new CallSession({\n callId,\n callType,\n roomName: resp.roomName,\n identity: this.identity,\n token: resp.token,\n livekitWs: this.livekitWs,\n iceServers: this.iceServers,\n })\n this.currentSession = session\n try {\n await session.connect()\n } catch (err) {\n // connect 失败:清本地状态 + 通知后端,避免僵尸会话\n this.currentSession = null\n this.transport.callEnd(callId, 'user_left').catch(() => {})\n throw err\n }\n // 设备开启异步进行——失败不阻塞 accept 返回,UI 通过 onSession('devices') 收到错误清单\n session.enableDevices().catch((err) => {\n console.warn('[im] enableDevices 异常', err)\n })\n return session\n }\n\n // 结束通话\n // reason: 挂断原因;callId: 可选,用于 currentSession 不存在时的拒接/取消场景\n // 关键:被叫拒接(reject)和主叫取消(cancel)时 currentSession 还是 null,\n // 但仍需调后端 /call/end 通知对方——所以 callId 必须显式传\n async end(reason: CallEndReason = 'user_left', callId?: string): Promise<void> {\n if (this.currentSession) {\n await this.currentSession.hangup(this.transport, reason)\n this.currentSession = null\n } else if (callId) {\n // currentSession 不存在(被叫拒接 / 主叫响铃中取消):直接调后端\n try {\n await this.transport.callEnd(callId, reason)\n } catch (err) {\n console.warn('[im] callEnd 失败(不阻塞)', err)\n }\n }\n }\n}\n","import {\n Room,\n Track,\n createLocalAudioTrack,\n createLocalVideoTrack,\n setLogLevel,\n type LocalAudioTrack,\n type LocalVideoTrack,\n type Participant,\n type TrackPublication,\n type RemoteTrackPublication,\n} from 'livekit-client'\n\nimport { EventEmitter } from './events'\nimport { disconnectSilently, silenceLiveKit } from './livekit-quiet'\nimport type { Transport } from './transport'\nimport type {\n CreateMeetingResp,\n MeetingMember,\n ListMeetingMembersResp,\n PendingInvite,\n ClientConfig,\n} from '@quitting/contract'\n\n// LiveKit 日志级别:默认 warn,抑制 trace/debug/info 噪音(ICE 候选、信令重连等)\n// DataChannel 在 room 断开时打印的 \"User-Initiated Abort\" 是 LiveKit 正常行为,\n// 断开期间由 disconnectSilently 临时降级 silent 并延迟恢复(见 livekit-quiet.ts)\nsetLogLevel('warn')\n\n// ICE 服务器条目类型(与 RTCIceServer 对齐,但简化为 SDK 实际使用的字段)\ntype IceServer = ClientConfig['iceServers'][number]\n\n// MeetingSession:单次会议会话,封装 livekit-client Room(多人)\n// 与 CallSession 同样的范式:connect 只入房,enableDevices 单独管理,设备失败不阻塞\nexport class MeetingSession extends EventEmitter {\n public meetingId: string\n public title: string\n public roomName: string\n public identity: string\n public isHost: boolean\n // 当前主持人 uid——主视频画面据此切换;host_changed 事件触发时由 SDK 更新\n public hostIdentity: string\n\n private room: Room\n private livekitWs: string\n private token: string\n private iceServers: IceServer[]\n private ended = false\n private sessionListeners = new Map<string, Set<Function>>()\n // 成员列表缓存:8 秒内复用,避免 UI 高频刷新打爆后端\n // MeetWindow 定时器每 10 秒拉一次,8 秒 TTL 保证轮询时缓存已过期、真正发请求;\n // 事件触发的 force refresh 不受此 TTL 限制\n private membersCache: MeetingMember[] = []\n private membersCacheAt = 0\n private static MEMBERS_CACHE_TTL = 8000\n // 待 attach 的元素缓存:identity → video 元素\n // 解决竞态:UI 元素就绪时远端 track 还没到,attachParticipantVideo 触发订阅后 return;\n // track 到达时从 pendingAttach 取出元素直接 attach,不依赖 UI 重试\n private pendingAttach = new Map<string, HTMLVideoElement>()\n // 预获取的本地媒体 track(prepareLocalTracks 与 room.connect 并行获取),\n // enableDevices 时直接 publish 免二次 getUserMedia;connect 失败时 stop 释放设备\n private preparedMic: LocalAudioTrack | null = null\n private preparedCamera: LocalVideoTrack | null = null\n\n constructor(opts: {\n meetingId: string\n title: string\n roomName: string\n identity: string\n token: string\n isHost: boolean\n hostIdentity: string\n livekitWs: string\n iceServers: IceServer[]\n }) {\n super()\n this.meetingId = opts.meetingId\n this.title = opts.title\n this.roomName = opts.roomName\n this.identity = opts.identity\n this.token = opts.token\n this.isHost = opts.isHost\n this.hostIdentity = opts.hostIdentity\n this.livekitWs = opts.livekitWs\n this.iceServers = opts.iceServers\n // Room 构造只放 RoomOptions 字段(adaptiveStream / dynacast 等)。\n // rtcConfig 属于 RoomConnectOptions,要传给 room.connect(),不能放构造参数——\n // livekit-client@2.20 起 TS 类型严格校验,构造里写 rtcConfig 会报 TS2353。\n this.room = new Room({\n adaptiveStream: true,\n dynacast: true,\n })\n }\n\n // 当前 room 是否已连接(UI 判断 connect 是否在 mount 之前完成)\n get isConnected(): boolean {\n return this.room.state === 'connected'\n }\n\n // 入房(不开设备):仅建立 LiveKit 信令 + 注册事件\n async connect() {\n this.room\n .on('participantConnected', (p: Participant) => {\n // 远端入会:若 UI 早已把元素存入 pendingAttach,触发 camera 订阅,\n // 真正 attach 由 trackSubscribed 处理器完成\n if (this.pendingAttach.has(p.identity)) {\n const pub = p.getTrackPublication(Track.Source.Camera) as\n | RemoteTrackPublication\n | undefined\n if (pub && !pub.isSubscribed) pub.setSubscribed(true)\n }\n this.emitSession('participantJoined', { identity: p.identity })\n })\n .on('participantDisconnected', (p: Participant) => {\n this.emitSession('participantLeft', { identity: p.identity, reason: 'leave' })\n })\n .on('disconnected', () => {\n if (!this.ended) this.end()\n })\n // 关键:对端 track 异步流入——participantConnected 时 track 还没到,必须等此事件才能 attach\n .on('trackSubscribed', (track: Track, _pub: TrackPublication, p: Participant) => {\n if (track.kind === 'video') {\n // video track:检查 pendingAttach 缓存,元素早就绪但 track 没到的场景在此补 attach\n const el = this.pendingAttach.get(p.identity)\n if (el) {\n track.attach(el)\n this.pendingAttach.delete(p.identity)\n }\n } else if (track.kind === Track.Kind.Audio) {\n // audio track:显式 attach 到隐藏元素,确保播放(LiveKit 默认自动播放,\n // 但多 Room 场景或浏览器自动播放策略可能阻止,显式 attach 最可靠)\n track.attach()\n }\n this.emitSession('participantTrackSubscribed', {\n kind: track.kind,\n identity: p.identity,\n })\n })\n // 发言检测:LiveKit 给当前正在发言的全量参与者列表(含本地,如本端在说话)\n // 后端 ListMembers 的 speaking 永远 false,UI 端靠此事件维护本地态\n .on('activeSpeakersChanged', (speakers: Participant[]) => {\n const identities = speakers.map((p) => p.identity)\n this.emitSession('activeSpeakersChanged', { identities })\n })\n // 远端麦克风/摄像头开/关:LiveKit 在 mute 瞬间广播,驱动 UI 立即更新\n // members[].muted / cameraOn(此前仅靠 10s 轮询后端 ListParticipants 快照,\n // 最坏延迟一个轮询周期)。kind 区分音/视频,UI 按字段分流。\n // 仅对远端参与者触发——本端自己 mute 不走此事件,由 UI 在 toggleMic/toggleCamera 后本地更新\n .on('trackMuted', (pub: TrackPublication, p: Participant) => {\n if (pub.source === Track.Source.Microphone) {\n this.emitSession('participantMuteChanged', {\n identity: p.identity,\n muted: true,\n kind: 'audio',\n })\n } else if (pub.source === Track.Source.Camera) {\n this.emitSession('participantMuteChanged', {\n identity: p.identity,\n muted: true,\n kind: 'video',\n })\n }\n })\n .on('trackUnmuted', (pub: TrackPublication, p: Participant) => {\n if (pub.source === Track.Source.Microphone) {\n this.emitSession('participantMuteChanged', {\n identity: p.identity,\n muted: false,\n kind: 'audio',\n })\n } else if (pub.source === Track.Source.Camera) {\n this.emitSession('participantMuteChanged', {\n identity: p.identity,\n muted: false,\n kind: 'video',\n })\n }\n })\n\n // rtcConfig.iceServers:Firefox 默认 mDNS 混淆 host 候选(.local),\n // LiveKit 容器内 DNS 解析不到,导致双向 connectivity check 失败 → ICE failed。\n // 配 STUN 让客户端生成 srflx 候选(真实 IP)兜底,Chrome/Edge 不受影响。\n //\n // iceServers 由 ImClient 从 /client/config 接口拉取后透传,避免硬编码\n // TURN 不在此声明——LiveKit 服务端 turn.enabled=true 时,会在 signaling 阶段\n // 自动下发含凭证的 TURN 服务器(与 LiveKit token 集成认证),SDK 自动合并使用。\n await this.room.connect(this.livekitWs, this.token, {\n rtcConfig: {\n iceServers: this.iceServers,\n },\n })\n // connect 后用 LiveKit 的真实 identity 覆盖构造传入值\n // (token 里用的是 externalId,room.localParticipant.identity 即 externalId)\n // 这样后续匹配 participant identity、hostIdentity 全部统一在 externalId 体系\n const realIdentity = this.room.localParticipant.identity\n if (realIdentity) {\n this.identity = realIdentity\n if (!this.hostIdentity) this.hostIdentity = realIdentity\n }\n this.emitSession('connected', {})\n }\n\n // 预获取本地媒体:与 room.connect 并行执行(getUserMedia 授权+采集 1-3s,\n // WebRTC 握手 2-5s,并行可显著缩短入会后出视频的时间)。\n // 单设备失败静默——enableDevices 会走 setXXXEnabled 兜底并收集错误清单\n async prepareLocalTracks(): Promise<void> {\n try {\n this.preparedMic = await createLocalAudioTrack()\n } catch {\n this.preparedMic = null\n }\n try {\n this.preparedCamera = await createLocalVideoTrack()\n } catch {\n this.preparedCamera = null\n }\n }\n\n // 释放预获取但未发布的 track(connect 失败时调用,避免摄像头指示灯常亮)\n discardPreparedTracks(): void {\n this.preparedMic?.stop()\n this.preparedMic = null\n this.preparedCamera?.stop()\n this.preparedCamera = null\n }\n\n // 开启本地 mic + camera,每个设备独立 try/catch——失败 emit 'devices' 携带错误清单,不阻塞会议\n async enableDevices(): Promise<{ mic: boolean; camera: boolean; errors: string[] }> {\n const errors: string[] = []\n let micOk = false\n let cameraOk = false\n\n try {\n if (this.preparedMic) {\n await this.room.localParticipant.publishTrack(this.preparedMic)\n this.preparedMic = null\n } else {\n await this.room.localParticipant.setMicrophoneEnabled(true)\n }\n micOk = true\n } catch (err: any) {\n errors.push(`mic: ${err?.message || String(err)}`)\n console.warn('[im] 麦克风开启失败,会议继续(音频不可用)', err)\n }\n try {\n if (this.preparedCamera) {\n await this.room.localParticipant.publishTrack(this.preparedCamera)\n this.preparedCamera = null\n } else {\n await this.room.localParticipant.setCameraEnabled(true)\n }\n cameraOk = true\n } catch (err: any) {\n errors.push(`camera: ${err?.message || String(err)}`)\n console.warn('[im] 摄像头开启失败,会议继续(视频不可用)', err)\n }\n\n this.emitSession('devices', { mic: micOk, camera: cameraOk, errors })\n return { mic: micOk, camera: cameraOk, errors }\n }\n\n // 主持人踢人(调后端 API,后端调 LiveKit RemoveParticipant)\n // 参数 uid 为被踢者 externalId(业务方用户 ID),透传给后端\n async kick(transport: Transport, uid: string) {\n await transport.meetingKick(this.meetingId, uid)\n }\n\n // 离开会议\n async leave(transport: Transport) {\n if (this.ended) return\n this.ended = true\n // 先静默再调后端 leave:host 兜底 auto-dissolve 会 DeleteRoom,\n // 服务端会抢在本地 disconnect 之前断开本端连接,DataChannel abort\n // 日志(User-Initiated Abort)会赶在 disconnectSilently 之前漏出\n silenceLiveKit()\n try {\n await transport.meetingLeave(this.meetingId)\n } catch (err) {\n console.error('[im] meetingLeave 失败', err)\n }\n await disconnectSilently(this.room)\n this.emitSession('ended', {})\n }\n\n // 房间被关闭/远端断开触发的结束(不调后端,靠 webhook 兜底)\n async end() {\n if (this.ended) return\n this.ended = true\n await disconnectSilently(this.room)\n this.emitSession('ended', {})\n }\n\n // 列出当前在线参与者(实时态)\n getRemoteParticipants(): RemoteParticipantInfo[] {\n const list: RemoteParticipantInfo[] = []\n for (const [, p] of this.room.remoteParticipants) {\n list.push({\n identity: p.identity,\n audioEnabled: p.isMicrophoneEnabled,\n videoEnabled: p.isCameraEnabled,\n isSpeaking: p.isSpeaking,\n })\n }\n return list\n }\n\n // 把本地视频 track 挂载到 DOM 元素\n // 如果 track 还没 publish(enableDevices 是异步的),注册一次性 LocalParticipant.on('localTrackPublished')\n // 监听,等 Camera track publish 后再 attach——避免 host 挂载主视频位时因竞态显示黑屏\n attachLocalVideo(el: HTMLVideoElement) {\n const tryAttach = () => {\n const pub = this.room.localParticipant.getTrackPublication(Track.Source.Camera)\n if (pub && pub.track) {\n pub.track.attach(el)\n return true\n }\n return false\n }\n if (tryAttach()) return\n // track 未就绪:等 localTrackPublished 事件\n const handler = (pub: TrackPublication) => {\n if (pub.source === Track.Source.Camera && pub.track) {\n pub.track.attach(el)\n this.room.localParticipant.off('localTrackPublished', handler)\n }\n }\n this.room.localParticipant.on('localTrackPublished', handler)\n }\n\n // 把指定参与者的视频 track 挂载到 DOM 元素\n // 解竞态:远端未入会 / 没有 publication / track 未流入 时,缓存元素到 pendingAttach;\n // track 到达时(trackSubscribed 或 participantConnected 后)补 attach,不依赖 UI 重试\n attachParticipantVideo(identity: string, el: HTMLVideoElement) {\n const p = this.room.remoteParticipants.get(identity)\n if (!p) {\n // 远端还没入会:缓存元素,等 participantConnected + trackSubscribed\n this.pendingAttach.set(identity, el)\n return\n }\n const pub = p.getTrackPublication(Track.Source.Camera) as RemoteTrackPublication | undefined\n if (!pub) {\n // 已入会但还没 publish camera:缓存元素,等 trackSubscribed\n this.pendingAttach.set(identity, el)\n return\n }\n if (pub.track) {\n pub.track.attach(el)\n this.pendingAttach.delete(identity)\n return\n }\n // publication 存在但 track 未流入(订阅中):缓存元素 + 确保订阅\n this.pendingAttach.set(identity, el)\n if (!pub.isSubscribed) {\n pub.setSubscribed(true)\n }\n }\n\n async setMicEnabled(enabled: boolean) {\n await this.room.localParticipant.setMicrophoneEnabled(enabled)\n }\n async setCameraEnabled(enabled: boolean) {\n await this.room.localParticipant.setCameraEnabled(enabled)\n }\n // 扬声器静音:对当前所有远端 audio track 设 volume=0;新入会成员需重新调用\n // LiveKit 没有\"主静音\"API,这是最贴近的实现;UI 端记本地态即可\n async setSpeakerMuted(muted: boolean) {\n for (const [, p] of this.room.remoteParticipants) {\n for (const [, pub] of p.audioTrackPublications) {\n const track = pub.track\n // 只对 RemoteAudioTrack 调 setVolume;其它 track 类型跳过\n if (track && track.kind === Track.Kind.Audio) {\n ;(track as any).setVolume(muted ? 0 : 1)\n }\n }\n }\n }\n\n // 拉取会议全量成员(含已邀请未加入者)+ 更新当前 hostIdentity\n // 2 秒缓存避免 UI 高频刷新打爆后端;强制刷新时 force=true 跳过缓存\n async refreshMembers(transport: Transport, force = false): Promise<MeetingMember[]> {\n const now = Date.now()\n if (\n !force &&\n now - this.membersCacheAt < MeetingSession.MEMBERS_CACHE_TTL &&\n this.membersCache.length > 0\n ) {\n return this.membersCache\n }\n const resp: ListMeetingMembersResp = await transport.meetingMembers(this.meetingId)\n this.membersCache = resp.members || []\n this.membersCacheAt = now\n if (resp.hostUid && resp.hostUid !== this.hostIdentity) {\n this.hostIdentity = resp.hostUid\n }\n return this.membersCache\n }\n\n // 同步返回缓存中的成员列表(无网络请求);UI 高频刷新用这个\n // 缓存过期时返回空数组,UI 不应该 crash——上层应同时调 refreshMembers 拉新数据\n getMembers(): MeetingMember[] {\n return this.membersCache\n }\n\n // 主持人转让:仅当前 isHost=true 可调;后端广播后所有客户端通过 hostChanged 事件感知\n // 参数 newHostUid 为新主持人 externalId(业务方用户 ID),透传给后端\n async transferHost(transport: Transport, newHostUid: string): Promise<void> {\n await transport.meetingTransferHost(this.meetingId, newHostUid)\n // 乐观本地更新——broadcast 可能延迟 1-2 秒\n this.hostIdentity = newHostUid\n this.emitSession('hostChanged', { hostUid: newHostUid })\n }\n\n onSession(\n event:\n | 'connected'\n | 'ended'\n | 'participantJoined'\n | 'participantLeft'\n | 'participantTrackSubscribed'\n | 'participantMuteChanged'\n | 'activeSpeakersChanged'\n | 'devices'\n | 'hostChanged'\n | 'membersChanged',\n handler: (payload: any) => void,\n ) {\n if (!this.sessionListeners.has(event)) {\n this.sessionListeners.set(event, new Set())\n }\n this.sessionListeners.get(event)!.add(handler)\n }\n\n private emitSession(event: string, payload: unknown) {\n const set = this.sessionListeners.get(event)\n if (!set) return\n for (const fn of set) {\n try {\n fn(payload)\n } catch (err) {\n console.error(`[im] session handler error: ${event}`, err)\n }\n }\n }\n}\n\nexport interface RemoteParticipantInfo {\n identity: string\n audioEnabled: boolean\n videoEnabled: boolean\n isSpeaking: boolean\n}\n\n// MeetingController:会议编排\nexport class MeetingController {\n private transport: Transport\n private livekitWs: string\n private identity: string\n private iceServers: IceServer[]\n currentSession: MeetingSession | null = null\n\n constructor(transport: Transport, livekitWs: string, identity: string, iceServers: IceServer[]) {\n this.transport = transport\n this.livekitWs = livekitWs\n this.identity = identity\n this.iceServers = iceServers\n }\n\n // 主持人创建会议\n // inviteeUids 为被邀请者 externalId 数组(业务方用户 ID),透传给后端\n // connect 失败时清 currentSession + 调后端 leave 兜底,避免僵尸会议\n async create(\n inviteeUids: string[],\n title: string,\n ): Promise<{ meetingId: string; session: MeetingSession; busyInvitees?: string[] }> {\n const resp: CreateMeetingResp = await this.transport.meetingCreate({ inviteeUids, title })\n const session = new MeetingSession({\n meetingId: resp.meetingId,\n title,\n roomName: resp.roomName,\n identity: this.identity, // 占位,connect 后会被 room.localParticipant.identity(externalId)覆盖\n token: resp.token,\n isHost: true,\n hostIdentity: resp.hostUid || this.identity, // hostUid 是 externalId(后端已转)\n livekitWs: this.livekitWs,\n iceServers: this.iceServers,\n })\n this.currentSession = session\n try {\n // 设备获取与 WebRTC 连接并行:串行时入会出视频要等 connect+getUserMedia 叠加延迟\n const prep = session\n .prepareLocalTracks()\n .catch((err) => console.warn('[im] 设备预获取失败', err))\n await session.connect()\n await prep\n session.enableDevices().catch((err) => console.warn('[im] enableDevices 异常', err))\n } catch (err) {\n this.currentSession = null\n session.discardPreparedTracks()\n this.transport.meetingLeave(resp.meetingId).catch(() => {})\n throw err\n }\n return { meetingId: resp.meetingId, session, busyInvitees: resp.busyInvitees }\n }\n\n // 普通参与者入会\n async join(meetingId: string, title: string = ''): Promise<MeetingSession> {\n const resp = await this.transport.meetingJoin(meetingId)\n const session = new MeetingSession({\n meetingId,\n title,\n roomName: resp.roomName,\n identity: this.identity,\n token: resp.token,\n isHost: false,\n hostIdentity: resp.hostUid || '',\n livekitWs: this.livekitWs,\n iceServers: this.iceServers,\n })\n this.currentSession = session\n try {\n // 与 create 同模式:设备获取与连接并行,缩短入会后出视频的时间\n const prep = session\n .prepareLocalTracks()\n .catch((err) => console.warn('[im] 设备预获取失败', err))\n await session.connect()\n await prep\n session.enableDevices().catch((err) => console.warn('[im] enableDevices 异常', err))\n } catch (err) {\n this.currentSession = null\n session.discardPreparedTracks()\n this.transport.meetingLeave(meetingId).catch(() => {})\n throw err\n }\n return session\n }\n\n // 接受/拒绝邀请\n // accept 时后端 Respond 内部完成 Join 流程并返回 token,SDK 直接建 session 连 LiveKit\n // 不再让调用方显式调 join(之前 respond 内部已调 + SDK 再调 join 导致双重 Join + RowsAffected=0 bug)\n // reject 时返回 null\n async respond(\n meetingId: string,\n action: 'accept' | 'reject',\n title?: string,\n ): Promise<MeetingSession | null> {\n const resp = await this.transport.meetingRespond(meetingId, action)\n if (action !== 'accept' || !resp?.token || !resp?.roomName) {\n return null\n }\n const session = new MeetingSession({\n meetingId,\n title: resp.title || title || '',\n roomName: resp.roomName,\n identity: this.identity,\n token: resp.token,\n isHost: false,\n hostIdentity: resp.hostUid || '',\n livekitWs: this.livekitWs,\n iceServers: this.iceServers,\n })\n this.currentSession = session\n try {\n // 与 create 同模式:设备获取与连接并行,缩短入会后出视频的时间\n const prep = session\n .prepareLocalTracks()\n .catch((err) => console.warn('[im] 设备预获取失败', err))\n await session.connect()\n await prep\n session.enableDevices().catch((err) => console.warn('[im] enableDevices 异常', err))\n } catch (err) {\n this.currentSession = null\n session.discardPreparedTracks()\n this.transport.meetingLeave(meetingId).catch(() => {})\n throw err\n }\n return session\n }\n\n async leave() {\n if (this.currentSession) {\n await this.currentSession.leave(this.transport)\n this.currentSession = null\n }\n }\n\n // 解散会议:仅当前 host 可调;后端广播后所有客户端通过 meeting:dissolved 事件感知\n // 返回 true 表示已解散;false 表示未触发(如非 host)\n async dissolve(): Promise<boolean> {\n if (!this.currentSession) return false\n const session = this.currentSession\n // 先静默再调后端(与 leave 同模式):Dissolve 会立即 DeleteRoom,服务端\n // 抢在 session.end() 的 disconnectSilently 之前强断 host 连接,\n // DataChannel abort 日志(User-Initiated Abort)会漏出\n silenceLiveKit()\n await this.transport.meetingDissolve(session.meetingId)\n // 本地立即清理 currentSession(不等 broadcast 回来)\n this.currentSession = null\n // 主动静默断开 room:后端 DeleteRoom 会强断 host 连接,被踢端会打出\n // 一串 DataChannel/publisher 错误日志(User-Initiated Abort / createOffer on\n // closed pc)——本地先静默断开,不等 server 踢\n await session.end().catch(() => {})\n return true\n }\n\n // 查 LiveKit 房间内当前参与者数量\n async getParticipantCount(): Promise<number> {\n if (!this.currentSession) return 0\n const resp = await this.transport.meetingParticipantCount(this.currentSession.meetingId)\n return resp.count\n }\n\n // 拉取当前 uid 的待处理邀请——SDK 在 connect 末尾调一次\n // 每条 PendingInvite 由调用方转成 meeting:invite 事件 emit 出去,UI 收到后弹邀请窗\n async fetchPendingInvites(): Promise<PendingInvite[]> {\n try {\n const resp = await this.transport.meetingPendingInvites()\n return resp.invites || []\n } catch (err) {\n console.warn('[im] fetchPendingInvites 失败', err)\n return []\n }\n }\n\n // 主持人邀请参会成员:无邀请记录的新成员走首次邀请;已有记录的(invited/left/removed)走重邀\n // 参数 uid 为目标用户 externalId(业务方用户 ID),透传给后端\n async reinvite(meetingId: string, uid: string): Promise<void> {\n await this.transport.meetingReinvite(meetingId, uid)\n }\n\n // 拉取成员列表——转发到 currentSession,挂载时调用方应主动 refresh\n async getMembers(force = false): Promise<MeetingMember[]> {\n if (!this.currentSession) return []\n return this.currentSession.refreshMembers(this.transport, force)\n }\n}\n","import { Transport } from './transport'\nimport { RealtimeConnection } from './realtime'\nimport type { Message, SendMessageReq, SendMessageResp, Conversation } from '@quitting/contract'\n\n// MessageController:消息发送/历史/已读/撤回\n//\n// ID 体系说明(externalId 改造后):\n// - 业务方调 send({to})/openDialog(peerUid) 传的都是 externalId\n// - SDK 内部拼 dialog#<a>,<b> 通道必须用 UUID(与 server 端一致,且 Centrifugo user_boundary 校验)\n// - 因此 SDK 维护 externalId → UUID 缓存,首次用某 peer 时查询后端 /users/by-external\nexport class MessageController {\n private transport: Transport\n private realtime: RealtimeConnection\n private uid: string // 自己的 UUID(订阅 personal# / dialog# 用)\n // externalId → UUID 缓存(避免每次发消息都查后端)\n private peerUidCache = new Map<string, string>()\n\n constructor(transport: Transport, realtime: RealtimeConnection, uid: string) {\n this.transport = transport\n this.realtime = realtime\n this.uid = uid\n }\n\n // resolvePeerUUID 把 peer 的 externalId 解析成 UUID(拼 dialog# 通道用)\n // 命中缓存直接返回;否则批量查后端\n private async resolvePeerUUID(peerExternalId: string): Promise<string> {\n const cached = this.peerUidCache.get(peerExternalId)\n if (cached) return cached\n const resp = await this.transport.usersByExternal([peerExternalId])\n const u = resp.users[peerExternalId]\n if (!u || !u.uid) {\n throw new Error(`peer not found: ${peerExternalId}`)\n }\n this.peerUidCache.set(peerExternalId, u.uid)\n return u.uid\n }\n\n // 发送文字消息\n // 参数 params.to 为收件人 externalId(业务方用户 ID),透传给后端\n async send(params: {\n to: string\n type: Message['type']\n body?: string\n mediaId?: string\n replyToId?: string\n }): Promise<SendMessageResp> {\n const req: SendMessageReq = {\n to: params.to,\n type: params.type,\n body: params.body,\n clientMid: crypto.randomUUID(),\n mediaId: params.mediaId,\n replyToId: params.replyToId,\n }\n const resp = await this.transport.sendMessage(req)\n // 立即 ACK 自己(消息已写入 MySQL 即视为送达)\n await this.transport.ackMessage(resp.msgId).catch(() => {})\n return resp\n }\n\n // 发送媒体消息(内部:获取预签名 URL → 直传 → 发消息)\n async sendMedia(\n params: {\n to: string\n file: File\n type: 'image' | 'voice' | 'video' | 'file'\n },\n uploader: { upload: (file: File, contentType: string) => Promise<{ mediaId: string }> },\n ): Promise<SendMessageResp> {\n const { mediaId } = await uploader.upload(params.file, params.file.type)\n return await this.send({\n to: params.to,\n type: params.type,\n mediaId,\n })\n }\n\n // 拉历史(按 seq 增量)\n async history(conversationId: string, sinceSeq = 0, limit = 50): Promise<Message[]> {\n const resp = await this.transport.listMessages(conversationId, sinceSeq, limit)\n return resp.messages as Message[]\n }\n\n // 客户端 ACK(消息必达三层保证)\n async ack(messageId: string) {\n await this.transport.ackMessage(messageId)\n }\n\n // 标记已读(按 seq 范围)\n async markRead(conversationId: string, lastReadSeq: number) {\n await this.transport.markRead(conversationId, lastReadSeq)\n }\n\n // 撤回消息(限时 2 分钟)\n async revoke(messageId: string) {\n await this.transport.revokeMessage(messageId)\n }\n\n // 输入指示器(走 Centrifugo 客户端直接 publish,allow_publish_for_subscriber)\n // to 为 peer externalId,内部解析成 UUID 拼通道;from 用 externalId(与 server payload 一致)\n async typing(to: string, isTyping: boolean) {\n const peerUUID = await this.resolvePeerUUID(to)\n const channel = this.dialogChannel(peerUUID)\n // from 应为 externalId 让对端识别;但 SDK 自己只有 UUID,用 dialogChannel 里 peerUUID 反查不到自己 externalId\n // 简化:from 留空或用 UUID,typing 是辅助功能,对端可忽略 from\n await this.realtime.publish(channel, { type: 'typing', from: '', isTyping })\n }\n\n // 会话列表\n async listConversations(): Promise<Conversation[]> {\n const resp = await this.transport.listConversations()\n return resp.conversations as Conversation[]\n }\n\n // 打开与 peer 的 1:1 会话通道(订阅 dialog#<UUID_A>,<UUID_B>,UUID 升序)\n // 业务方传 peer externalId,SDK 内部解析成 UUID 拼 channel(与 server dialogChannel 一致)\n // 内部走 realtime 引用计数:同一 peer 多次 openDialog 只首次订阅,closeDialog 归零才退订\n // 业务方在打开聊天面板时调用、关闭时调 closeDialog\n async openDialog(peerExternalId: string): Promise<void> {\n const peerUUID = await this.resolvePeerUUID(peerExternalId)\n const channel = this.dialogChannel(peerUUID)\n await this.realtime.subscribe(channel)\n }\n\n // 关闭与 peer 的 1:1 会话通道(引用计数 -1,归零退订)\n closeDialog(peerExternalId: string): void {\n // 异步解析但不阻塞退订;命中缓存则精准退订,未命中(极少见)忽略\n const peerUUID = this.peerUidCache.get(peerExternalId)\n if (!peerUUID) return\n const channel = this.dialogChannel(peerUUID)\n this.realtime.unsubscribe(channel)\n }\n\n // 计算 dialog 通道名(UUID 升序),与后端 signaling.message.dialogChannel 一致\n // 必须用 UUID 拼(Centrifugo user_boundary 校验 + 与 server 通道名一致)\n private dialogChannel(peerUUID: string): string {\n const a = [this.uid, peerUUID].sort()[0]\n const b = [this.uid, peerUUID].sort()[1]\n return `dialog#${a},${b}`\n }\n}\n","import { Transport } from './transport'\n\n// MediaUploader:媒体上传(预签名直传 + MD5 秒传)\n// 阶段 5 简化版:后端 /media/upload-url 返回占位 uploadUrl\n// 真实接入 MinIO 后会返回真正的预签名 PUT URL\nexport class MediaUploader {\n private transport: Transport\n\n constructor(transport: Transport) {\n this.transport = transport\n }\n\n // 计算文件 MD5(浏览器 SubtleCrypto)\n async computeMD5(file: File | Blob): Promise<string> {\n // SubtleCrypto 只支持 SHA-1/256/384/512,不支持 MD5\n // 这里用 SHA-256 作为秒传指纹(后端 media_objects.md5 字段语义改为\"任意 hash\")\n const buf = await file.arrayBuffer()\n const hash = await crypto.subtle.digest('SHA-256', buf)\n return Array.from(new Uint8Array(hash))\n .map((b) => b.toString(16).padStart(2, '0'))\n .join('')\n }\n\n // 上传文件:1) 检查秒传 2) 申请预签名 URL 3) 直传对象存储\n async upload(file: File, contentType?: string): Promise<{ mediaId: string }> {\n const md5 = await this.computeMD5(file)\n\n // 1. 秒传检查\n const checkResp = await this.transport.checkMd5(md5)\n if (checkResp.hit && checkResp.mediaId) {\n return { mediaId: checkResp.mediaId }\n }\n\n // 2. 申请 uploadUrl\n const uploadResp = await this.transport.getUploadUrl({\n fileName: file.name,\n contentType: contentType || file.type,\n fileSize: file.size,\n md5,\n })\n\n // 3. 直传对象存储(PUT 预签名 URL)\n // 阶段 5 后端返回的是占位 URL(\"/media/upload/<mediaId>\"),实际不可用\n // TODO phase-6:后端接入 MinIO 后会返回真实预签名 PUT URL\n if (uploadResp.uploadUrl && uploadResp.uploadUrl.startsWith('http')) {\n const resp = await fetch(uploadResp.uploadUrl, {\n method: 'PUT',\n body: file,\n headers: { 'Content-Type': contentType || file.type },\n })\n if (!resp.ok) {\n throw new Error(`上传对象存储失败: HTTP ${resp.status}`)\n }\n }\n\n return { mediaId: uploadResp.mediaId }\n }\n}\n","import { Transport } from './transport'\n\n// 用户目录:externalId ↔ uid 双向映射 + 展示信息(name/dept/avatar)+ 在线状态\n// 用途:UI 组件缺省数据源——选人弹窗默认拉全量列表、CallDialog/MeetingInvite 的\n// uid→姓名 解析在业务方未传 prop 时都走这里(业务方传 prop 的优先,见各组件)\n// presence:update 事件(uid 为 externalId)到达时增量更新 online,避免整表轮询\n\nexport interface ImUser {\n uid: string // IM 内部 UUID\n externalId: string // 业务方用户 ID\n name: string\n deptName?: string\n avatarUrl?: string\n online?: boolean\n}\n\nexport class UserDirectory {\n private transport: Transport\n private byExternal = new Map<string, ImUser>()\n private byUid = new Map<string, ImUser>()\n private loaded = false\n // list 进行中的 Promise 合并:并发 resolve() 只发一次请求\n private loading: Promise<void> | null = null\n\n constructor(transport: Transport) {\n this.transport = transport\n }\n\n // 全量列表(默认走缓存;force=true 强刷,用于 presence 兜底轮询场景)\n async list(force = false): Promise<ImUser[]> {\n if (this.loaded && !force) return [...this.byExternal.values()]\n if (!this.loading) {\n this.loading = this.transport\n .listUsers()\n .then((resp) => {\n for (const u of resp.users || []) this.upsert(u)\n this.loaded = true\n })\n .finally(() => {\n this.loading = null\n })\n }\n await this.loading\n return [...this.byExternal.values()]\n }\n\n // 按 externalId 或内部 uid 解析展示信息(缓存 miss 时拉一次全量再查;仍无则 undefined)\n async resolve(id: string): Promise<ImUser | undefined> {\n const cached = this.byExternal.get(id) || this.byUid.get(id)\n if (cached) return cached\n if (this.loaded) return undefined\n await this.list()\n return this.byExternal.get(id) || this.byUid.get(id)\n }\n\n // 写入/更新一条(listUsers 结果与外部补充数据共用)\n upsert(u: ImUser) {\n this.byExternal.set(u.externalId, u)\n this.byUid.set(u.uid, u)\n }\n\n // presence:update 增量更新(e.uid 是 externalId)\n updateOnline(externalId: string, online: boolean) {\n const u = this.byExternal.get(externalId)\n if (u && u.online !== online) u.online = online\n }\n}\n","import type { ImEventName, ClientConfig } from '@quitting/contract'\n\n// realtime → ImClient 事件转发清单\n// RealtimeConnection 是独立 emitter;业务方调 client.on(...) 注册到 ImClient.emitter\n// 不转发的话 cb 永远不触发——这是 SDK 早期实现的疏漏\nconst REALTIME_FORWARDED_EVENTS: ImEventName[] = [\n 'message',\n 'message_preview',\n 'message:revoked',\n 'read_receipt',\n 'typing',\n 'call:invite',\n 'call:accepted',\n 'call:rejected',\n 'call:cancelled',\n 'call:busy',\n 'call:no_answer',\n 'call:ended',\n 'meeting:invite',\n 'meeting:kicked',\n 'meeting:hostChanged',\n 'meeting:dissolved',\n 'group:memberJoined',\n 'group:memberLeft',\n 'group:kicked',\n 'group:announcement',\n 'group:profileChanged',\n 'session:kicked',\n 'connection:state',\n // presence 聚合推送:后端每 10s 扫描在线状态变化,diff 拆成单用户事件逐条转发\n // 业务方 client.on('presence:update', e => { onlineMap[e.uid] = e.online })\n 'presence:update',\n]\n\n// @quitting/core 主入口\n// 业务方使用方式:\n// import { ImClient } from '@quitting/core'\n// const client = new ImClient({ appId, apiBase, centrifugoWs, livekitWs })\n// await client.auth.login(appId, externalId, ClientType.Web)\n// await client.connect()\n// client.on('call:invite', e => { ... })\n// client.call.init(to, 'video').then(session => session.attachLocalVideo(el))\n\nimport { EventEmitter } from './events'\nimport { Transport } from './transport'\nimport { AuthManager } from './auth'\nimport { RealtimeConnection } from './realtime'\nimport { CallController } from './call'\nimport { MeetingController } from './meeting'\nimport { MessageController } from './message'\nimport { MediaUploader } from './media'\nimport { UserDirectory } from './users'\nimport type { SessionState } from '@quitting/contract'\n\nexport interface ImClientOptions {\n appId: string\n apiBase: string // https://api.example.com\n centrifugoWs: string // wss://rt.example.com/connection/websocket\n livekitWs: string // wss://media.example.com 或 ws://localhost:7880\n}\n\n// ImClient 主类(业务方唯一入口)\n// 继承 EventEmitter,所有事件通过 client.on('event', handler) 订阅\nexport class ImClient extends EventEmitter {\n public auth!: AuthManager\n public transport!: Transport\n public realtime!: RealtimeConnection\n public call!: CallController\n public meeting!: MeetingController\n public message!: MessageController\n public media!: MediaUploader\n public users!: UserDirectory\n\n public uid = ''\n public appId: string\n // 会话状态快照:仅在 connect 时从后端 /session/state 同步一次,之后不再更新。\n // 不要用它做实时忙线判断(会滞后)——UI 层应基于事件(call:*/meeting:*)与\n // client.call.currentSession / client.meeting.currentSession 驱动状态机\n public sessionState: SessionState = 'idle'\n // 本端 client_type(1=PC 2=? 3=? 4=Web):session:kicked 只对同端生效,需要比对\n private clientType = 4\n // realtime 监听是否已注册:ImClient 重连(disconnect 后再 connect)时\n // initAfterAuth 会再次执行,闭包每次都是新引用、EventEmitter 的 Set 去重不了,\n // 不加守卫会导致重连后每个事件被重复转发\n private realtimeBound = false\n\n // 客户端运行时配置(/client/config 接口下发),含 iceServers 等\n // SDK 内部通过 getConfig() 读取;拉取失败时为 null,调用方需兜底\n private clientConfig: ClientConfig | null = null\n\n private livekitWs: string\n private apiBase: string\n private name = ''\n private avatar = ''\n\n constructor(opts: ImClientOptions) {\n super()\n this.appId = opts.appId\n this.apiBase = opts.apiBase\n this.livekitWs = opts.livekitWs\n\n // 依赖注入链条\n this.auth = new AuthManager(null as any) // 占位,下面设 transport 后再重建\n this.transport = new Transport(opts.apiBase, () => this.auth.getToken())\n this.auth = new AuthManager(this.transport)\n this.realtime = new RealtimeConnection(opts.centrifugoWs, async () => {\n return await this.auth.getCentrifugoToken(this.name, this.avatar)\n })\n }\n\n // 登录 + 建立 Centrifugo 长连接 + 同步会话状态\n async connect(loginOpts: {\n externalId: string\n clientType?: number\n deviceName?: string\n name?: string\n avatar?: string\n }) {\n if (loginOpts.name) this.name = loginOpts.name\n if (loginOpts.avatar) this.avatar = loginOpts.avatar\n\n // 1. 登录拿 session JWT\n this.clientType = loginOpts.clientType ?? 4\n const loginResp = await this.auth.login(\n this.appId,\n loginOpts.externalId,\n loginOpts.clientType ?? 4,\n loginOpts.deviceName,\n )\n this.uid = loginResp.uid\n if (loginResp.name) this.name = loginResp.name\n if (loginResp.avatarUrl) this.avatar = loginResp.avatarUrl\n\n // 2-7. 公共初始化(loadClientConfig + Controller + realtime + sessionState + 事件)\n await this.initAfterAuth()\n\n return loginResp\n }\n\n /**\n * 业务方后端已调过 /auth/login 拿到 IM 会话 JWT,前端直接注入。\n * 适用于业务系统有自己的鉴权体系(SSO/OIDC),通过签名后端代理调用 IM 登录的场景。\n *\n * 与 connect() 的区别:跳过 SDK 内部的 auth.login(因为 JWT 已由业务后端换取),\n * 直接用注入的 JWT 初始化后续链路(clientConfig + Controller + Centrifugo + sessionState)。\n *\n * @param opts.jwt IM 会话 JWT(业务后端从 /auth/login 拿到)\n * @param opts.uid 用户 IM 内部 UUID(业务后端从 /auth/login 响应拿到,透传即可)\n * SDK 内部用于订阅 personal#<UUID> / dialog#<UUID> 通道及 getSessionState,\n * 业务方无需解析其语义,原样透传 SDK\n * @param opts.expiresAt JWT 过期时间(Unix 秒)\n * @param opts.name 可选展示名\n * @param opts.avatar 可选头像\n */\n async connectWithExternalJwt(opts: {\n jwt: string\n uid: string\n expiresAt: number\n name?: string\n avatar?: string\n /** 本端 client_type(与业务后端调 /auth/login 时传的一致,默认 4=Web),用于同端互踢过滤 */\n clientType?: number\n }) {\n if (opts.name) this.name = opts.name\n if (opts.avatar) this.avatar = opts.avatar\n this.uid = opts.uid\n this.clientType = opts.clientType ?? 4\n this.auth.setExternalToken(opts.jwt, opts.expiresAt)\n\n // 公共初始化(loadClientConfig + Controller + realtime + sessionState + 事件)\n await this.initAfterAuth()\n }\n\n // connect / connectWithExternalJwt 共用的第 2-7 步初始化\n // 1. loadClientConfig\n // 2. 初始化 4 个 Controller(依赖 uid + iceServers)\n // 3. 连 Centrifugo + 订阅 personal#<uid>\n // 4. 同步 sessionState(被叫会话冲突检测用)\n // 5. 注册 phase-2 业务监听(call:busy/no_answer、meeting:hostChanged/dissolved)\n // 6. 转发 realtime 事件到 ImClient\n // 7. 主动拉取待处理会议邀请\n private async initAfterAuth() {\n // 1. 拉取客户端运行时配置(无鉴权接口,登录前后都能调)\n // 失败时 fallback 空 iceServers——SDK 用浏览器默认 host 候选兜底\n await this.loadClientConfig()\n\n // 2. 初始化各 Controller(依赖 uid + iceServers)\n const iceServers = this.clientConfig?.iceServers ?? []\n this.call = new CallController(this.transport, this.livekitWs, this.uid, iceServers)\n this.meeting = new MeetingController(this.transport, this.livekitWs, this.uid, iceServers)\n this.message = new MessageController(this.transport, this.realtime, this.uid)\n this.media = new MediaUploader(this.transport)\n this.users = new UserDirectory(this.transport)\n\n // 3. 连 Centrifugo + 订阅 personal#<uid>\n await this.realtime.connect(this.uid)\n\n // 4. 同步 sessionState(被叫会话冲突检测用)\n // 不传 uid —— 查自己的状态(后端从 JWT 解析),避免 UUID/externalId 语义混淆\n try {\n const stateResp = await this.transport.getSessionState()\n this.sessionState = stateResp.state\n this.emit('session:state', {\n state: stateResp.state,\n callId: stateResp.id,\n meetingId: stateResp.id,\n })\n } catch (err) {\n console.warn('[im] 同步 sessionState 失败', err)\n }\n\n // 5. 注册 realtime 业务监听 + 6. 转发事件(仅首次——重连时 initAfterAuth 再次执行,\n // 重复注册的是新闭包,会导致事件被重复分发)\n if (!this.realtimeBound) {\n this.realtimeBound = true\n this.realtime.on('call:busy', () => {\n this.call.currentSession = null\n })\n this.realtime.on('call:no_answer', () => {\n this.call.currentSession = null\n })\n // 主持人转让广播:更新 currentSession.hostIdentity,UI 据此切换主视频\n this.realtime.on('meeting:hostChanged', (e: any) => {\n const sess = this.meeting?.currentSession\n if (sess && e?.meetingId === sess.meetingId && e?.hostUid) {\n sess.hostIdentity = e.hostUid\n }\n })\n // 会议解散广播:触发 currentSession.end,UI 显示「会议已结束」蒙层\n // end 后必须清 currentSession(与 call:busy/no_answer 一致)——\n // 残留会导致新会议邀请被「已在会中」判定静默丢弃(刷新页面才恢复弹框)\n this.realtime.on('meeting:dissolved', (e: any) => {\n const sess = this.meeting?.currentSession\n if (sess && e?.meetingId === sess.meetingId) {\n sess\n .end()\n .catch((err: any) => console.warn('[im] dissolved end session 失败', err))\n .finally(() => {\n if (this.meeting?.currentSession === sess) this.meeting.currentSession = null\n })\n }\n })\n // 被主持人踢出:必须主动断开 LiveKit Room,否则窗口虽关但远端 audio track\n // 仍 attach 在隐藏元素上播放——用户会\"还能听到会议声音\"。\n // 与 dissolved 对称:SDK 层兜底断连 + 清 currentSession,业务方只需处理 UI 关窗。\n this.realtime.on('meeting:kicked', (e: any) => {\n const sess = this.meeting?.currentSession\n if (sess && e?.meetingId === sess.meetingId) {\n sess\n .end()\n .catch((err: any) => console.warn('[im] kicked end session 失败', err))\n .finally(() => {\n if (this.meeting?.currentSession === sess) this.meeting.currentSession = null\n })\n }\n })\n // 同端互踢:另一端用同 client_type 登录,本端被动下线。\n // kicked 通知发在用户级通道 personal#<uid>(所有端都收得到),\n // 必须比对 clientType——否则用户重登 Web 会把在线的 PC 端一起误杀\n this.realtime.on('session:kicked', (e: any) => {\n if (e?.clientType !== undefined && e.clientType !== this.clientType) {\n return\n }\n console.warn('[im] 会话被踢(同端新登录),断开连接')\n try {\n this.realtime.disconnect()\n } catch (err) {\n // ignore\n }\n this.auth.logout()\n this.uid = ''\n })\n // presence 增量更新进用户目录(e.uid 为 externalId)\n // 与 forwardRealtimeEvents 的转发并存:转发供业务方订阅,这里供 SDK 内部缓存\n this.realtime.on('presence:update', (e: any) => {\n if (e?.uid) this.users?.updateOnline(e.uid, !!e.online)\n })\n this.forwardRealtimeEvents()\n }\n\n // 7. 主动拉取待处理会议邀请——用户离线期间收到的邀请/重邀都在这里取回\n // 业务方收到 meeting:invite 时自行异步校验后端 pending(应对 recovery 重放 stale publication)\n this.fetchAndEmitPendingInvites()\n }\n\n // 拉取客户端运行时配置(无鉴权)\n // 失败时 clientConfig 置 null,SDK 用浏览器默认 ICE 候选兜底(仅 host 候选,无 srflx)\n // 时序:connect 早期阶段调一次,之后 getConfig() 同步返回\n private async loadClientConfig() {\n if (this.clientConfig) return\n try {\n this.clientConfig = await this.transport.getClientConfig()\n } catch (err) {\n console.warn('[im] 拉取 client/config 失败,使用空 iceServers 兜底', err)\n this.clientConfig = { iceServers: [] }\n }\n }\n\n // 读取已缓存的客户端配置(loadClientConfig 后才有值)\n // 业务方一般用不到——SDK 内部 CallSession/MeetingSession 已消费 iceServers\n public getConfig(): ClientConfig | null {\n return this.clientConfig\n }\n\n // 拉取待处理会议邀请,把每条转成 meeting:invite 事件 emit\n // 由调用方 UI(MeetingInvite 组件)接管展示\n // 业务方收到 meeting:invite 时应异步校验后端 pending(应对 recovery 重放 stale publication)\n private async fetchAndEmitPendingInvites() {\n if (!this.meeting) return\n try {\n const invites = await this.meeting.fetchPendingInvites()\n const currentMeetingId = this.meeting.currentSession?.meetingId\n for (const inv of invites) {\n if (inv.meetingId === currentMeetingId) continue\n ;(this.emit as (event: ImEventName, payload: unknown) => void)('meeting:invite', {\n meetingId: inv.meetingId,\n from: inv.from,\n title: inv.title,\n ts: inv.ts,\n variant: inv.variant,\n reinvite: inv.variant === 'reinvite',\n })\n }\n } catch (err) {\n console.warn('[im] fetchAndEmitPendingInvites 失败', err)\n }\n }\n\n // 把 RealtimeConnection 的所有事件透传到 ImClient 自身的 emitter\n // 业务方调 client.on('message', cb) 时 cb 注册在 ImClient.emitter;realtime.emit 不会触发它\n // 必须显式转发。每次注册的是新闭包,EventEmitter 的 Set 去重不了同一功能的新引用,\n // 因此只在首次 initAfterAuth 时注册(realtimeBound 守卫)\n private forwardRealtimeEvents() {\n for (const name of REALTIME_FORWARDED_EVENTS) {\n this.realtime.on(name, (payload: unknown) => {\n ;(this.emit as (event: ImEventName, payload: unknown) => void)(name, payload)\n })\n }\n }\n\n // 主动断开\n async disconnect() {\n if (this.call?.currentSession) {\n await this.call.end('user_left').catch(() => {})\n }\n if (this.meeting?.currentSession) {\n await this.meeting.leave().catch(() => {})\n }\n this.realtime.disconnect()\n this.auth.logout()\n this.removeAllListeners()\n }\n}\n\n// 重新导出所有公共类型与子模块\nexport { EventEmitter } from './events'\nexport { Transport } from './transport'\nexport { AuthManager } from './auth'\nexport { RealtimeConnection } from './realtime'\nexport { CallController, CallSession } from './call'\nexport { MeetingController, MeetingSession } from './meeting'\nexport { MessageController } from './message'\nexport { MediaUploader } from './media'\nexport { UserDirectory } from './users'\nexport type { ImUser } from './users'\nexport { ApiError } from './api-error'\nexport * from '@quitting/contract'\n"],"mappings":"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"}