import { Drafty, Tinode } from 'tinode-sdk'; export interface CustomerServiceSession { tenant_code: string; api_base_url: string; file_url: string; websocket_url: string; api_key: string; token: string; current_user_id: string; topic: string; } export interface ChatMessage { seq: number; from: string; text: string; sent_at: string; mine: boolean; sending: boolean; failed: boolean; type: 'text' | 'image' | 'video'; media_url?: string; preview_url?: string; file_name?: string; } export type CustomerServiceConnectionState = 'connecting' | 'online' | 'reconnecting' | 'offline'; interface CustomerServiceChatOptions { origin?: string; onConnectionState?: (state: CustomerServiceConnectionState) => void; } export function resolveCustomerServiceEndpoints(session: CustomerServiceSession, origin: string) { const pageOrigin = new URL(origin); const sameOriginURL = (value: string, fallback: string) => { const configured = new URL(value || fallback, pageOrigin); const resolved = new URL(`${configured.pathname}${configured.search}`, pageOrigin); return resolved.toString().replace(/\/$/, ''); }; const websocket = new URL(sameOriginURL(session.websocket_url, '/v0/channels')); websocket.protocol = pageOrigin.protocol === 'https:' ? 'wss:' : 'ws:'; return { apiBaseURL: sameOriginURL(session.api_base_url, '/v1/im'), fileURL: sameOriginURL(session.file_url, '/v0/file'), websocketURL: websocket.toString().replace(/\/$/, ''), }; } export class CustomerServiceChat { private static readonly reconnectPageSize = 50; private static readonly maxReconnectPages = 100; private tinode: any; private topic: any; private currentUserID: string; private onMessages: (messages: ChatMessage[]) => void; private session: CustomerServiceSession; private endpoints: ReturnType; private token = ''; private sessionActive = false; private intentionalDisconnect = false; private recovery: Promise | null = null; private onConnectionState: (state: CustomerServiceConnectionState) => void; constructor( session: CustomerServiceSession, onMessages: (messages: ChatMessage[]) => void, options: CustomerServiceChatOptions = {}, ) { const origin = options.origin || window.location.origin; this.endpoints = resolveCustomerServiceEndpoints(session, origin); const endpoint = new URL(this.endpoints.websocketURL); this.session = session; this.token = session.token; this.currentUserID = session.current_user_id; this.onMessages = onMessages; this.onConnectionState = options.onConnectionState || (() => undefined); this.tinode = new Tinode({ appName: '悦选', host: endpoint.host, apiKey: session.api_key, transport: 'ws', secure: endpoint.protocol === 'wss:', persist: false, }); this.installTenantPatch(session.tenant_code); this.topic = this.tinode.getTopic(session.topic); this.topic.onData = () => this.refreshMessages(); this.tinode.onDisconnect = () => { if (!this.intentionalDisconnect && this.sessionActive) { this.onConnectionState('reconnecting'); } }; this.tinode.onAutoreconnectIteration = (timeoutMs: number) => { if (!this.intentionalDisconnect && this.sessionActive) { this.onConnectionState(timeoutMs >= 0 ? 'reconnecting' : 'offline'); } }; this.tinode.onConnect = () => { if (!this.intentionalDisconnect && this.sessionActive) { return this.restoreAfterReconnect(); } }; } async connect(token: string) { this.intentionalDisconnect = false; this.token = token; this.onConnectionState('connecting'); await this.tinode.connect(); const login = await this.tinode.loginToken(this.token); if (login?.code >= 300) throw new Error('客服会话登录失败'); this.acceptRotatedToken(login); const query = this.topic.startMetaQuery().withData(undefined, undefined, 50).build(); await this.requestMessagePage(() => this.topic.subscribe(query), '客服会话订阅失败'); this.sessionActive = true; this.onConnectionState('online'); this.refreshMessages(); } async send(text: string) { const value = text.trim(); if (!value) return; const message = this.topic.createMessage(value, false); message.head = Object.assign(message.head || {}, { 'x-im-client-mid': clientMID() }); const result = await this.topic.publishDraft(message, Promise.resolve()); if (!result || result.code >= 300 || message._failed || message._fatal) { throw new Error(result?.text || '消息发送失败,请重试'); } } async sendMedia(file: File, type: 'image' | 'video', onProgress?: (progress: number) => void) { const dimensions = await mediaDimensions(file, type); const ref = await this.upload(file, onProgress); const descriptor = { mime: file.type, // Keep the server-issued relative reference. Tinode only includes // relative references in extra.attachments, which lets the server bind // the uploaded file to this message and authorize the recipient. refurl: ref, filename: file.name, size: file.size, width: dimensions.width, height: dimensions.height, duration: dimensions.duration, }; const content = type === 'image' ? Drafty.appendImage(null, descriptor) : Drafty.insertVideo(null, 0, descriptor); const message = this.topic.createMessage(content, false); message.head = Object.assign(message.head || {}, { 'x-im-client-mid': clientMID(), 'x-im-type': type, }); const result = await this.topic.publishDraft(message, Promise.resolve()); if (!result || result.code >= 300 || message._failed || message._fatal) { throw new Error(result?.text || `${type === 'image' ? '照片' : '视频'}发送失败,请重试`); } } disconnect() { this.intentionalDisconnect = true; this.sessionActive = false; this.onConnectionState('offline'); try { this.topic?.leave?.(false); } finally { this.tinode?.disconnect?.(); } } private restoreAfterReconnect() { if (this.recovery) return this.recovery; this.recovery = (async () => { this.onConnectionState('reconnecting'); const login = await this.tinode.loginToken(this.token); if (login?.code >= 300) throw new Error('客服会话重新登录失败'); this.acceptRotatedToken(login); await this.restoreSubscriptionAndMessages(); this.refreshMessages(); this.onConnectionState('online'); })() .catch(() => { this.onConnectionState('offline'); }) .finally(() => { this.recovery = null; }); return this.recovery; } private acceptRotatedToken(login: any) { const rotated = String(login?.params?.token || ''); if (!rotated) return; this.token = rotated; this.session.token = rotated; } private async restoreSubscriptionAndMessages() { const pageSize = CustomerServiceChat.reconnectPageSize; const firstQuery = this.topic.startMetaQuery().withLaterData(pageSize).build(); let count = await this.requestMessagePage(() => this.topic.subscribe(firstQuery), '客服会话重新订阅失败'); for (let page = 1; count >= pageSize; page += 1) { if (page >= CustomerServiceChat.maxReconnectPages) { throw new Error('客服消息积压过多,请重新进入客服会话'); } const previousMax = Number(this.topic.maxMsgSeq?.() || 0); const query = this.topic.startMetaQuery().withLaterData(pageSize).build(); count = await this.requestMessagePage(() => this.topic.getMeta(query), '客服历史消息补拉失败'); const currentMax = Number(this.topic.maxMsgSeq?.() || 0); if (count > 0 && currentMax <= previousMax) { throw new Error('客服历史消息补拉未推进'); } } } private async requestMessagePage(request: () => Promise, errorMessage: string) { let observedCount: number | undefined; let resolveObserved: ((count: number) => void) | undefined; const observed = new Promise((resolve) => { resolveObserved = resolve; }); const previousHandler = this.topic.onAllMessagesReceived; this.topic.onAllMessagesReceived = (count: number) => { previousHandler?.(count); observedCount = Number(count || 0); resolveObserved?.(observedCount); }; let timeout: ReturnType | undefined; try { const response = await request(); if (response?.code >= 300) throw new Error(errorMessage); if (observedCount !== undefined) return observedCount; return await Promise.race([ observed, new Promise((_, reject) => { // A Tinode request promise resolves before the SDK finishes routing // its data packets. Only this completion callback guarantees that // maxMsgSeq and the local message cache belong to the finished page. timeout = setTimeout(() => reject(new Error(errorMessage)), 5_000); }), ]); } finally { if (timeout) clearTimeout(timeout); this.topic.onAllMessagesReceived = previousHandler; } } private installTenantPatch(tenantCode: string) { const connection = this.tinode._connection; if (!connection || connection.__olonsTenantPatched) return; const original = connection.sendText.bind(connection); connection.sendText = (packet: unknown) => { if (typeof packet === 'string' && packet.includes('"hi":')) { try { const parsed = JSON.parse(packet); if (parsed.hi) { parsed.hi.tenant = tenantCode; packet = JSON.stringify(parsed); } } catch { // Preserve the original packet when it is not JSON. } } return original(packet); }; connection.__olonsTenantPatched = true; } private refreshMessages() { const rows: ChatMessage[] = []; this.topic.messages((message: any) => { const parsed = messageContent(message?.content, (ref) => this.mediaURL(ref)); if (!parsed.text && !parsed.media_url) return; rows.push({ seq: Number(message.seq || 0), from: String(message.from || ''), text: parsed.text, sent_at: message.ts ? new Date(message.ts).toISOString() : '', mine: message.from === this.currentUserID, sending: Boolean(message._sending), failed: Boolean(message._failed || message._fatal), type: parsed.type, media_url: parsed.media_url, preview_url: parsed.preview_url, file_name: parsed.file_name, }); }); this.onMessages(rows.sort((a, b) => a.seq - b.seq)); } private async upload(file: File, onProgress?: (progress: number) => void) { const uploadURL = new URL(this.endpoints.fileURL); uploadURL.pathname = `${uploadURL.pathname.replace(/\/$/, '').replace(/\/u$/, '')}/u/`; const form = new FormData(); form.set('id', clientMID()); form.set('file', file, file.name); onProgress?.(0.1); let response: Response; try { // Keep uploads on fetch so API and token credentials stay in headers and // never leak into the URL, browser history, or proxy access logs. response = await fetch(uploadURL.toString(), { method: 'POST', headers: { 'X-MeetingIM-APIKey': this.session.api_key, Authorization: `Token ${this.token}`, }, body: form, }); } catch { throw new Error('媒体上传失败,请检查网络后重试'); } let payload: any; try { payload = await response.json(); } catch { throw new Error('媒体上传响应格式不正确'); } const ref = String(payload?.ctrl?.params?.url || ''); if (!response.ok || !ref) { throw new Error(payload?.ctrl?.text || '媒体上传失败'); } onProgress?.(1); return ref; } private mediaURL(ref?: string) { if (!ref) return ''; const apiBase = new URL(this.endpoints.apiBaseURL); const resolved = new URL(ref, apiBase); // Historical messages may contain the old absolute IM host. Route only // known IM file paths through the current frontend origin; leave unrelated // external media untouched. if (!resolved.pathname.startsWith('/v0/file/') && resolved.origin !== apiBase.origin) return ref; const signable = `${resolved.pathname}${resolved.search}`; const authorized = this.tinode.authorizeURL(signable); return new URL(authorized, this.endpoints.apiBaseURL).toString(); } } function messageContent(content: unknown, mediaURL: (ref?: string) => string) { if (typeof content === 'string') return { text: content, type: 'text' as const }; if (content && typeof content === 'object') { const drafty = content as { txt?: unknown; ent?: Array<{ tp?: string; data?: Record }> }; const media = drafty.ent?.find((entity) => entity?.tp === 'IM' || entity?.tp === 'VD'); if (media?.data) { const type = media.tp === 'VD' ? 'video' : 'image'; return { text: '', type, media_url: mediaURL(String(media.data.ref || '')), preview_url: mediaURL(String(media.data.preref || '')), file_name: String(media.data.name || ''), } as const; } return { text: String(drafty.txt || ''), type: 'text' as const }; } return { text: '', type: 'text' as const }; } function mediaDimensions(file: File, type: 'image' | 'video') { return new Promise<{ width: number; height: number; duration: number }>((resolve, reject) => { const url = URL.createObjectURL(file); const cleanup = () => URL.revokeObjectURL(url); if (type === 'image') { const image = new Image(); image.onload = () => { cleanup(); resolve({ width: image.naturalWidth, height: image.naturalHeight, duration: 0 }); }; image.onerror = () => { cleanup(); reject(new Error('无法读取所选照片')); }; image.src = url; return; } const video = document.createElement('video'); video.preload = 'metadata'; video.onloadedmetadata = () => { cleanup(); resolve({ width: video.videoWidth, height: video.videoHeight, duration: Math.round(video.duration * 1000) }); }; video.onerror = () => { cleanup(); reject(new Error('无法读取所选视频')); }; video.src = url; }); } function clientMID() { return crypto.randomUUID?.() || `mid_${Date.now()}_${Math.random().toString(36).slice(2)}`; }