feat(im): 添加即时通讯功能模块

- 添加了 IM API 接口定义,包含登录、消息、群组、好友等接口
- 实现了 ImSDK 核心功能,支持发送各类消息和管理会话
- 集成了 WebSocket 连接管理和自动重连机制
- 添加了本地联系人缓存并优化对话标题显示逻辑
- 实现了 HarmonyOS 平台 HTTP 客户端基础功能
这个提交包含在:
XuqmGroup 2026-04-28 16:55:11 +08:00
父节点 fe8b72d2be
当前提交 930c8f36ae
共有 6 个文件被更改,包括 282 次插入27 次删除

查看文件

@ -13,6 +13,11 @@ export type {
PushTokenInfo,
MsgType,
ChatType,
MsgStatus,
ConversationData,
HistoryQuery,
PageResult,
UserProfile,
ApiResponse,
} from './src/main/ets/core/Types'
export type { ImEventDelegate, RevokeData } from './src/main/ets/im/ImClient'

查看文件

@ -21,6 +21,14 @@ export class XuqmSDK {
return SDKContext.getToken()
}
static setUserId(userId: string | null): void {
SDKContext.setUserId(userId)
}
static getUserId(): string | null {
return SDKContext.getUserId()
}
static get im(): ImClient {
if (!XuqmSDK._imClient) {
XuqmSDK._imClient = new ImClient()

查看文件

@ -3,10 +3,23 @@ import type { ApiResponse } from './Types'
import { SDKContext } from './SDKContext'
export class HttpClient {
static async request<T>(method: http.RequestMethod, path: string, body?: object): Promise<T> {
static async request<T>(
method: http.RequestMethod,
path: string,
body?: object,
query?: Record<string, string | number | boolean | Date | null | undefined>,
): Promise<T> {
const config = SDKContext.getConfig()
const token = SDKContext.getToken()
const url = config.apiBaseUrl.replace(/\/$/, '') + path
const queryPairs: string[] = []
if (query) {
for (const key of Object.keys(query)) {
const value = query[key]
if (value === undefined || value === null || value === '') continue
queryPairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(value instanceof Date ? value.toISOString() : String(value))}`)
}
}
const url = config.apiBaseUrl.replace(/\/$/, '') + path + (queryPairs.length > 0 ? `?${queryPairs.join('&')}` : '')
const client = http.createHttp()
try {
@ -30,19 +43,19 @@ export class HttpClient {
}
}
static get<T>(path: string): Promise<T> {
return HttpClient.request<T>(http.RequestMethod.GET, path)
static get<T>(path: string, query?: Record<string, string | number | boolean | Date | null | undefined>): Promise<T> {
return HttpClient.request<T>(http.RequestMethod.GET, path, undefined, query)
}
static post<T>(path: string, body?: object): Promise<T> {
return HttpClient.request<T>(http.RequestMethod.POST, path, body)
static post<T>(path: string, body?: object, query?: Record<string, string | number | boolean | Date | null | undefined>): Promise<T> {
return HttpClient.request<T>(http.RequestMethod.POST, path, body, query)
}
static put<T>(path: string, body?: object): Promise<T> {
return HttpClient.request<T>(http.RequestMethod.PUT, path, body)
static put<T>(path: string, body?: object, query?: Record<string, string | number | boolean | Date | null | undefined>): Promise<T> {
return HttpClient.request<T>(http.RequestMethod.PUT, path, body, query)
}
static delete<T>(path: string): Promise<T> {
return HttpClient.request<T>(http.RequestMethod.DELETE, path)
static delete<T>(path: string, query?: Record<string, string | number | boolean | Date | null | undefined>): Promise<T> {
return HttpClient.request<T>(http.RequestMethod.DELETE, path, undefined, query)
}
}

查看文件

@ -7,6 +7,7 @@ const PREF_NAME = 'xuqm_sdk_prefs'
export class SDKContext {
private static _config: SDKConfig | null = null
private static _token: string | null = null
private static _userId: string | null = null
private static _pref: preferences.Preferences | null = null
static init(config: SDKConfig): void {
@ -44,4 +45,12 @@ export class SDKContext {
static getToken(): string | null {
return SDKContext._token
}
static setUserId(userId: string | null): void {
SDKContext._userId = userId
}
static getUserId(): string | null {
return SDKContext._userId
}
}

查看文件

@ -25,29 +25,81 @@ export type MsgType =
| 'RICH_TEXT'
| 'CALL_AUDIO'
| 'CALL_VIDEO'
| 'QUOTE'
| 'MERGE'
| 'REVOKED'
| 'FORWARD'
export type ChatType = 'SINGLE' | 'GROUP'
export type MsgStatus = 'SENDING' | 'SENT' | 'DELIVERED' | 'READ' | 'FAILED' | 'REVOKED'
export interface ImMessage {
id: string
appId: string
fromUserId: string
fromId: string
toId: string
chatType: ChatType
msgType: MsgType
content: string
extra?: string
revoked: boolean
createdAt: string
status: MsgStatus
mentionedUserIds?: string
groupReadCount?: number
revoked?: boolean
createdAt: number
}
export interface ConversationData {
targetId: string
chatType: ChatType
lastMsgContent?: string | null
lastMsgType?: string | null
lastMsgTime: number
unreadCount: number
isMuted: boolean
isPinned: boolean
}
export interface HistoryQuery {
msgType?: MsgType
keyword?: string
startTime?: Date | string | number
endTime?: Date | string | number
page?: number
size?: number
}
export interface PageResult<T> {
content: T[]
totalElements: number
totalPages: number
size: number
number: number
numberOfElements: number
first: boolean
last: boolean
empty: boolean
}
export interface UserProfile {
id?: string
appId?: string
userId: string
nickname?: string | null
avatar?: string | null
gender?: string | null
status?: string | null
createdAt?: number | null
}
export interface SendMessageParams {
messageId?: string
toId: string
chatType: ChatType
msgType: MsgType
content: string
extra?: string
mentionedUserIds?: string
}
export interface AppVersionInfo {

查看文件

@ -1,6 +1,7 @@
import webSocket from '@ohos.net.webSocket'
import type { ImMessage, SendMessageParams } from '../core/Types'
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
@ -41,9 +42,9 @@ export class ImClient {
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: object }
const frame = JSON.parse(text) as { type: string; payload: unknown }
if (frame.type === 'MESSAGE') {
this.delegate?.onMessage?.(frame.payload as ImMessage)
this.delegate?.onMessage?.(this.normalizeMessage(frame.payload as ImMessage))
} else if (frame.type === 'REVOKE') {
this.delegate?.onRevoke?.(frame.payload as RevokeData)
}
@ -64,19 +65,133 @@ export class ImClient {
this.ws.connect(url, {})
}
send(params: SendMessageParams): void {
if (!this.ws) throw new Error('WebSocket not connected')
const frame = JSON.stringify({ destination: '/app/chat.send', payload: params })
this.ws.send(frame, (_err: Error) => {
if (_err) console.error('[ImClient] send error', _err.message)
})
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')
const frame = JSON.stringify({ destination: '/app/chat.revoke', payload: { msgId } })
this.ws.send(frame, (_err: Error) => {
if (_err) console.error('[ImClient] revoke error', _err.message)
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 } : {}),
})
}
@ -99,4 +214,57 @@ export class ImClient {
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('')
}
}