XuqmGroup-HarmonySDK/xuqm-sdk/src/main/ets/im/ImClient.ets

644 行
20 KiB
Plaintext

2026-04-21 22:07:29 +08:00
import webSocket from '@ohos.net.webSocket'
import { HttpClient } from '../core/HttpClient'
2026-04-21 22:07:29 +08:00
import { SDKContext } from '../core/SDKContext'
import type {
ChatType,
ConversationData,
FriendRequest,
GroupJoinRequest,
HistoryQuery,
ImGroup,
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
onRead?(msg: ImMessage): void
2026-04-21 22:07:29 +08:00
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 = ''
}
2026-04-21 22:07:29 +08:00
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)
2026-04-21 22:07:29 +08:00
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
2026-04-21 22:07:29 +08:00
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)
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, {})
}
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
2026-04-21 22:07:29 +08:00
}
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<PageResult<ImMessage>> {
const queryString = this.buildHistoryQuery(page, size, query)
return HttpClient.get<PageResult<ImMessage>>('/api/im/messages/history/' + encodeURIComponent(toId), queryString)
}
async fetchGroupHistory(
groupId: string,
page: number = 0,
size: number = 50,
query: HistoryQuery = {},
): Promise<PageResult<ImMessage>> {
const queryString = this.buildHistoryQuery(page, size, query)
return HttpClient.get<PageResult<ImMessage>>('/api/im/messages/group-history/' + encodeURIComponent(groupId), queryString)
}
async locateHistoryPage(
toId: string,
messageId: string,
pageSize: number = 20,
maxPages: number = 20,
): Promise<ImMessage[] | null> {
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<ImMessage[] | null> {
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<ConversationData[]> {
return HttpClient.get<ConversationData[]>('/api/im/conversations', this.buildConversationQuery(size))
}
async markRead(targetId: string, chatType: ChatType = 'SINGLE'): Promise<void> {
await HttpClient.put<void>('/api/im/conversations/' + encodeURIComponent(targetId) + '/read', undefined, this.buildConversationActionQuery(chatType))
}
async setDraft(targetId: string, chatType: ChatType, draft: string): Promise<void> {
const params = new DraftBody()
params.appId = SDKContext.getConfig().appKey
params.chatType = chatType
params.draft = draft
await HttpClient.put<void>('/api/im/conversations/' + encodeURIComponent(targetId) + '/draft', params)
}
async setConversationPinned(targetId: string, chatType: ChatType, pinned: boolean): Promise<void> {
const params = new PinBody()
params.appId = SDKContext.getConfig().appKey
params.chatType = chatType
params.pinned = pinned
await HttpClient.put<void>('/api/im/conversations/' + encodeURIComponent(targetId) + '/pinned', params)
}
async setConversationMuted(targetId: string, chatType: ChatType, muted: boolean): Promise<void> {
const params = new MuteBody()
params.appId = SDKContext.getConfig().appKey
params.chatType = chatType
params.muted = muted
await HttpClient.put<void>('/api/im/conversations/' + encodeURIComponent(targetId) + '/muted', params)
}
async deleteConversation(targetId: string, chatType: ChatType): Promise<void> {
await HttpClient.delete<void>('/api/im/conversations/' + encodeURIComponent(targetId), this.buildConversationActionQuery(chatType))
}
async listFriends(): Promise<string[]> {
return HttpClient.get<string[]>('/api/im/friends', this.buildAppQuery())
}
async listGroups(): Promise<ImGroup[]> {
return HttpClient.get<ImGroup[]>('/api/im/groups', this.buildAppQuery())
}
async getGroupInfo(groupId: string): Promise<ImGroup> {
return HttpClient.get<ImGroup>('/api/im/groups/' + encodeURIComponent(groupId), this.buildAppQuery())
}
async listGroupMembers(groupId: string): Promise<UserProfile[]> {
return HttpClient.get<UserProfile[]>(
'/api/im/groups/' + encodeURIComponent(groupId) + '/members',
this.buildAppQuery(),
)
}
async searchGroupMembers(groupId: string, keyword: string, size: number = 20): Promise<UserProfile[]> {
return HttpClient.get<UserProfile[]>(
'/api/im/groups/' + encodeURIComponent(groupId) + '/members/search',
this.buildSearchQuery(keyword, size),
)
}
async searchUsers(keyword: string, size: number = 20): Promise<UserProfile[]> {
return HttpClient.get<UserProfile[]>(
'/api/im/admin/users/search',
this.buildSearchQuery(keyword, size),
)
}
async searchGroups(keyword: string, size: number = 20): Promise<ImGroup[]> {
return HttpClient.get<ImGroup[]>(
'/api/im/admin/groups/search',
this.buildSearchQuery(keyword, size),
)
}
async searchMessages(
keyword: string = '',
chatType: ChatType | '' = '',
msgType: MsgType | '' = '',
page: number = 0,
size: number = 20,
): Promise<PageResult<ImMessage>> {
return HttpClient.get<PageResult<ImMessage>>(
'/api/im/admin/messages/search',
this.buildMessageSearchQuery(keyword, chatType, msgType, page, size),
)
}
async editMessage(messageId: string, content: string): Promise<ImMessage> {
const body = new EditMessageBody()
body.content = content
return HttpClient.put<ImMessage>(
'/api/im/messages/' + encodeURIComponent(messageId),
body,
this.buildAppQuery(),
)
}
async revokeMessage(messageId: string): Promise<ImMessage> {
return HttpClient.post<ImMessage>(
'/api/im/messages/' + encodeURIComponent(messageId) + '/revoke',
this.buildAppBody(),
this.buildAppQuery(),
)
}
async sendFriendRequest(toUserId: string, remark: string | null = null): Promise<FriendRequest> {
const params = new FriendRequestBody()
params.appId = SDKContext.getConfig().appKey
params.toUserId = toUserId
if (remark !== null && remark !== '') {
params.remark = remark
}
return HttpClient.post<FriendRequest>('/api/im/friend-requests', params)
}
async listFriendRequests(direction: 'incoming' | 'outgoing' = 'incoming'): Promise<FriendRequest[]> {
return HttpClient.get<FriendRequest[]>('/api/im/friend-requests', this.buildFriendRequestQuery(direction))
}
async acceptFriendRequest(requestId: string): Promise<FriendRequest> {
return HttpClient.post<FriendRequest>('/api/im/friend-requests/' + encodeURIComponent(requestId) + '/accept', this.buildAppBody())
}
async rejectFriendRequest(requestId: string): Promise<FriendRequest> {
return HttpClient.post<FriendRequest>('/api/im/friend-requests/' + encodeURIComponent(requestId) + '/reject', this.buildAppBody())
}
async sendGroupJoinRequest(groupId: string, remark: string | null = null): Promise<GroupJoinRequest> {
const params = new GroupJoinRequestBody()
params.appId = SDKContext.getConfig().appKey
if (remark !== null && remark !== '') {
params.remark = remark
}
return HttpClient.post<GroupJoinRequest>('/api/im/groups/' + encodeURIComponent(groupId) + '/join-requests', params)
}
async listGroupJoinRequests(groupId: string): Promise<GroupJoinRequest[]> {
return HttpClient.get<GroupJoinRequest[]>('/api/im/groups/' + encodeURIComponent(groupId) + '/join-requests', this.buildAppQuery())
}
async acceptGroupJoinRequest(groupId: string, requestId: string): Promise<GroupJoinRequest> {
return HttpClient.post<GroupJoinRequest>(
'/api/im/groups/' + encodeURIComponent(groupId) + '/join-requests/' + encodeURIComponent(requestId) + '/accept',
this.buildAppBody(),
)
}
async rejectGroupJoinRequest(groupId: string, requestId: string): Promise<GroupJoinRequest> {
return HttpClient.post<GroupJoinRequest>(
'/api/im/groups/' + encodeURIComponent(groupId) + '/join-requests/' + encodeURIComponent(requestId) + '/reject',
this.buildAppBody(),
)
}
async getProfile(userId: string): Promise<UserProfile> {
return HttpClient.get<UserProfile>('/api/im/accounts/' + encodeURIComponent(userId), this.buildAppQuery())
}
async updateProfile(
userId: string,
nickname: string | null = null,
avatar: string | null = null,
gender: string | null = null,
): Promise<UserProfile> {
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<UserProfile>('/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('&')
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')
2026-04-21 22:07:29 +08:00
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)
}
2026-04-21 22:07:29 +08:00
}