From 44bb4c2ebe602824974b2bcadbd863d33cf89b8f Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Sun, 3 May 2026 00:11:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(im):=20=E6=B7=BB=E5=8A=A0=E5=8D=B3?= =?UTF-8?q?=E6=97=B6=E6=B6=88=E6=81=AFSDK=E6=A0=B8=E5=BF=83=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现了聊天消息发送功能,支持文本、图片、视频、音频、文件等多种消息类型 - 集成了文件上传下载功能,支持多媒体文件的传输和管理 - 添加了群组管理功能,包括创建群组、成员管理、权限控制等操作 - 实现了好友系统,支持好友添加、删除、分组等功能 - 集成了黑名单管理,提供用户屏蔽和解除屏蔽功能 - 添加了会话管理功能,支持对话列表、未读消息统计等 - 实现了历史消息查询和搜索功能 - 添加了实时连接状态管理和自动重连机制 --- Sources/XuqmSDK/Core/SDKConfig.swift | 5 - Sources/XuqmSDK/File/FileSDK.swift | 85 ++++++++++++++ Sources/XuqmSDK/IM/ImSDK.swift | 170 ++++++++++++++++++++++++++- 3 files changed, 254 insertions(+), 6 deletions(-) create mode 100644 Sources/XuqmSDK/File/FileSDK.swift diff --git a/Sources/XuqmSDK/Core/SDKConfig.swift b/Sources/XuqmSDK/Core/SDKConfig.swift index 0d6871c..8dfb1c0 100644 --- a/Sources/XuqmSDK/Core/SDKConfig.swift +++ b/Sources/XuqmSDK/Core/SDKConfig.swift @@ -2,16 +2,13 @@ import Foundation public struct SDKConfig: Sendable { public let appKey: String - public let appSecret: String public let debug: Bool public init( appKey: String, - appSecret: String, debug: Bool = false ) { self.appKey = appKey - self.appSecret = appSecret self.debug = debug } } @@ -19,12 +16,10 @@ public struct SDKConfig: Sendable { public extension SDKConfig { static func development( appKey: String, - appSecret: String, debug: Bool = false ) -> SDKConfig { SDKConfig( appKey: appKey, - appSecret: appSecret, debug: debug ) } diff --git a/Sources/XuqmSDK/File/FileSDK.swift b/Sources/XuqmSDK/File/FileSDK.swift new file mode 100644 index 0000000..40637aa --- /dev/null +++ b/Sources/XuqmSDK/File/FileSDK.swift @@ -0,0 +1,85 @@ +import Foundation +import UniformTypeIdentifiers + +public struct FileUploadResult: Codable, Sendable { + public let url: String + public let thumbnailUrl: String? + public let hash: String + public let size: Int64 + public let originalName: String? + public let mimeType: String? + public let ext: String? +} + +public final class FileSDK: @unchecked Sendable { + public static let shared = FileSDK() + private init() {} + + public func upload(fileURL: URL, thumbnailData: Data? = nil) async throws -> FileUploadResult { + let config = await XuqmSDK.shared.requireConfig() + let tokenStore = await XuqmSDK.shared.tokenStore + let boundary = "Boundary-\(UUID().uuidString)" + + let url = SDKEndpoints.apiBaseURL.appendingPathComponent("api/file/upload") + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + if let token = tokenStore?.get() { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + let body = try createMultipartBody(fileURL: fileURL, boundary: boundary, thumbnailData: thumbnailData) + let (data, response) = try await URLSession.shared.upload(for: request, from: body) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw URLError(.badServerResponse) + } + let wrapper = try JSONDecoder().decode(ApiResponse.self, from: data) + guard let result = wrapper.data else { + throw URLError(.cannotDecodeContentData) + } + return result + } + + private func createMultipartBody(fileURL: URL, boundary: String, thumbnailData: Data?) throws -> Data { + var body = Data() + let filename = fileURL.lastPathComponent + + let mimeType: String + if #available(iOS 14.0, macOS 11.0, *) { + if let uti = UTType(filenameExtension: (fileURL.path as NSString).pathExtension), + let preferred = uti.preferredMIMEType { + mimeType = preferred + } else { + mimeType = "application/octet-stream" + } + } else { + mimeType = "application/octet-stream" + } + + body.append("--\(boundary)\r\n") + body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n") + body.append("Content-Type: \(mimeType)\r\n\r\n") + body.append(try Data(contentsOf: fileURL)) + body.append("\r\n") + + if let thumbnailData { + let thumbFilename = "\((filename as NSString).deletingPathExtension)_thumb.jpg" + body.append("--\(boundary)\r\n") + body.append("Content-Disposition: form-data; name=\"thumbnail\"; filename=\"\(thumbFilename)\"\r\n") + body.append("Content-Type: image/jpeg\r\n\r\n") + body.append(thumbnailData) + body.append("\r\n") + } + + body.append("--\(boundary)--\r\n") + return body + } +} + +private extension Data { + mutating func append(_ string: String) { + if let data = string.data(using: .utf8) { + append(data) + } + } +} diff --git a/Sources/XuqmSDK/IM/ImSDK.swift b/Sources/XuqmSDK/IM/ImSDK.swift index a1fe736..5e85775 100644 --- a/Sources/XuqmSDK/IM/ImSDK.swift +++ b/Sources/XuqmSDK/IM/ImSDK.swift @@ -1,12 +1,18 @@ import Foundation +public protocol ConversationDelegate: AnyObject { + func conversationsDidChange(_ conversations: [ConversationData]) +} + @MainActor public final class ImSDK { public static let shared = ImSDK() private var client: ImClient? private weak var delegate: ImEventDelegate? + private weak var conversationDelegate: ConversationDelegate? private var currentUserId: String? + private var _conversations: [ConversationData] = [] public private(set) var connectionState: ImConnectionState = .disconnected private var connectionStateListeners: [(ImConnectionState) -> Void] = [] @@ -41,6 +47,10 @@ public final class ImSDK { client?.delegate = self } + public func setConversationDelegate(_ delegate: ConversationDelegate) { + self.conversationDelegate = delegate + } + 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) @@ -51,6 +61,25 @@ public final class ImSDK { } public func sendImageMessage( + toId: String, + chatType: ChatType, + fileURL: URL, + width: Int? = nil, + height: Int? = nil + ) async throws -> ImMessage { + let result = try await FileSDK.shared.upload(fileURL: fileURL) + return sendImageMessageWithURL( + toId: toId, + chatType: chatType, + url: result.url, + thumbnailUrl: result.thumbnailUrl, + width: width, + height: height, + size: result.size + ) + } + + private func sendImageMessageWithURL( toId: String, chatType: ChatType, url: String, @@ -74,6 +103,23 @@ public final class ImSDK { } public func sendVideoMessage( + toId: String, + chatType: ChatType, + fileURL: URL, + duration: Int64? = nil + ) async throws -> ImMessage { + let result = try await FileSDK.shared.upload(fileURL: fileURL) + return sendVideoMessageWithURL( + toId: toId, + chatType: chatType, + url: result.url, + thumbnailUrl: result.thumbnailUrl, + duration: duration, + size: result.size + ) + } + + private func sendVideoMessageWithURL( toId: String, chatType: ChatType, url: String, @@ -95,6 +141,22 @@ public final class ImSDK { } public func sendAudioMessage( + toId: String, + chatType: ChatType, + fileURL: URL, + duration: Int64? = nil + ) async throws -> ImMessage { + let result = try await FileSDK.shared.upload(fileURL: fileURL) + return sendAudioMessageWithURL( + toId: toId, + chatType: chatType, + url: result.url, + duration: duration, + size: result.size + ) + } + + private func sendAudioMessageWithURL( toId: String, chatType: ChatType, url: String, @@ -114,6 +176,21 @@ public final class ImSDK { } public func sendFileMessage( + toId: String, + chatType: ChatType, + fileURL: URL + ) async throws -> ImMessage { + let result = try await FileSDK.shared.upload(fileURL: fileURL) + return sendFileMessageWithURL( + toId: toId, + chatType: chatType, + url: result.url, + name: result.originalName ?? fileURL.lastPathComponent, + size: result.size + ) + } + + private func sendFileMessageWithURL( toId: String, chatType: ChatType, url: String, @@ -789,10 +866,13 @@ public final class ImSDK { public func listConversations() async throws -> [ConversationData] { let config = XuqmSDK.shared.requireConfig() - return try await ApiClient.shared.request( + let result: [ConversationData] = try await ApiClient.shared.request( path: "/api/im/conversations", queryItems: [URLQueryItem(name: "appId", value: config.appId)] ) + _conversations = result + conversationDelegate?.conversationsDidChange(_conversations) + return result } public func markRead(targetId: String, chatType: ChatType = .single) async throws { @@ -1081,6 +1161,90 @@ public final class ImSDK { formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" return formatter.string(from: date) } + + private func getConversationTargetId(_ message: ImMessage) -> String { + if message.chatType == .group { + return message.toId + } else { + return message.fromUserId == currentUserId ? message.toId : message.fromUserId + } + } + + private func updateConversations(with message: ImMessage) { + let targetId = getConversationTargetId(message) + let chatType = message.chatType + if let index = _conversations.firstIndex(where: { $0.targetId == targetId && $0.chatType == chatType }) { + let existing = _conversations[index] + _conversations[index] = ConversationData( + targetId: targetId, + chatType: chatType, + conversationGroup: existing.conversationGroup, + lastMsgContent: message.content, + lastMsgType: message.msgType.rawValue, + lastMsgTime: message.createdAt, + unreadCount: message.fromUserId == currentUserId ? existing.unreadCount : existing.unreadCount + 1, + isMuted: existing.isMuted, + isPinned: existing.isPinned + ) + } else { + _conversations.append(ConversationData( + targetId: targetId, + chatType: chatType, + lastMsgContent: message.content, + lastMsgType: message.msgType.rawValue, + lastMsgTime: message.createdAt, + unreadCount: message.fromUserId == currentUserId ? 0 : 1, + isMuted: false, + isPinned: false + )) + } + _conversations.sort { $0.lastMsgTime > $1.lastMsgTime } + conversationDelegate?.conversationsDidChange(_conversations) + } + + private func markConversationRead(_ message: ImMessage) { + let targetId = getConversationTargetId(message) + let chatType = message.chatType + guard let index = _conversations.firstIndex(where: { $0.targetId == targetId && $0.chatType == chatType }) else { return } + let existing = _conversations[index] + _conversations[index] = ConversationData( + targetId: targetId, + chatType: chatType, + conversationGroup: existing.conversationGroup, + lastMsgContent: existing.lastMsgContent, + lastMsgType: existing.lastMsgType, + lastMsgTime: existing.lastMsgTime, + unreadCount: 0, + isMuted: existing.isMuted, + isPinned: existing.isPinned + ) + conversationDelegate?.conversationsDidChange(_conversations) + } + + private func handleRevokedMessage(_ message: ImMessage) { + let targetId = getConversationTargetId(message) + let chatType = message.chatType + guard let index = _conversations.firstIndex(where: { $0.targetId == targetId && $0.chatType == chatType }) else { return } + let existing = _conversations[index] + let updated: ConversationData + if existing.lastMsgTime == message.createdAt { + updated = ConversationData( + targetId: targetId, + chatType: chatType, + conversationGroup: existing.conversationGroup, + lastMsgContent: "消息已撤回", + lastMsgType: MsgType.revoked.rawValue, + lastMsgTime: existing.lastMsgTime, + unreadCount: existing.unreadCount, + isMuted: existing.isMuted, + isPinned: existing.isPinned + ) + } else { + updated = existing + } + _conversations[index] = updated + conversationDelegate?.conversationsDidChange(_conversations) + } } @MainActor @@ -1097,18 +1261,22 @@ extension ImSDK: ImEventDelegate { public func imClientDidReceiveMessage(_ message: ImMessage) { delegate?.imClientDidReceiveMessage(message) + updateConversations(with: message) } public func imClientDidReceiveGroupMessage(_ message: ImMessage) { delegate?.imClientDidReceiveGroupMessage(message) + updateConversations(with: message) } public func imClientDidReadMessage(_ message: ImMessage) { delegate?.imClientDidReadMessage(message) + markConversationRead(message) } public func imClientDidReceiveRevokedMessage(_ message: ImMessage) { delegate?.imClientDidReceiveRevokedMessage(message) + handleRevokedMessage(message) } public func imClientDidError(_ error: String) {