import webSocket from '@ohos.net.webSocket' import { HttpClient } from '../core/HttpClient' import { SDKContext } from '../core/SDKContext' import type { ChatType, ConversationData, HistoryQuery, ImMessage, MsgType, PageResult, SendMessageParams, UserProfile } from '../core/Types' export interface ImEventDelegate { onConnected?(): void onDisconnected?(code: number, reason: string): void onMessage?(msg: ImMessage): void onRevoke?(data: RevokeData): void onError?(message: string): void } export interface RevokeData { msgId: string operatorId: string } const MAX_RECONNECT_DELAY = 30_000 export class ImClient { private ws: webSocket.WebSocket | null = null private reconnectDelay: number = 3_000 private reconnectTimer: number | null = null private destroyed: boolean = false delegate: ImEventDelegate | null = null connect(): void { if (this.destroyed) return const config = SDKContext.getConfig() const token = SDKContext.getToken() ?? '' const url = `${config.imBaseUrl}/ws/im?token=${token}` this.ws = webSocket.createWebSocket() this.ws.on('open', (_err: Error, _value: Object) => { this.reconnectDelay = 3_000 if (config.debug) console.log('[ImClient] connected') this.delegate?.onConnected?.() }) this.ws.on('message', (_err: Error, value: string | ArrayBuffer) => { try { const text = typeof value === 'string' ? value : new TextDecoder().decode(value) const frame = JSON.parse(text) as { type: string; payload: unknown } if (frame.type === 'MESSAGE') { this.delegate?.onMessage?.(this.normalizeMessage(frame.payload as ImMessage)) } else if (frame.type === 'REVOKE') { this.delegate?.onRevoke?.(frame.payload as RevokeData) } } catch { // ignore malformed frames } }) this.ws.on('close', (_err: Error, value: webSocket.CloseResult) => { this.delegate?.onDisconnected?.(value.code, value.reason) if (!this.destroyed) this.scheduleReconnect() }) this.ws.on('error', (_err: Error) => { this.delegate?.onError?.(_err.message) }) this.ws.connect(url, {}) } send(params: SendMessageParams): ImMessage { const outgoing = this.buildOutgoingMessage(params) if (!this.ws) { return { ...outgoing, status: 'FAILED' } } this.ws.send( JSON.stringify({ destination: '/app/chat.send', payload: { ...params, messageId: outgoing.id, }, }), (_err: Error) => { if (_err) { this.delegate?.onError?.(_err.message) } }, ) return outgoing } revoke(msgId: string): void { if (!this.ws) { throw new Error('WebSocket not connected') } this.ws.send( JSON.stringify({ destination: '/app/chat.revoke', payload: { msgId }, }), (_err: Error) => { if (_err) this.delegate?.onError?.(_err.message) }, ) } async fetchHistory( toId: string, page: number = 0, size: number = 20, query: HistoryQuery = {}, ): Promise> { return HttpClient.get>(`/api/im/messages/history/${encodeURIComponent(toId)}`, { appId: SDKContext.getConfig().appKey, page, size, msgType: query.msgType, keyword: query.keyword, startTime: query.startTime instanceof Date ? this.formatDateTime(query.startTime) : query.startTime, endTime: query.endTime instanceof Date ? this.formatDateTime(query.endTime) : query.endTime, }) } async fetchGroupHistory( groupId: string, page: number = 0, size: number = 50, query: HistoryQuery = {}, ): Promise> { return HttpClient.get>(`/api/im/messages/group-history/${encodeURIComponent(groupId)}`, { appId: SDKContext.getConfig().appKey, page, size, msgType: query.msgType, keyword: query.keyword, startTime: query.startTime instanceof Date ? this.formatDateTime(query.startTime) : query.startTime, endTime: query.endTime instanceof Date ? this.formatDateTime(query.endTime) : query.endTime, }) } async listConversations(size: number = 20): Promise { return HttpClient.get('/api/im/conversations', { appId: SDKContext.getConfig().appKey, page: 0, size, }) } async markRead(targetId: string, chatType: ChatType = 'SINGLE'): Promise { await HttpClient.put(`/api/im/conversations/${encodeURIComponent(targetId)}/read`, undefined, { appId: SDKContext.getConfig().appKey, chatType, }) } async setDraft(targetId: string, chatType: ChatType, draft: string): Promise { await HttpClient.put(`/api/im/conversations/${encodeURIComponent(targetId)}/draft`, undefined, { appId: SDKContext.getConfig().appKey, chatType, draft, }) } async deleteConversation(targetId: string, chatType: ChatType): Promise { await HttpClient.delete(`/api/im/conversations/${encodeURIComponent(targetId)}`, { appId: SDKContext.getConfig().appKey, chatType, }) } async getProfile(userId: string): Promise { return HttpClient.get(`/api/im/accounts/${encodeURIComponent(userId)}`, { appId: SDKContext.getConfig().appKey, }) } async updateProfile( userId: string, nickname: string | null = null, avatar: string | null = null, gender: string | null = null, ): Promise { return HttpClient.put(`/api/im/accounts/${encodeURIComponent(userId)}`, undefined, { appId: SDKContext.getConfig().appKey, ...(nickname !== null ? { nickname } : {}), ...(avatar !== null ? { avatar } : {}), ...(gender !== null ? { gender } : {}), }) } disconnect(): void { this.destroyed = true if (this.reconnectTimer !== null) { clearTimeout(this.reconnectTimer) this.reconnectTimer = null } this.ws?.close((_err: Error) => {}) this.ws = null } private scheduleReconnect(): void { if (this.destroyed) return const delay = this.reconnectDelay if (SDKContext.getConfig().debug) console.log(`[ImClient] reconnect in ${delay}ms`) this.reconnectTimer = setTimeout(() => { this.connect() this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY) }, delay) } private buildOutgoingMessage(params: SendMessageParams): ImMessage { const messageId = params.messageId ?? this.generateMessageId() const userId = SDKContext.getUserId() ?? '' const appId = SDKContext.getConfig().appKey return { id: messageId, 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 normalizeMessage(message: ImMessage): ImMessage { return { ...message, fromId: message.fromId ?? message.fromUserId, revoked: message.revoked ?? message.status === 'REVOKED', appId: message.appId ?? SDKContext.getConfig().appKey, } } private generateMessageId(): string { const cryptoId = globalThis.crypto?.randomUUID?.() if (cryptoId) return cryptoId return `msg_${Date.now()}_${Math.random().toString(16).slice(2)}` } private formatDateTime(value: Date): string { const pad = (n: number) => String(n).padStart(2, '0') return [ value.getFullYear(), '-', pad(value.getMonth() + 1), '-', pad(value.getDate()), 'T', pad(value.getHours()), ':', pad(value.getMinutes()), ':', pad(value.getSeconds()), ].join('') } }