import type { ImMessage, SendMessageParams, ImEventMap } from '../types' import { getConfig, getToken, getUserId } from '../core/sdk' type EventListener = ImEventMap[K] const MAX_RECONNECT_DELAY = 30_000 export class ImClient { private ws: WebSocket | null = null private reconnectDelay = 3_000 private reconnectTimer: ReturnType | null = null private destroyed = false private listeners: { [K in keyof ImEventMap]?: Set> } = {} on(event: K, handler: EventListener): this { const store = this.listeners as Record> const listeners = (store[event] ?? new Set>()) as Set> listeners.add(handler) store[event] = listeners as Set return this } off(event: K, handler: EventListener): this { (this.listeners[event] as Set> | undefined)?.delete(handler) return this } private emit(event: K, ...args: Parameters): void { (this.listeners[event] as Set<(...a: unknown[]) => void> | undefined)?.forEach((h) => h(...(args as unknown[])) ) } connect(): void { if (this.destroyed) return const config = getConfig() const token = getToken() const url = `${config.imBaseUrl}/ws/im?token=${token ?? ''}` this.ws = new WebSocket(url) this.ws.onopen = () => { this.reconnectDelay = 3_000 if (config.debug) console.log('[ImClient] connected') this.emit('connected') } this.ws.onmessage = (event) => { try { const frame = JSON.parse(event.data as string) if (frame.type === 'MESSAGE') { this.emit('message', this.normalizeMessage(frame.payload as ImMessage)) } else if (frame.type === 'REVOKE') { this.emit('revoke', frame.payload as { msgId: string; operatorId: string }) } } catch { // ignore malformed frames } } this.ws.onclose = (event) => { this.emit('disconnected', event.code, event.reason) if (!this.destroyed) this.scheduleReconnect() } this.ws.onerror = (event) => { this.emit('error', event) } } send(params: SendMessageParams): ImMessage { const config = getConfig() const userId = getUserId() ?? '' const messageId = params.messageId ?? this.generateMessageId() const outgoing: ImMessage = { id: messageId, appId: config.appKey, fromUserId: userId, fromId: userId, toId: params.toId, chatType: params.chatType, msgType: params.msgType, content: params.content, status: 'SENDING', mentionedUserIds: params.mentionedUserIds, revoked: false, createdAt: new Date().toISOString(), } if (this.ws?.readyState !== WebSocket.OPEN) { return { ...outgoing, status: 'FAILED' } } this.ws.send( JSON.stringify({ destination: '/app/chat.send', payload: { ...params, messageId, }, }) ) return outgoing } revoke(msgId: string): void { if (this.ws?.readyState !== WebSocket.OPEN) { throw new Error('WebSocket not connected') } this.ws.send( JSON.stringify({ destination: '/app/chat.revoke', payload: { msgId }, }) ) } disconnect(): void { this.destroyed = true if (this.reconnectTimer) clearTimeout(this.reconnectTimer) this.ws?.close() this.ws = null } private scheduleReconnect(): void { if (this.destroyed) return if (getConfig().debug) { console.log(`[ImClient] reconnect in ${this.reconnectDelay}ms`) } this.reconnectTimer = setTimeout(() => { this.connect() this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY) }, this.reconnectDelay) } private generateMessageId(): string { const cryptoId = globalThis.crypto?.randomUUID?.() if (cryptoId) return cryptoId return `msg_${Date.now()}_${Math.random().toString(16).slice(2)}` } private normalizeMessage(message: ImMessage): ImMessage { return { ...message, fromId: message.fromId ?? message.fromUserId, revoked: message.revoked ?? message.status === 'REVOKED', } } }