import webSocket from '@ohos.net.webSocket' import { HttpClient } from '../core/HttpClient' import { SDKContext } from '../core/SDKContext' import type { ChatType, ConversationData, FriendRequest, GroupJoinRequest, HistoryQuery, ImGroup, ImMessage, MsgType, PageResult, SendMessageParams, UserProfile, } from '../core/Types' export interface ImEventDelegate { onConnected?(): void onDisconnected?(code: number, reason: string): void onMessage?(msg: ImMessage): void onRead?(msg: ImMessage): void onRevoke?(data: RevokeData): void onError?(message: string): void } export interface RevokeData { msgId: string operatorId: string } class WebSocketFrame { type: string = '' payload: Object = new Object() } class WebSocketEnvelope { destination: string = '' payload: Object = new Object() } class AppBody { appId: string = '' } class HistoryQueryParams { appId: string = '' page: number = 0 size: number = 0 msgType: MsgType = 'TEXT' keyword: string = '' startTime: string = '' endTime: string = '' } class ConversationActionBody { appId: string = '' chatType: ChatType = 'SINGLE' } class FriendRequestQueryBody { appId: string = '' direction: 'incoming' | 'outgoing' = 'incoming' } class FriendRequestBody { appId: string = '' toUserId: string = '' remark: string = '' } class GroupJoinRequestBody { appId: string = '' remark: string = '' } class EditMessageBody { content: string = '' } class UpdateProfileBody { appId: string = '' nickname: string = '' avatar: string = '' gender: string = '' } class DraftBody { appId: string = '' chatType: ChatType = 'SINGLE' draft: string = '' } class PinBody { appId: string = '' chatType: ChatType = 'SINGLE' pinned: boolean = false } class MuteBody { appId: string = '' chatType: ChatType = 'SINGLE' muted: boolean = false } class SendEnvelopePayload { messageId: string = '' toId: string = '' chatType: ChatType = 'SINGLE' msgType: MsgType = 'TEXT' content: string = '' mentionedUserIds: string = '' } class RevokeEnvelopePayload { msgId: 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.replace(/\/$/, '') + '/ws/im?token=' + encodeURIComponent(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 { if (typeof value !== 'string') { return } const frame = JSON.parse(value) as WebSocketFrame if (frame.type === 'MESSAGE') { const message = this.normalizeMessage(frame.payload as ImMessage) if (message.status === 'READ') { this.delegate?.onRead?.(message) } if (message.revoked || message.status === 'REVOKED' || message.msgType === 'REVOKED') { this.delegate?.onRevoke?.({ msgId: message.id, operatorId: message.fromId }) } this.delegate?.onMessage?.(message) } 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 this.markFailed(outgoing) } const payload = new SendEnvelopePayload() payload.messageId = outgoing.id payload.toId = params.toId payload.chatType = params.chatType payload.msgType = params.msgType payload.content = params.content if (params.mentionedUserIds !== undefined) { payload.mentionedUserIds = params.mentionedUserIds } const envelope = new WebSocketEnvelope() envelope.destination = '/app/chat.send' envelope.payload = payload this.ws.send(JSON.stringify(envelope), (_err: Error) => { if (_err) { this.delegate?.onError?.(_err.message) } }) return outgoing } revoke(msgId: string): void { if (!this.ws) { throw new Error('WebSocket not connected') } const payload = new RevokeEnvelopePayload() payload.msgId = msgId const envelope = new WebSocketEnvelope() envelope.destination = '/app/chat.revoke' envelope.payload = payload this.ws.send(JSON.stringify(envelope), (_err: Error) => { if (_err) this.delegate?.onError?.(_err.message) }) } async fetchHistory( toId: string, page: number = 0, size: number = 20, query: HistoryQuery = {}, ): Promise> { const queryString = this.buildHistoryQuery(page, size, query) return HttpClient.get>('/api/im/messages/history/' + encodeURIComponent(toId), queryString) } async fetchGroupHistory( groupId: string, page: number = 0, size: number = 50, query: HistoryQuery = {}, ): Promise> { const queryString = this.buildHistoryQuery(page, size, query) return HttpClient.get>('/api/im/messages/group-history/' + encodeURIComponent(groupId), queryString) } async locateHistoryPage( toId: string, messageId: string, pageSize: number = 20, maxPages: number = 20, ): Promise { const pageCount = Math.max(maxPages, 1) for (let page = 0; page < pageCount; page += 1) { const result = await this.fetchHistory(toId, page, pageSize) const messages = result.content ?? [] if (messages.some(item => item.id === messageId)) { return messages } if (messages.length < pageSize) { return null } } return null } async locateGroupHistoryPage( groupId: string, messageId: string, pageSize: number = 50, maxPages: number = 20, ): Promise { const pageCount = Math.max(maxPages, 1) for (let page = 0; page < pageCount; page += 1) { const result = await this.fetchGroupHistory(groupId, page, pageSize) const messages = result.content ?? [] if (messages.some(item => item.id === messageId)) { return messages } if (messages.length < pageSize) { return null } } return null } async listConversations(size: number = 20): Promise { return HttpClient.get('/api/im/conversations', this.buildConversationQuery(size)) } async markRead(targetId: string, chatType: ChatType = 'SINGLE'): Promise { await HttpClient.put('/api/im/conversations/' + encodeURIComponent(targetId) + '/read', undefined, this.buildConversationActionQuery(chatType)) } async setDraft(targetId: string, chatType: ChatType, draft: string): Promise { const params = new DraftBody() params.appId = SDKContext.getConfig().appKey params.chatType = chatType params.draft = draft await HttpClient.put('/api/im/conversations/' + encodeURIComponent(targetId) + '/draft', params) } async setConversationPinned(targetId: string, chatType: ChatType, pinned: boolean): Promise { const params = new PinBody() params.appId = SDKContext.getConfig().appKey params.chatType = chatType params.pinned = pinned await HttpClient.put('/api/im/conversations/' + encodeURIComponent(targetId) + '/pinned', params) } async setConversationMuted(targetId: string, chatType: ChatType, muted: boolean): Promise { const params = new MuteBody() params.appId = SDKContext.getConfig().appKey params.chatType = chatType params.muted = muted await HttpClient.put('/api/im/conversations/' + encodeURIComponent(targetId) + '/muted', params) } async deleteConversation(targetId: string, chatType: ChatType): Promise { await HttpClient.delete('/api/im/conversations/' + encodeURIComponent(targetId), this.buildConversationActionQuery(chatType)) } async listFriends(): Promise { return HttpClient.get('/api/im/friends', this.buildAppQuery()) } async listGroups(): Promise { return HttpClient.get('/api/im/groups', this.buildAppQuery()) } async getGroupInfo(groupId: string): Promise { return HttpClient.get('/api/im/groups/' + encodeURIComponent(groupId), this.buildAppQuery()) } async listGroupMembers(groupId: string): Promise { return HttpClient.get( '/api/im/groups/' + encodeURIComponent(groupId) + '/members', this.buildAppQuery(), ) } async searchGroupMembers(groupId: string, keyword: string, size: number = 20): Promise { return HttpClient.get( '/api/im/groups/' + encodeURIComponent(groupId) + '/members/search', this.buildSearchQuery(keyword, size), ) } async searchUsers(keyword: string, size: number = 20): Promise { return HttpClient.get( '/api/im/admin/users/search', this.buildSearchQuery(keyword, size), ) } async searchGroups(keyword: string, size: number = 20): Promise { return HttpClient.get( '/api/im/admin/groups/search', this.buildSearchQuery(keyword, size), ) } async searchMessages( keyword: string = '', chatType: ChatType | '' = '', msgType: MsgType | '' = '', page: number = 0, size: number = 20, ): Promise> { return HttpClient.get>( '/api/im/admin/messages/search', this.buildMessageSearchQuery(keyword, chatType, msgType, page, size), ) } async editMessage(messageId: string, content: string): Promise { const body = new EditMessageBody() body.content = content return HttpClient.put( '/api/im/messages/' + encodeURIComponent(messageId), body, this.buildAppQuery(), ) } async revokeMessage(messageId: string): Promise { return HttpClient.post( '/api/im/messages/' + encodeURIComponent(messageId) + '/revoke', this.buildAppBody(), this.buildAppQuery(), ) } async sendFriendRequest(toUserId: string, remark: string | null = null): Promise { const params = new FriendRequestBody() params.appId = SDKContext.getConfig().appKey params.toUserId = toUserId if (remark !== null && remark !== '') { params.remark = remark } return HttpClient.post('/api/im/friend-requests', params) } async listFriendRequests(direction: 'incoming' | 'outgoing' = 'incoming'): Promise { return HttpClient.get('/api/im/friend-requests', this.buildFriendRequestQuery(direction)) } async acceptFriendRequest(requestId: string): Promise { return HttpClient.post('/api/im/friend-requests/' + encodeURIComponent(requestId) + '/accept', this.buildAppBody()) } async rejectFriendRequest(requestId: string): Promise { return HttpClient.post('/api/im/friend-requests/' + encodeURIComponent(requestId) + '/reject', this.buildAppBody()) } async sendGroupJoinRequest(groupId: string, remark: string | null = null): Promise { const params = new GroupJoinRequestBody() params.appId = SDKContext.getConfig().appKey if (remark !== null && remark !== '') { params.remark = remark } return HttpClient.post('/api/im/groups/' + encodeURIComponent(groupId) + '/join-requests', params) } async listGroupJoinRequests(groupId: string): Promise { return HttpClient.get('/api/im/groups/' + encodeURIComponent(groupId) + '/join-requests', this.buildAppQuery()) } async acceptGroupJoinRequest(groupId: string, requestId: string): Promise { return HttpClient.post( '/api/im/groups/' + encodeURIComponent(groupId) + '/join-requests/' + encodeURIComponent(requestId) + '/accept', this.buildAppBody(), ) } async rejectGroupJoinRequest(groupId: string, requestId: string): Promise { return HttpClient.post( '/api/im/groups/' + encodeURIComponent(groupId) + '/join-requests/' + encodeURIComponent(requestId) + '/reject', this.buildAppBody(), ) } async getProfile(userId: string): Promise { return HttpClient.get('/api/im/accounts/' + encodeURIComponent(userId), this.buildAppQuery()) } async updateProfile( userId: string, nickname: string | null = null, avatar: string | null = null, gender: string | null = null, ): Promise { const params = new UpdateProfileBody() params.appId = SDKContext.getConfig().appKey if (nickname !== null) { params.nickname = nickname } if (avatar !== null) { params.avatar = avatar } if (gender !== null) { params.gender = gender } return HttpClient.put('/api/im/accounts/' + encodeURIComponent(userId), params) } private buildAppBody(): AppBody { const body = new AppBody() body.appId = SDKContext.getConfig().appKey return body } private buildAppQuery(): string { return 'appId=' + encodeURIComponent(SDKContext.getConfig().appKey) } private buildConversationActionQuery(chatType: ChatType): string { return 'appId=' + encodeURIComponent(SDKContext.getConfig().appKey) + '&chatType=' + encodeURIComponent(chatType) } private buildConversationQuery(size: number): string { return 'appId=' + encodeURIComponent(SDKContext.getConfig().appKey) + '&page=0&size=' + encodeURIComponent(size) } private buildFriendRequestQuery(direction: 'incoming' | 'outgoing'): string { return 'appId=' + encodeURIComponent(SDKContext.getConfig().appKey) + '&direction=' + encodeURIComponent(direction) } private buildSearchQuery(keyword: string, size: number): string { return 'appId=' + encodeURIComponent(SDKContext.getConfig().appKey) + '&keyword=' + encodeURIComponent(keyword) + '&size=' + encodeURIComponent(size) } private buildMessageSearchQuery( keyword: string, chatType: ChatType | '', msgType: MsgType | '', page: number, size: number, ): string { const parts: string[] = [] parts.push('appId=' + encodeURIComponent(SDKContext.getConfig().appKey)) if (keyword !== '') { parts.push('keyword=' + encodeURIComponent(keyword)) } if (chatType !== '') { parts.push('chatType=' + encodeURIComponent(chatType)) } if (msgType !== '') { parts.push('msgType=' + encodeURIComponent(msgType)) } parts.push('page=' + encodeURIComponent(page)) parts.push('size=' + encodeURIComponent(size)) return parts.join('&') } private buildHistoryQuery(page: number, size: number, query: HistoryQuery): string { const parts: string[] = [] parts.push('appId=' + encodeURIComponent(SDKContext.getConfig().appKey)) parts.push('page=' + encodeURIComponent(page)) parts.push('size=' + encodeURIComponent(size)) if (query.msgType !== undefined) { parts.push('msgType=' + encodeURIComponent(query.msgType)) } if (query.keyword !== undefined && query.keyword !== '') { parts.push('keyword=' + encodeURIComponent(query.keyword)) } if (query.startTime !== undefined) { const startValue = query.startTime instanceof Date ? this.formatDateTime(query.startTime) : String(query.startTime) parts.push('startTime=' + encodeURIComponent(startValue)) } if (query.endTime !== undefined) { const endValue = query.endTime instanceof Date ? this.formatDateTime(query.endTime) : String(query.endTime) parts.push('endTime=' + encodeURIComponent(endValue)) } return parts.join('&') } 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 const message: ImMessage = { 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(), } return message } private markFailed(message: ImMessage): ImMessage { const failed: ImMessage = { id: message.id, appId: message.appId, fromUserId: message.fromUserId, fromId: message.fromId, toId: message.toId, chatType: message.chatType, msgType: message.msgType, content: message.content, status: 'FAILED', mentionedUserIds: message.mentionedUserIds, groupReadCount: message.groupReadCount, revoked: message.revoked, createdAt: message.createdAt, } return failed } private normalizeMessage(message: ImMessage): ImMessage { const normalized: ImMessage = { id: message.id, appId: message.appId || SDKContext.getConfig().appKey, fromUserId: message.fromUserId, fromId: message.fromId || message.fromUserId, toId: message.toId, chatType: message.chatType, msgType: message.msgType, content: message.content, status: message.status, mentionedUserIds: message.mentionedUserIds, groupReadCount: message.groupReadCount, revoked: message.revoked || message.status === 'REVOKED', createdAt: message.createdAt, } return normalized } private generateMessageId(): string { return 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000000).toString(16) } private formatDateTime(value: Date): string { const pad = (n: number): string => { return n < 10 ? '0' + n : String(n) } return [ value.getFullYear(), '-', pad(value.getMonth() + 1), '-', pad(value.getDate()), 'T', pad(value.getHours()), ':', pad(value.getMinutes()), ':', pad(value.getSeconds()), ].join('') } private toStringValue(value: Date | string | number | undefined): string | undefined { if (value === undefined) { return undefined } if (value instanceof Date) { return this.formatDateTime(value) } return String(value) } }