diff --git a/web/src/mobile/im.ts b/web/src/mobile/im.ts new file mode 100644 index 0000000..75815ef --- /dev/null +++ b/web/src/mobile/im.ts @@ -0,0 +1,253 @@ +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 class CustomerServiceChat { + private tinode: any; + private topic: any; + private currentUserID: string; + private onMessages: (messages: ChatMessage[]) => void; + private session: CustomerServiceSession; + + constructor(session: CustomerServiceSession, onMessages: (messages: ChatMessage[]) => void) { + const endpoint = new URL(session.websocket_url); + this.session = session; + this.currentUserID = session.current_user_id; + this.onMessages = onMessages; + this.tinode = new Tinode({ + appName: 'Olons Web', + 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(); + } + + async connect(token: string) { + await this.tinode.connect(); + const login = await this.tinode.loginToken(token); + if (login?.code >= 300) throw new Error('客服会话登录失败'); + const query = this.topic.startMetaQuery().withData(undefined, undefined, 50).build(); + const subscribed = await this.topic.subscribe(query); + if (subscribed?.code >= 300) throw new Error('客服会话订阅失败'); + 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, + // Tinode returns a relative file path. Publish an absolute reference so + // other clients do not resolve it against their own web-app origin. + refurl: new URL(ref, this.session.api_base_url).toString(), + 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() { + try { + this.topic?.leave?.(false); + } finally { + this.tinode?.disconnect?.(); + } + } + + 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.session.file_url || '/v0/file', this.session.api_base_url); + uploadURL.pathname = `${uploadURL.pathname.replace(/\/$/, '').replace(/\/u$/, '')}/u/`; + uploadURL.searchParams.set('apikey', this.session.api_key); + uploadURL.searchParams.set('auth', 'token'); + uploadURL.searchParams.set('secret', this.session.token); + const form = new FormData(); + form.set('id', clientMID()); + form.set('file', file, file.name); + onProgress?.(0.1); + let response: Response; + try { + // Do not attach XMLHttpRequest upload progress listeners here. For a + // cross-origin IM endpoint they trigger a CORS preflight which the file + // service does not expose, while multipart/form-data itself is accepted. + response = await fetch(uploadURL.toString(), { method: 'POST', 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.session.api_base_url); + const resolved = new URL(ref, apiBase); + // The SDK only signs relative file URLs. Convert our absolute IM file + // reference back to a relative path for signing when rendering locally. + const signable = resolved.origin === apiBase.origin + ? `${resolved.pathname}${resolved.search}` + : ref; + const authorized = this.tinode.authorizeURL(signable); + return new URL(authorized, this.session.api_base_url).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)}`; +}