From 027937db1b4a831b999f3d4ee91ac770c6813b6f Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Tue, 28 Apr 2026 16:55:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(im):=20=E6=B7=BB=E5=8A=A0=E5=8D=B3?= =?UTF-8?q?=E6=97=B6=E9=80=9A=E8=AE=AF=E5=8A=9F=E8=83=BD=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加了 IM API 接口定义,包含登录、消息、群组、好友等接口 - 实现了 ImSDK 核心功能,支持发送各类消息和管理会话 - 集成了 WebSocket 连接管理和自动重连机制 - 添加了本地联系人缓存并优化对话标题显示逻辑 - 实现了 HarmonyOS 平台 HTTP 客户端基础功能 --- Sources/XuqmSDK/IM/ImClient.swift | 53 +++++++++++++++++--- Sources/XuqmSDK/IM/ImSDK.swift | 82 +++++++++++++++++++++++++------ Sources/XuqmSDK/IM/ImTypes.swift | 12 +++++ 3 files changed, 126 insertions(+), 21 deletions(-) diff --git a/Sources/XuqmSDK/IM/ImClient.swift b/Sources/XuqmSDK/IM/ImClient.swift index e32365b..126c525 100644 --- a/Sources/XuqmSDK/IM/ImClient.swift +++ b/Sources/XuqmSDK/IM/ImClient.swift @@ -19,6 +19,7 @@ public final class ImClient: NSObject, URLSessionWebSocketDelegate, @unchecked S private var activeWsURL: URL? private var activeToken: String? private var activeAppId: String? + private var activeUserId: String? public init(wsURL: URL? = nil, token: String? = nil, appId: String? = nil) { self.wsURLOverride = wsURL @@ -27,6 +28,10 @@ public final class ImClient: NSObject, URLSessionWebSocketDelegate, @unchecked S super.init() } + public func setCurrentUserId(_ userId: String) { + activeUserId = userId + } + public func connect() { shouldReconnect = true reconnectWorkItem?.cancel() @@ -55,7 +60,7 @@ public final class ImClient: NSObject, URLSessionWebSocketDelegate, @unchecked S 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) } @@ -65,12 +70,27 @@ public final class ImClient: NSObject, URLSessionWebSocketDelegate, @unchecked S msgType: MsgType, content: 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 { delegate?.imClientDidError("IM appId not configured") - return + return message.failedCopy() } - sendFrame( + let sent = sendFrame( command: "SEND", headers: [ "destination": "/app/chat.send", @@ -78,6 +98,7 @@ public final class ImClient: NSObject, URLSessionWebSocketDelegate, @unchecked S ], body: encodeJSONString([ "appId": activeAppId, + "messageId": messageId, "toId": toId, "chatType": chatType.rawValue, "msgType": msgType.rawValue, @@ -85,6 +106,7 @@ public final class ImClient: NSObject, URLSessionWebSocketDelegate, @unchecked S "mentionedUserIds": mentionedUserIds ?? "", ]), ) + return sent ? message : message.failedCopy() } 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) } - private func sendFrame(command: String, headers: [String: String] = [:], body: String = "") { - guard let webSocketTask else { return } + private func sendFrame(command: String, headers: [String: String] = [:], body: String = "") -> Bool { + guard let webSocketTask, webSocketTask.state == .running else { return false } let headerLines = headers.map { "\($0.key):\($0.value)" }.joined(separator: "\n") let frame = headerLines.isEmpty ? "\(command)\n\n\(body)\u{0000}" : "\(command)\n\(headerLines)\n\n\(body)\u{0000}" webSocketTask.send(.string(frame)) { _ in } + return true } 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 { let command: String let headers: [String: String] diff --git a/Sources/XuqmSDK/IM/ImSDK.swift b/Sources/XuqmSDK/IM/ImSDK.swift index 74022f7..f4abfb4 100644 --- a/Sources/XuqmSDK/IM/ImSDK.swift +++ b/Sources/XuqmSDK/IM/ImSDK.swift @@ -6,6 +6,7 @@ public final class ImSDK { public static let shared = ImSDK() private var client: ImClient? private weak var delegate: ImEventDelegate? + private var currentUserId: String? private init() {} @@ -24,9 +25,11 @@ public final class ImSDK { method: "POST", queryItems: items ) + currentUserId = userId XuqmSDK.shared.tokenStore?.save(res.token) client?.disconnect() client = ImClient(wsURL: config.imWebSocketURL, token: res.token, appId: config.appId) + client?.setCurrentUserId(userId) client?.delegate = delegate client?.connect() } @@ -36,11 +39,12 @@ public final class ImSDK { 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) + ?? 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) } @@ -52,7 +56,7 @@ public final class ImSDK { width: Int? = nil, height: Int? = nil, size: Int64? = nil - ) { + ) -> ImMessage { sendMessage( toId: toId, chatType: chatType, @@ -74,7 +78,7 @@ public final class ImSDK { thumbnailUrl: String? = nil, duration: Int64? = nil, size: Int64? = nil - ) { + ) -> ImMessage { sendMessage( toId: toId, chatType: chatType, @@ -94,7 +98,7 @@ public final class ImSDK { url: String, duration: Int64? = nil, size: Int64? = nil - ) { + ) -> ImMessage { sendMessage( toId: toId, chatType: chatType, @@ -113,7 +117,7 @@ public final class ImSDK { url: String, name: String, size: Int64 - ) { + ) -> ImMessage { sendMessage( toId: toId, chatType: chatType, @@ -133,7 +137,7 @@ public final class ImSDK { lng: Double, title: String? = nil, address: String? = nil - ) { + ) -> ImMessage { sendMessage( toId: toId, 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( toId: toId, 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( toId: toId, 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( toId: toId, chatType: chatType, @@ -180,7 +184,7 @@ public final class ImSDK { quotedMsgId: String, quotedContent: String, text: String - ) { + ) -> ImMessage { sendMessage( toId: toId, chatType: chatType, @@ -198,7 +202,7 @@ public final class ImSDK { chatType: ChatType, title: String, msgList: [String] - ) { + ) -> ImMessage { sendMessage( toId: toId, 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( toId: toId, 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( toId: toId, 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( toId: toId, chatType: chatType, @@ -252,6 +256,28 @@ public final class ImSDK { 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( toId: String, 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] { let config = XuqmSDK.shared.requireConfig() return try await ApiClient.shared.request( diff --git a/Sources/XuqmSDK/IM/ImTypes.swift b/Sources/XuqmSDK/IM/ImTypes.swift index 3a6e3b8..1963738 100644 --- a/Sources/XuqmSDK/IM/ImTypes.swift +++ b/Sources/XuqmSDK/IM/ImTypes.swift @@ -107,7 +107,19 @@ public struct BlacklistEntry: Codable, Sendable { 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 let messageId: String public let toId: String public let chatType: ChatType public let msgType: MsgType