2026-04-28 16:55:12 +08:00
|
|
|
import { _getToken, getConfig, getUserId } from '@xuqm/rn-common'
|
2026-04-24 16:16:31 +08:00
|
|
|
import type { ImEventListener, ImMessage, SendMessageParams } from './types'
|
|
|
|
|
|
|
|
|
|
interface StompFrame {
|
|
|
|
|
command: string
|
|
|
|
|
headers: Record<string, string>
|
|
|
|
|
body: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class ImClient {
|
|
|
|
|
private ws: WebSocket | null = null
|
|
|
|
|
private listeners: ImEventListener[] = []
|
|
|
|
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
|
private reconnectDelay = 3000
|
|
|
|
|
private shouldReconnect = true
|
|
|
|
|
private readonly subscriptionId = 'sub-user-queue'
|
|
|
|
|
private groupSubscriptions = new Set<string>()
|
2026-04-28 10:27:23 +08:00
|
|
|
private activeWsUrl: string | null = null
|
|
|
|
|
private activeToken: string | null = null
|
|
|
|
|
private activeAppId: string | null = null
|
2026-04-24 16:16:31 +08:00
|
|
|
|
|
|
|
|
constructor(
|
2026-04-28 10:27:23 +08:00
|
|
|
private readonly wsUrl?: string,
|
|
|
|
|
private readonly token?: string,
|
|
|
|
|
private readonly appId?: string,
|
2026-04-24 16:16:31 +08:00
|
|
|
) {}
|
|
|
|
|
|
2026-04-28 10:27:23 +08:00
|
|
|
async connect() {
|
2026-04-24 16:16:31 +08:00
|
|
|
this.shouldReconnect = true
|
2026-04-28 10:27:23 +08:00
|
|
|
this.activeWsUrl = this.wsUrl ?? getConfig().imWsUrl
|
|
|
|
|
this.activeToken = this.token ?? null
|
|
|
|
|
this.activeAppId = this.appId ?? getConfig().appId
|
|
|
|
|
if (!this.activeToken) {
|
|
|
|
|
this.activeToken = await _getToken()
|
|
|
|
|
}
|
|
|
|
|
if (!this.activeWsUrl) {
|
|
|
|
|
this.listeners.forEach(listener => listener.onError?.('IM websocket URL not configured'))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (!this.activeToken) {
|
|
|
|
|
this.listeners.forEach(listener => listener.onError?.('IM token not configured'))
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-04-24 16:16:31 +08:00
|
|
|
this.openSocket()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sendMessage(
|
|
|
|
|
toId: string,
|
|
|
|
|
chatType: SendMessageParams['chatType'],
|
|
|
|
|
msgType: SendMessageParams['msgType'],
|
|
|
|
|
content: string,
|
|
|
|
|
mentionedUserIds?: string,
|
2026-04-28 16:55:12 +08:00
|
|
|
): ImMessage {
|
|
|
|
|
return this.send({
|
2026-04-24 16:16:31 +08:00
|
|
|
toId,
|
|
|
|
|
chatType,
|
|
|
|
|
msgType,
|
|
|
|
|
content,
|
|
|
|
|
mentionedUserIds,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 16:55:12 +08:00
|
|
|
send(params: SendMessageParams): ImMessage {
|
2026-04-28 10:27:23 +08:00
|
|
|
if (!this.activeAppId) {
|
|
|
|
|
throw new Error('IM appId not configured')
|
|
|
|
|
}
|
2026-04-28 16:55:12 +08:00
|
|
|
const outgoing = this.buildOutgoingMessage(params)
|
|
|
|
|
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
|
|
|
return { ...outgoing, status: 'FAILED' }
|
|
|
|
|
}
|
2026-04-24 16:16:31 +08:00
|
|
|
|
|
|
|
|
this.sendFrame(
|
|
|
|
|
'SEND',
|
|
|
|
|
{
|
|
|
|
|
destination: '/app/chat.send',
|
|
|
|
|
'content-type': 'application/json',
|
|
|
|
|
},
|
|
|
|
|
JSON.stringify({
|
2026-04-28 10:27:23 +08:00
|
|
|
appId: this.activeAppId,
|
2026-04-28 16:55:12 +08:00
|
|
|
messageId: outgoing.id,
|
2026-04-24 16:16:31 +08:00
|
|
|
toId: params.toId,
|
|
|
|
|
chatType: params.chatType,
|
|
|
|
|
msgType: params.msgType,
|
|
|
|
|
content: params.content,
|
|
|
|
|
mentionedUserIds: params.mentionedUserIds ?? '',
|
|
|
|
|
}),
|
|
|
|
|
)
|
2026-04-28 16:55:12 +08:00
|
|
|
|
|
|
|
|
return outgoing
|
2026-04-24 16:16:31 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
revoke(messageId: string) {
|
|
|
|
|
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
|
|
|
throw new Error('IM not connected')
|
|
|
|
|
}
|
2026-04-28 10:27:23 +08:00
|
|
|
if (!this.activeAppId) {
|
|
|
|
|
throw new Error('IM appId not configured')
|
|
|
|
|
}
|
2026-04-24 16:16:31 +08:00
|
|
|
|
|
|
|
|
this.sendFrame(
|
|
|
|
|
'SEND',
|
|
|
|
|
{
|
|
|
|
|
destination: '/app/chat.revoke',
|
|
|
|
|
'content-type': 'application/json',
|
|
|
|
|
},
|
|
|
|
|
JSON.stringify({
|
2026-04-28 10:27:23 +08:00
|
|
|
appId: this.activeAppId,
|
2026-04-24 16:16:31 +08:00
|
|
|
messageId,
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
subscribeGroup(groupId: string) {
|
2026-04-28 10:27:23 +08:00
|
|
|
const alreadySubscribed = this.groupSubscriptions.has(groupId)
|
2026-04-24 16:16:31 +08:00
|
|
|
this.groupSubscriptions.add(groupId)
|
|
|
|
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
2026-04-28 10:27:23 +08:00
|
|
|
if (alreadySubscribed) return
|
2026-04-24 16:16:31 +08:00
|
|
|
this.subscribe(`/topic/group/${groupId}`, `group-${groupId}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 10:27:23 +08:00
|
|
|
unsubscribeGroup(groupId: string) {
|
|
|
|
|
this.groupSubscriptions.delete(groupId)
|
|
|
|
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
|
|
|
this.sendFrame('UNSUBSCRIBE', { id: `group-${groupId}` })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 16:16:31 +08:00
|
|
|
addListener(listener: ImEventListener) {
|
|
|
|
|
this.listeners.push(listener)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
removeListener(listener: ImEventListener) {
|
|
|
|
|
this.listeners = this.listeners.filter(item => item !== listener)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
disconnect() {
|
|
|
|
|
this.shouldReconnect = false
|
|
|
|
|
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
|
|
|
|
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
|
|
|
this.sendFrame('DISCONNECT')
|
|
|
|
|
}
|
|
|
|
|
this.ws?.close(1000, 'User disconnect')
|
|
|
|
|
this.ws = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isConnected(): boolean {
|
|
|
|
|
return this.ws?.readyState === WebSocket.OPEN
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private openSocket() {
|
2026-04-28 10:27:23 +08:00
|
|
|
if (!this.activeWsUrl) return
|
|
|
|
|
this.ws = new WebSocket(this.activeWsUrl)
|
2026-04-24 16:16:31 +08:00
|
|
|
|
|
|
|
|
this.ws.onopen = () => {
|
2026-04-28 10:27:23 +08:00
|
|
|
if (!this.activeToken) {
|
2026-04-28 20:11:38 +08:00
|
|
|
void _getToken().then((token: string | null) => {
|
2026-04-28 10:27:23 +08:00
|
|
|
this.activeToken = token
|
|
|
|
|
if (token) {
|
|
|
|
|
this.sendFrame('CONNECT', {
|
|
|
|
|
'accept-version': '1.2',
|
|
|
|
|
Authorization: `Bearer ${token}`,
|
|
|
|
|
'heart-beat': '10000,10000',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-04-24 16:16:31 +08:00
|
|
|
this.sendFrame('CONNECT', {
|
|
|
|
|
'accept-version': '1.2',
|
2026-04-28 10:27:23 +08:00
|
|
|
Authorization: `Bearer ${this.activeToken}`,
|
2026-04-24 16:16:31 +08:00
|
|
|
'heart-beat': '10000,10000',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.ws.onmessage = event => {
|
|
|
|
|
try {
|
|
|
|
|
const frames = this.parseFrames(String(event.data))
|
|
|
|
|
frames.forEach(frame => this.handleFrame(frame))
|
|
|
|
|
} catch {
|
|
|
|
|
this.listeners.forEach(listener => listener.onError?.('Parse error'))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.ws.onclose = event => {
|
|
|
|
|
this.listeners.forEach(listener => listener.onDisconnected?.(event.reason))
|
|
|
|
|
if (this.shouldReconnect) {
|
|
|
|
|
this.reconnectTimer = setTimeout(() => {
|
|
|
|
|
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000)
|
|
|
|
|
this.openSocket()
|
|
|
|
|
}, this.reconnectDelay)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.ws.onerror = () => {
|
|
|
|
|
this.listeners.forEach(listener => listener.onError?.('WebSocket error'))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private handleFrame(frame: StompFrame) {
|
|
|
|
|
if (frame.command === 'CONNECTED') {
|
|
|
|
|
this.reconnectDelay = 3000
|
|
|
|
|
this.subscribe('/user/queue/messages', this.subscriptionId)
|
|
|
|
|
this.groupSubscriptions.forEach(groupId => {
|
|
|
|
|
this.subscribe(`/topic/group/${groupId}`, `group-${groupId}`)
|
|
|
|
|
})
|
|
|
|
|
this.listeners.forEach(listener => listener.onConnected?.())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (frame.command === 'MESSAGE') {
|
2026-04-28 16:55:12 +08:00
|
|
|
const message: ImMessage = this.normalizeMessage(JSON.parse(frame.body))
|
2026-04-28 22:32:21 +08:00
|
|
|
if (message.status === 'READ') {
|
|
|
|
|
this.listeners.forEach(listener => listener.onRead?.(message))
|
|
|
|
|
}
|
|
|
|
|
if (message.revoked || message.status === 'REVOKED' || message.msgType === 'REVOKED') {
|
|
|
|
|
this.listeners.forEach(listener =>
|
|
|
|
|
listener.onRevoke?.({ msgId: message.id, operatorId: message.fromId ?? message.fromUserId }),
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-04-24 16:16:31 +08:00
|
|
|
if (message.chatType === 'GROUP') {
|
|
|
|
|
this.listeners.forEach(listener => listener.onGroupMessage?.(message))
|
2026-04-28 20:11:38 +08:00
|
|
|
} else {
|
|
|
|
|
this.listeners.forEach(listener => listener.onMessage?.(message))
|
|
|
|
|
}
|
|
|
|
|
if (message.msgType === 'NOTIFY') {
|
|
|
|
|
this.listeners.forEach(listener => listener.onSystemMessage?.(message))
|
2026-04-24 16:16:31 +08:00
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (frame.command === 'ERROR') {
|
|
|
|
|
this.listeners.forEach(listener => listener.onError?.(frame.body || 'WebSocket error'))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private subscribe(destination: string, id: string) {
|
|
|
|
|
this.sendFrame('SUBSCRIBE', { destination, id })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private sendFrame(command: string, headers: Record<string, string> = {}, body = '') {
|
|
|
|
|
if (!this.ws) return
|
|
|
|
|
const headerLines = Object.entries(headers)
|
|
|
|
|
.map(([key, value]) => `${key}:${value}`)
|
|
|
|
|
.join('\n')
|
|
|
|
|
const frame = `${command}\n${headerLines}\n\n${body}\u0000`
|
|
|
|
|
this.ws.send(frame)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private parseFrames(raw: string): StompFrame[] {
|
|
|
|
|
return raw
|
|
|
|
|
.split('\u0000')
|
|
|
|
|
.map(frame => frame.replace(/^\n+/, '').trim())
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.map(frame => {
|
|
|
|
|
const separatorIndex = frame.indexOf('\n\n')
|
|
|
|
|
const headerBlock = separatorIndex >= 0 ? frame.slice(0, separatorIndex) : frame
|
|
|
|
|
const body = separatorIndex >= 0 ? frame.slice(separatorIndex + 2) : ''
|
|
|
|
|
const [command, ...headerLines] = headerBlock.split('\n').filter(Boolean)
|
|
|
|
|
const headers = Object.fromEntries(
|
|
|
|
|
headerLines
|
|
|
|
|
.filter(line => line.includes(':'))
|
|
|
|
|
.map(line => {
|
|
|
|
|
const index = line.indexOf(':')
|
|
|
|
|
return [line.slice(0, index), line.slice(index + 1)]
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
return { command, headers, body }
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-04-28 16:55:12 +08:00
|
|
|
|
|
|
|
|
private buildOutgoingMessage(params: SendMessageParams): ImMessage {
|
|
|
|
|
const userId = getUserId() ?? ''
|
|
|
|
|
const messageId = params.messageId ?? this.generateMessageId()
|
|
|
|
|
return {
|
|
|
|
|
id: messageId,
|
|
|
|
|
appId: this.activeAppId ?? getConfig().appId,
|
|
|
|
|
fromUserId: userId,
|
|
|
|
|
fromId: userId,
|
|
|
|
|
toId: params.toId,
|
|
|
|
|
chatType: params.chatType,
|
|
|
|
|
msgType: params.msgType,
|
|
|
|
|
content: params.content,
|
|
|
|
|
status: 'SENDING',
|
|
|
|
|
mentionedUserIds: params.mentionedUserIds,
|
|
|
|
|
groupReadCount: 0,
|
|
|
|
|
revoked: false,
|
|
|
|
|
createdAt: Date.now(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private generateMessageId(): string {
|
2026-04-28 20:11:38 +08:00
|
|
|
const cryptoApi = globalThis as typeof globalThis & { crypto?: { randomUUID?: () => string } }
|
|
|
|
|
const cryptoId = cryptoApi.crypto?.randomUUID?.()
|
2026-04-28 16:55:12 +08:00
|
|
|
return cryptoId ?? `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',
|
|
|
|
|
appId: message.appId ?? (this.activeAppId ?? getConfig().appId),
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-24 16:16:31 +08:00
|
|
|
}
|