feat add customer service IM client
This commit is contained in:
@@ -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<string, unknown> }> };
|
||||
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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user