2026-04-21 22:07:29 +08:00
|
|
|
import type { ImMessage, SendMessageParams, ImEventMap } from '../types'
|
2026-04-28 16:55:12 +08:00
|
|
|
import { getConfig, getToken, getUserId } from '../core/sdk'
|
2026-04-29 15:46:39 +08:00
|
|
|
import { DEFAULT_IM_WS_URL } from '../core/endpoints'
|
2026-04-21 22:07:29 +08:00
|
|
|
|
|
|
|
|
type EventListener<K extends keyof ImEventMap> = ImEventMap[K]
|
|
|
|
|
|
|
|
|
|
const MAX_RECONNECT_DELAY = 30_000
|
|
|
|
|
|
|
|
|
|
export class ImClient {
|
|
|
|
|
private ws: WebSocket | null = null
|
|
|
|
|
private reconnectDelay = 3_000
|
|
|
|
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
|
private destroyed = false
|
|
|
|
|
private listeners: { [K in keyof ImEventMap]?: Set<EventListener<K>> } = {}
|
|
|
|
|
|
|
|
|
|
on<K extends keyof ImEventMap>(event: K, handler: EventListener<K>): this {
|
2026-04-28 16:55:12 +08:00
|
|
|
const store = this.listeners as Record<string, Set<unknown>>
|
|
|
|
|
const listeners = (store[event] ?? new Set<EventListener<K>>()) as Set<EventListener<K>>
|
|
|
|
|
listeners.add(handler)
|
|
|
|
|
store[event] = listeners as Set<unknown>
|
2026-04-21 22:07:29 +08:00
|
|
|
return this
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
off<K extends keyof ImEventMap>(event: K, handler: EventListener<K>): this {
|
|
|
|
|
(this.listeners[event] as Set<EventListener<K>> | undefined)?.delete(handler)
|
|
|
|
|
return this
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private emit<K extends keyof ImEventMap>(event: K, ...args: Parameters<ImEventMap[K]>): 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()
|
2026-04-29 15:46:39 +08:00
|
|
|
const url = `${DEFAULT_IM_WS_URL}?token=${token ?? ''}`
|
2026-04-21 22:07:29 +08:00
|
|
|
|
|
|
|
|
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') {
|
2026-04-28 22:32:21 +08:00
|
|
|
const message = this.normalizeMessage(frame.payload as ImMessage)
|
|
|
|
|
if (message.status === 'READ') {
|
|
|
|
|
this.emit('read', message)
|
|
|
|
|
}
|
|
|
|
|
if (message.revoked || message.status === 'REVOKED' || message.msgType === 'REVOKED') {
|
|
|
|
|
this.emit('revoke', { msgId: message.id, operatorId: message.fromId ?? message.fromUserId })
|
|
|
|
|
}
|
|
|
|
|
this.emit('message', message)
|
2026-04-21 22:07:29 +08:00
|
|
|
} 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 16:55:12 +08:00
|
|
|
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(),
|
|
|
|
|
}
|
2026-04-21 22:07:29 +08:00
|
|
|
if (this.ws?.readyState !== WebSocket.OPEN) {
|
2026-04-28 16:55:12 +08:00
|
|
|
return { ...outgoing, status: 'FAILED' }
|
2026-04-21 22:07:29 +08:00
|
|
|
}
|
|
|
|
|
this.ws.send(
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
destination: '/app/chat.send',
|
2026-04-28 16:55:12 +08:00
|
|
|
payload: {
|
|
|
|
|
...params,
|
|
|
|
|
messageId,
|
|
|
|
|
},
|
2026-04-21 22:07:29 +08:00
|
|
|
})
|
|
|
|
|
)
|
2026-04-28 16:55:12 +08:00
|
|
|
return outgoing
|
2026-04-21 22:07:29 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
2026-04-28 16:55:12 +08:00
|
|
|
|
|
|
|
|
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',
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-21 22:07:29 +08:00
|
|
|
}
|