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

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

查看文件

@ -19,6 +19,7 @@ public final class ImClient: NSObject, URLSessionWebSocketDelegate, @unchecked S
private var activeWsURL: URL? private var activeWsURL: URL?
private var activeToken: String? private var activeToken: String?
private var activeAppId: String? private var activeAppId: String?
private var activeUserId: String?
public init(wsURL: URL? = nil, token: String? = nil, appId: String? = nil) { public init(wsURL: URL? = nil, token: String? = nil, appId: String? = nil) {
self.wsURLOverride = wsURL self.wsURLOverride = wsURL
@ -27,6 +28,10 @@ public final class ImClient: NSObject, URLSessionWebSocketDelegate, @unchecked S
super.init() super.init()
} }
public func setCurrentUserId(_ userId: String) {
activeUserId = userId
}
public func connect() { public func connect() {
shouldReconnect = true shouldReconnect = true
reconnectWorkItem?.cancel() reconnectWorkItem?.cancel()
@ -55,7 +60,7 @@ public final class ImClient: NSObject, URLSessionWebSocketDelegate, @unchecked S
self.activeToken = activeToken self.activeToken = activeToken
} }
public func sendMessage(toId: String, chatType: ChatType, msgType: MsgType, content: String) { public func sendMessage(toId: String, chatType: ChatType, msgType: MsgType, content: String) -> ImMessage {
sendMessage(toId: toId, chatType: chatType, msgType: msgType, content: content, mentionedUserIds: nil) sendMessage(toId: toId, chatType: chatType, msgType: msgType, content: content, mentionedUserIds: nil)
} }
@ -65,12 +70,27 @@ public final class ImClient: NSObject, URLSessionWebSocketDelegate, @unchecked S
msgType: MsgType, msgType: MsgType,
content: String, content: String,
mentionedUserIds: String? mentionedUserIds: String?
) { ) -> ImMessage {
let messageId = UUID().uuidString
let now = Int64(Date().timeIntervalSince1970 * 1000)
let message = ImMessage(
id: messageId,
appId: activeAppId ?? "",
fromUserId: activeUserId ?? "",
toId: toId,
chatType: chatType,
msgType: msgType,
content: content,
status: .sending,
mentionedUserIds: mentionedUserIds?.isEmpty == false ? mentionedUserIds : nil,
groupReadCount: nil,
createdAt: now
)
guard let activeAppId else { guard let activeAppId else {
delegate?.imClientDidError("IM appId not configured") delegate?.imClientDidError("IM appId not configured")
return return message.failedCopy()
} }
sendFrame( let sent = sendFrame(
command: "SEND", command: "SEND",
headers: [ headers: [
"destination": "/app/chat.send", "destination": "/app/chat.send",
@ -78,6 +98,7 @@ public final class ImClient: NSObject, URLSessionWebSocketDelegate, @unchecked S
], ],
body: encodeJSONString([ body: encodeJSONString([
"appId": activeAppId, "appId": activeAppId,
"messageId": messageId,
"toId": toId, "toId": toId,
"chatType": chatType.rawValue, "chatType": chatType.rawValue,
"msgType": msgType.rawValue, "msgType": msgType.rawValue,
@ -85,6 +106,7 @@ public final class ImClient: NSObject, URLSessionWebSocketDelegate, @unchecked S
"mentionedUserIds": mentionedUserIds ?? "", "mentionedUserIds": mentionedUserIds ?? "",
]), ]),
) )
return sent ? message : message.failedCopy()
} }
public func revoke(messageId: String) { public func revoke(messageId: String) {
@ -202,13 +224,14 @@ public final class ImClient: NSObject, URLSessionWebSocketDelegate, @unchecked S
DispatchQueue.global().asyncAfter(deadline: .now() + reconnectDelay, execute: workItem) DispatchQueue.global().asyncAfter(deadline: .now() + reconnectDelay, execute: workItem)
} }
private func sendFrame(command: String, headers: [String: String] = [:], body: String = "") { private func sendFrame(command: String, headers: [String: String] = [:], body: String = "") -> Bool {
guard let webSocketTask else { return } guard let webSocketTask, webSocketTask.state == .running else { return false }
let headerLines = headers.map { "\($0.key):\($0.value)" }.joined(separator: "\n") let headerLines = headers.map { "\($0.key):\($0.value)" }.joined(separator: "\n")
let frame = headerLines.isEmpty let frame = headerLines.isEmpty
? "\(command)\n\n\(body)\u{0000}" ? "\(command)\n\n\(body)\u{0000}"
: "\(command)\n\(headerLines)\n\n\(body)\u{0000}" : "\(command)\n\(headerLines)\n\n\(body)\u{0000}"
webSocketTask.send(.string(frame)) { _ in } webSocketTask.send(.string(frame)) { _ in }
return true
} }
private func encodeJSONString(_ object: [String: String]) -> String { private func encodeJSONString(_ object: [String: String]) -> String {
@ -262,6 +285,24 @@ public final class ImClient: NSObject, URLSessionWebSocketDelegate, @unchecked S
} }
} }
private extension ImMessage {
func failedCopy() -> ImMessage {
ImMessage(
id: id,
appId: appId,
fromUserId: fromUserId,
toId: toId,
chatType: chatType,
msgType: msgType,
content: content,
status: .failed,
mentionedUserIds: mentionedUserIds,
groupReadCount: groupReadCount,
createdAt: createdAt
)
}
}
private struct StompFrame { private struct StompFrame {
let command: String let command: String
let headers: [String: String] let headers: [String: String]

查看文件

@ -6,6 +6,7 @@ public final class ImSDK {
public static let shared = ImSDK() public static let shared = ImSDK()
private var client: ImClient? private var client: ImClient?
private weak var delegate: ImEventDelegate? private weak var delegate: ImEventDelegate?
private var currentUserId: String?
private init() {} private init() {}
@ -24,9 +25,11 @@ public final class ImSDK {
method: "POST", method: "POST",
queryItems: items queryItems: items
) )
currentUserId = userId
XuqmSDK.shared.tokenStore?.save(res.token) XuqmSDK.shared.tokenStore?.save(res.token)
client?.disconnect() client?.disconnect()
client = ImClient(wsURL: config.imWebSocketURL, token: res.token, appId: config.appId) client = ImClient(wsURL: config.imWebSocketURL, token: res.token, appId: config.appId)
client?.setCurrentUserId(userId)
client?.delegate = delegate client?.delegate = delegate
client?.connect() client?.connect()
} }
@ -36,11 +39,12 @@ public final class ImSDK {
client?.delegate = delegate client?.delegate = delegate
} }
public func sendMessage(toId: String, chatType: ChatType, msgType: MsgType, content: String) { public func sendMessage(toId: String, chatType: ChatType, msgType: MsgType, content: String) -> ImMessage {
client?.sendMessage(toId: toId, chatType: chatType, msgType: msgType, content: content) client?.sendMessage(toId: toId, chatType: chatType, msgType: msgType, content: content)
?? fallbackFailedMessage(toId: toId, chatType: chatType, msgType: msgType, content: content, mentionedUserIds: nil)
} }
public func sendTextMessage(toId: String, chatType: ChatType, content: String) { public func sendTextMessage(toId: String, chatType: ChatType, content: String) -> ImMessage {
sendMessage(toId: toId, chatType: chatType, msgType: .text, content: content) sendMessage(toId: toId, chatType: chatType, msgType: .text, content: content)
} }
@ -52,7 +56,7 @@ public final class ImSDK {
width: Int? = nil, width: Int? = nil,
height: Int? = nil, height: Int? = nil,
size: Int64? = nil size: Int64? = nil
) { ) -> ImMessage {
sendMessage( sendMessage(
toId: toId, toId: toId,
chatType: chatType, chatType: chatType,
@ -74,7 +78,7 @@ public final class ImSDK {
thumbnailUrl: String? = nil, thumbnailUrl: String? = nil,
duration: Int64? = nil, duration: Int64? = nil,
size: Int64? = nil size: Int64? = nil
) { ) -> ImMessage {
sendMessage( sendMessage(
toId: toId, toId: toId,
chatType: chatType, chatType: chatType,
@ -94,7 +98,7 @@ public final class ImSDK {
url: String, url: String,
duration: Int64? = nil, duration: Int64? = nil,
size: Int64? = nil size: Int64? = nil
) { ) -> ImMessage {
sendMessage( sendMessage(
toId: toId, toId: toId,
chatType: chatType, chatType: chatType,
@ -113,7 +117,7 @@ public final class ImSDK {
url: String, url: String,
name: String, name: String,
size: Int64 size: Int64
) { ) -> ImMessage {
sendMessage( sendMessage(
toId: toId, toId: toId,
chatType: chatType, chatType: chatType,
@ -133,7 +137,7 @@ public final class ImSDK {
lng: Double, lng: Double,
title: String? = nil, title: String? = nil,
address: String? = nil address: String? = nil
) { ) -> ImMessage {
sendMessage( sendMessage(
toId: toId, toId: toId,
chatType: chatType, chatType: chatType,
@ -147,7 +151,7 @@ public final class ImSDK {
) )
} }
public func sendCustomMessage(toId: String, chatType: ChatType, data: String) { public func sendCustomMessage(toId: String, chatType: ChatType, data: String) -> ImMessage {
sendMessage( sendMessage(
toId: toId, toId: toId,
chatType: chatType, chatType: chatType,
@ -156,7 +160,7 @@ public final class ImSDK {
) )
} }
public func sendRichTextMessage(toId: String, chatType: ChatType, html: String) { public func sendRichTextMessage(toId: String, chatType: ChatType, html: String) -> ImMessage {
sendMessage( sendMessage(
toId: toId, toId: toId,
chatType: chatType, chatType: chatType,
@ -165,7 +169,7 @@ public final class ImSDK {
) )
} }
public func sendNotifyMessage(toId: String, chatType: ChatType, title: String, content: String) { public func sendNotifyMessage(toId: String, chatType: ChatType, title: String, content: String) -> ImMessage {
sendMessage( sendMessage(
toId: toId, toId: toId,
chatType: chatType, chatType: chatType,
@ -180,7 +184,7 @@ public final class ImSDK {
quotedMsgId: String, quotedMsgId: String,
quotedContent: String, quotedContent: String,
text: String text: String
) { ) -> ImMessage {
sendMessage( sendMessage(
toId: toId, toId: toId,
chatType: chatType, chatType: chatType,
@ -198,7 +202,7 @@ public final class ImSDK {
chatType: ChatType, chatType: ChatType,
title: String, title: String,
msgList: [String] msgList: [String]
) { ) -> ImMessage {
sendMessage( sendMessage(
toId: toId, toId: toId,
chatType: chatType, chatType: chatType,
@ -210,7 +214,7 @@ public final class ImSDK {
) )
} }
public func sendForwardMessage(toId: String, chatType: ChatType, originalSender: String, originalContent: String) { public func sendForwardMessage(toId: String, chatType: ChatType, originalSender: String, originalContent: String) -> ImMessage {
sendMessage( sendMessage(
toId: toId, toId: toId,
chatType: chatType, chatType: chatType,
@ -222,7 +226,7 @@ public final class ImSDK {
) )
} }
public func sendCallAudioMessage(toId: String, chatType: ChatType, action: String) { public func sendCallAudioMessage(toId: String, chatType: ChatType, action: String) -> ImMessage {
sendMessage( sendMessage(
toId: toId, toId: toId,
chatType: chatType, chatType: chatType,
@ -231,7 +235,7 @@ public final class ImSDK {
) )
} }
public func sendCallVideoMessage(toId: String, chatType: ChatType, action: String) { public func sendCallVideoMessage(toId: String, chatType: ChatType, action: String) -> ImMessage {
sendMessage( sendMessage(
toId: toId, toId: toId,
chatType: chatType, chatType: chatType,
@ -252,6 +256,28 @@ public final class ImSDK {
client?.unsubscribeGroup(groupId) client?.unsubscribeGroup(groupId)
} }
private func fallbackFailedMessage(
toId: String,
chatType: ChatType,
msgType: MsgType,
content: String,
mentionedUserIds: String?
) -> ImMessage {
ImMessage(
id: UUID().uuidString,
appId: XuqmSDK.shared.requireConfig().appId,
fromUserId: currentUserId ?? "",
toId: toId,
chatType: chatType,
msgType: msgType,
content: content,
status: .failed,
mentionedUserIds: mentionedUserIds,
groupReadCount: nil,
createdAt: Int64(Date().timeIntervalSince1970 * 1000)
)
}
public func fetchHistory( public func fetchHistory(
toId: String, toId: String,
page: Int = 0, page: Int = 0,
@ -521,6 +547,32 @@ public final class ImSDK {
) )
} }
public func getProfile(userId: String) async throws -> UserProfile {
let config = XuqmSDK.shared.requireConfig()
return try await ApiClient.shared.request(
path: "/api/im/accounts/\(userId)",
queryItems: [URLQueryItem(name: "appId", value: config.appId)]
)
}
public func updateProfile(
userId: String,
nickname: String? = nil,
avatar: String? = nil,
gender: String? = nil
) async throws -> UserProfile {
let config = XuqmSDK.shared.requireConfig()
var items = [URLQueryItem(name: "appId", value: config.appId)]
if let nickname { items.append(URLQueryItem(name: "nickname", value: nickname)) }
if let avatar { items.append(URLQueryItem(name: "avatar", value: avatar)) }
if let gender { items.append(URLQueryItem(name: "gender", value: gender)) }
return try await ApiClient.shared.request(
path: "/api/im/accounts/\(userId)",
method: "PUT",
queryItems: items
)
}
public func listConversations() async throws -> [ConversationData] { public func listConversations() async throws -> [ConversationData] {
let config = XuqmSDK.shared.requireConfig() let config = XuqmSDK.shared.requireConfig()
return try await ApiClient.shared.request( return try await ApiClient.shared.request(

查看文件

@ -107,7 +107,19 @@ public struct BlacklistEntry: Codable, Sendable {
public let createdAt: Int64 public let createdAt: Int64
} }
public struct UserProfile: Codable, Sendable {
public let id: String?
public let appId: String?
public let userId: String
public let nickname: String?
public let avatar: String?
public let gender: String?
public let status: String?
public let createdAt: Int64?
}
public struct ImSendMessageRequest: Encodable, Sendable { public struct ImSendMessageRequest: Encodable, Sendable {
public let messageId: String
public let toId: String public let toId: String
public let chatType: ChatType public let chatType: ChatType
public let msgType: MsgType public let msgType: MsgType