2026-04-21 22:07:29 +08:00
|
|
|
import webSocket from '@ohos.net.webSocket'
|
2026-04-28 16:55:11 +08:00
|
|
|
import { HttpClient } from '../core/HttpClient'
|
2026-04-21 22:07:29 +08:00
|
|
|
import { SDKContext } from '../core/SDKContext'
|
2026-04-28 16:55:11 +08:00
|
|
|
import type { ChatType, ConversationData, HistoryQuery, ImMessage, MsgType, PageResult, SendMessageParams, UserProfile } from '../core/Types'
|
2026-04-21 22:07:29 +08:00
|
|
|
|
|
|
|
|
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)
|
2026-04-28 16:55:11 +08:00
|
|
|
const frame = JSON.parse(text) as { type: string; payload: unknown }
|
2026-04-21 22:07:29 +08:00
|
|
|
if (frame.type === 'MESSAGE') {
|
2026-04-28 16:55:11 +08:00
|
|
|
this.delegate?.onMessage?.(this.normalizeMessage(frame.payload as ImMessage))
|
2026-04-21 22:07:29 +08:00
|
|
|
} 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, {})
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 16:55:11 +08:00
|
|
|
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
|
2026-04-21 22:07:29 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
revoke(msgId: string): void {
|
2026-04-28 16:55:11 +08:00
|
|
|
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<PageResult<ImMessage>> {
|
|
|
|
|
return HttpClient.get<PageResult<ImMessage>>(`/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<PageResult<ImMessage>> {
|
|
|
|
|
return HttpClient.get<PageResult<ImMessage>>(`/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<ConversationData[]> {
|
|
|
|
|
return HttpClient.get<ConversationData[]>('/api/im/conversations', {
|
|
|
|
|
appId: SDKContext.getConfig().appKey,
|
|
|
|
|
page: 0,
|
|
|
|
|
size,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async markRead(targetId: string, chatType: ChatType = 'SINGLE'): Promise<void> {
|
|
|
|
|
await HttpClient.put<void>(`/api/im/conversations/${encodeURIComponent(targetId)}/read`, undefined, {
|
|
|
|
|
appId: SDKContext.getConfig().appKey,
|
|
|
|
|
chatType,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async setDraft(targetId: string, chatType: ChatType, draft: string): Promise<void> {
|
|
|
|
|
await HttpClient.put<void>(`/api/im/conversations/${encodeURIComponent(targetId)}/draft`, undefined, {
|
|
|
|
|
appId: SDKContext.getConfig().appKey,
|
|
|
|
|
chatType,
|
|
|
|
|
draft,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async deleteConversation(targetId: string, chatType: ChatType): Promise<void> {
|
|
|
|
|
await HttpClient.delete<void>(`/api/im/conversations/${encodeURIComponent(targetId)}`, {
|
|
|
|
|
appId: SDKContext.getConfig().appKey,
|
|
|
|
|
chatType,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getProfile(userId: string): Promise<UserProfile> {
|
|
|
|
|
return HttpClient.get<UserProfile>(`/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<UserProfile> {
|
|
|
|
|
return HttpClient.put<UserProfile>(`/api/im/accounts/${encodeURIComponent(userId)}`, undefined, {
|
|
|
|
|
appId: SDKContext.getConfig().appKey,
|
|
|
|
|
...(nickname !== null ? { nickname } : {}),
|
|
|
|
|
...(avatar !== null ? { avatar } : {}),
|
|
|
|
|
...(gender !== null ? { gender } : {}),
|
2026-04-21 22:07:29 +08:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
2026-04-28 16:55:11 +08:00
|
|
|
|
|
|
|
|
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('')
|
|
|
|
|
}
|
2026-04-21 22:07:29 +08:00
|
|
|
}
|