From 1ea512ae10e679da53ca5252af4c0b1536ac5494 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Tue, 28 Apr 2026 10:27:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(sdk):=20=E6=B7=BB=E5=8A=A0=E5=8D=B3?= =?UTF-8?q?=E6=97=B6=E9=80=9A=E8=AE=AF=E5=92=8C=E6=8E=A8=E9=80=81=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ApiClient 类用于处理 API 请求和响应 - 实现 ImClient 类支持 WebSocket 连接和消息收发 - 添加 ImSDK 类提供完整的即时通讯功能接口 - 定义 ImTypes.swift 包含聊天类型、消息类型等相关数据结构 - 实现 PushSDK 类支持推送通知令牌注册 - 添加基础的 UpdateSDK 框架结构 - 集成登录认证和聊天室订阅功能 - 实现群组管理、好友关系和会话功能 - 支持多种消息类型包括文本、图片、视频、音频等 - 提供历史消息查询和黑名单管理功能 --- .gitignore | 1 + Sources/XuqmSDK/Core/ApiClient.swift | 13 +- Sources/XuqmSDK/IM/ImClient.swift | 253 ++++++++-- Sources/XuqmSDK/IM/ImSDK.swift | 628 ++++++++++++++++++++++++- Sources/XuqmSDK/IM/ImTypes.swift | 102 +++- Sources/XuqmSDK/Push/PushSDK.swift | 2 - Sources/XuqmSDK/Update/UpdateSDK.swift | 9 + Tests/XuqmSDKTests/SmokeTests.swift | 8 + 8 files changed, 971 insertions(+), 45 deletions(-) create mode 100644 Tests/XuqmSDKTests/SmokeTests.swift diff --git a/.gitignore b/.gitignore index 10dfc90..cd1614f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ build/ *.iml .idea/ *.log +/.build/ diff --git a/Sources/XuqmSDK/Core/ApiClient.swift b/Sources/XuqmSDK/Core/ApiClient.swift index fdec9f9..206b0b5 100644 --- a/Sources/XuqmSDK/Core/ApiClient.swift +++ b/Sources/XuqmSDK/Core/ApiClient.swift @@ -7,6 +7,10 @@ public struct ApiResponse: Decodable { public let message: String } +public struct EmptyResponse: Decodable, Sendable { + public init() {} +} + public final class ApiClient: @unchecked Sendable { public static let shared = ApiClient() @@ -47,7 +51,12 @@ public final class ApiClient: @unchecked Sendable { } let wrapper = try JSONDecoder().decode(ApiResponse.self, from: data) - guard let result = wrapper.data else { throw URLError(.cannotDecodeContentData) } - return result + if let result = wrapper.data { + return result + } + if T.self == EmptyResponse.self { + return EmptyResponse() as! T + } + throw URLError(.cannotDecodeContentData) } } diff --git a/Sources/XuqmSDK/IM/ImClient.swift b/Sources/XuqmSDK/IM/ImClient.swift index 6d7308c..e32365b 100644 --- a/Sources/XuqmSDK/IM/ImClient.swift +++ b/Sources/XuqmSDK/IM/ImClient.swift @@ -3,78 +3,267 @@ import Foundation public final class ImClient: NSObject, URLSessionWebSocketDelegate, @unchecked Sendable { public weak var delegate: ImEventDelegate? + private var webSocketTask: URLSessionWebSocketTask? private var session: URLSession? - private let wsURL: URL - private let token: String - private let appId: String - private var shouldReconnect = false + private var reconnectWorkItem: DispatchWorkItem? + private var reconnectDelay: TimeInterval = 3 + private var shouldReconnect = true + private let subscriptionId = "sub-user-queue" + private var groupSubscriptions = Set() - public init(wsURL: URL, token: String, appId: String) { - self.wsURL = wsURL - self.token = token - self.appId = appId + private let wsURLOverride: URL? + private let tokenOverride: String? + private let appIdOverride: String? + + private var activeWsURL: URL? + private var activeToken: String? + private var activeAppId: String? + + public init(wsURL: URL? = nil, token: String? = nil, appId: String? = nil) { + self.wsURLOverride = wsURL + self.tokenOverride = token + self.appIdOverride = appId super.init() } public func connect() { shouldReconnect = true - var request = URLRequest(url: wsURL) - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + reconnectWorkItem?.cancel() + reconnectWorkItem = nil + + activeWsURL = wsURLOverride + activeToken = tokenOverride + activeAppId = appIdOverride + + guard let activeWsURL, let activeToken else { + delegate?.imClientDidError("IM config or token not found") + return + } + + if webSocketTask != nil { + webSocketTask?.cancel(with: .goingAway, reason: nil) + } + + let request = URLRequest(url: activeWsURL) session = URLSession(configuration: .default, delegate: self, delegateQueue: nil) webSocketTask = session?.webSocketTask(with: request) webSocketTask?.resume() receiveMessage() + + // Keep the token in sync in case a direct caller provided only the stored token. + self.activeToken = activeToken + } + + public func sendMessage(toId: String, chatType: ChatType, msgType: MsgType, content: String) { + sendMessage(toId: toId, chatType: chatType, msgType: msgType, content: content, mentionedUserIds: nil) + } + + public func sendMessage( + toId: String, + chatType: ChatType, + msgType: MsgType, + content: String, + mentionedUserIds: String? + ) { + guard let activeAppId else { + delegate?.imClientDidError("IM appId not configured") + return + } + sendFrame( + command: "SEND", + headers: [ + "destination": "/app/chat.send", + "content-type": "application/json", + ], + body: encodeJSONString([ + "appId": activeAppId, + "toId": toId, + "chatType": chatType.rawValue, + "msgType": msgType.rawValue, + "content": content, + "mentionedUserIds": mentionedUserIds ?? "", + ]), + ) + } + + public func revoke(messageId: String) { + guard let activeAppId else { + delegate?.imClientDidError("IM appId not configured") + return + } + sendFrame( + command: "SEND", + headers: [ + "destination": "/app/chat.revoke", + "content-type": "application/json", + ], + body: encodeJSONString([ + "appId": activeAppId, + "messageId": messageId, + ]), + ) + } + + public func subscribeGroup(_ groupId: String) { + let subscriptionKey = "group-\(groupId)" + let isNew = groupSubscriptions.insert(groupId).inserted + if isNew, webSocketTask?.state == .running { + sendFrame(command: "SUBSCRIBE", headers: [ + "destination": "/topic/group/\(groupId)", + "id": subscriptionKey, + ]) + } + } + + public func unsubscribeGroup(_ groupId: String) { + groupSubscriptions.remove(groupId) + if webSocketTask?.state == .running { + sendFrame(command: "UNSUBSCRIBE", headers: [ + "id": "group-\(groupId)", + ]) + } + } + + public func disconnect() { + shouldReconnect = false + reconnectWorkItem?.cancel() + reconnectWorkItem = nil + webSocketTask?.cancel(with: .normalClosure, reason: nil) + webSocketTask = nil + session = nil + } + + public func isConnected() -> Bool { + webSocketTask?.state == .running } private func receiveMessage() { webSocketTask?.receive { [weak self] result in + guard let self else { return } switch result { case .success(let message): if case .string(let text) = message { - self?.handleMessage(text) + self.handleMessage(text) } - self?.receiveMessage() + self.receiveMessage() case .failure(let error): - self?.delegate?.imClientDidError(error.localizedDescription) + self.delegate?.imClientDidError(error.localizedDescription) + self.scheduleReconnect() } } } private func handleMessage(_ text: String) { - guard let data = text.data(using: .utf8), - let msg = try? JSONDecoder().decode(ImMessage.self, from: data) else { return } - if msg.chatType == .group { - delegate?.imClientDidReceiveGroupMessage(msg) - } else { - delegate?.imClientDidReceiveMessage(msg) + guard let data = text.data(using: .utf8) else { return } + let raw = String(decoding: data, as: UTF8.self) + for frame in parseFrames(raw) { + switch frame.command { + case "CONNECTED": + reconnectDelay = 3 + sendFrame(command: "SUBSCRIBE", headers: [ + "destination": "/user/queue/messages", + "id": subscriptionId, + ]) + for groupId in groupSubscriptions { + sendFrame(command: "SUBSCRIBE", headers: [ + "destination": "/topic/group/\(groupId)", + "id": "group-\(groupId)", + ]) + } + delegate?.imClientDidConnect() + case "MESSAGE": + guard let messageData = frame.body.data(using: .utf8), + let msg = try? JSONDecoder().decode(ImMessage.self, from: messageData) else { + continue + } + if msg.chatType == .group { + delegate?.imClientDidReceiveGroupMessage(msg) + } else { + delegate?.imClientDidReceiveMessage(msg) + } + case "ERROR": + delegate?.imClientDidError(frame.body.isEmpty ? "WebSocket error" : frame.body) + default: + break + } } } - public func sendMessage(toId: String, chatType: ChatType, msgType: MsgType, content: String) { - let payload: [String: Any] = [ - "type": "chat.send", - "data": ["appId": appId, "toId": toId, "chatType": chatType.rawValue, - "msgType": msgType.rawValue, "content": content] - ] - guard let data = try? JSONSerialization.data(withJSONObject: payload), - let text = String(data: data, encoding: .utf8) else { return } - webSocketTask?.send(.string(text)) { _ in } + private func scheduleReconnect() { + guard shouldReconnect else { return } + reconnectWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + self.reconnectDelay = min(self.reconnectDelay * 2, 30) + self.connect() + } + reconnectWorkItem = workItem + DispatchQueue.global().asyncAfter(deadline: .now() + reconnectDelay, execute: workItem) } - public func disconnect() { - shouldReconnect = false - webSocketTask?.cancel(with: .normalClosure, reason: nil) + private func sendFrame(command: String, headers: [String: String] = [:], body: String = "") { + guard let webSocketTask else { return } + 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 } + } + + private func encodeJSONString(_ object: [String: String]) -> String { + guard let data = try? JSONSerialization.data(withJSONObject: object, options: []), + let text = String(data: data, encoding: .utf8) else { + return "{}" + } + return text + } + + private func parseFrames(_ raw: String) -> [StompFrame] { + raw + .components(separatedBy: "\u{0000}") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .map { frame in + let separator = frame.range(of: "\n\n") + let headerBlock = separator.map { String(frame[..<$0.lowerBound]) } ?? frame + let body = separator.map { String(frame[$0.upperBound...]) } ?? "" + let lines = headerBlock.split(separator: "\n").map(String.init) + let command = lines.first ?? "" + let headers: [String: String] = Dictionary(uniqueKeysWithValues: lines.dropFirst().compactMap { line in + guard let index = line.firstIndex(of: ":") else { return nil } + let key = String(line[.. [ImMessage] { + try await fetchHistoryInternal( + toId: toId, + page: page, + size: size, + msgType: msgType, + keyword: keyword, + startTime: startTime, + endTime: endTime + ) + } + + public func fetchGroupHistory( + groupId: String, + page: Int = 0, + size: Int = 20, + msgType: MsgType? = nil, + keyword: String? = nil, + startTime: Date? = nil, + endTime: Date? = nil + ) async throws -> [ImMessage] { + try await fetchHistoryInternal( + groupId: groupId, + page: page, + size: size, + msgType: msgType, + keyword: keyword, + startTime: startTime, + endTime: endTime, + isGroup: true + ) + } + + public func listGroups() async throws -> [ImGroup] { + let config = XuqmSDK.shared.requireConfig() + let groups: [ImGroup] = try await ApiClient.shared.request( + path: "/api/im/groups", + queryItems: [URLQueryItem(name: "appId", value: config.appId)] + ) + return groups + } + + public func listPublicGroups(keyword: String? = nil) async throws -> [ImGroup] { + let config = XuqmSDK.shared.requireConfig() + var items = [URLQueryItem(name: "appId", value: config.appId)] + if let keyword { items.append(URLQueryItem(name: "keyword", value: keyword)) } + let groups: [ImGroup] = try await ApiClient.shared.request( + path: "/api/im/groups/public", + queryItems: items + ) + return groups + } + + public func createGroup(name: String, memberIds: [String], groupType: String = "WORK") async throws -> ImGroup { + let config = XuqmSDK.shared.requireConfig() + return try await ApiClient.shared.request( + path: "/api/im/groups", + method: "POST", + queryItems: [URLQueryItem(name: "appId", value: config.appId)], + body: ImCreateGroupRequest(name: name, memberIds: memberIds, groupType: groupType) + ) + } + + public func getGroupInfo(groupId: String) async throws -> ImGroup { + try await ApiClient.shared.request(path: "/api/im/groups/\(groupId)") + } + + public func updateGroupInfo(groupId: String, name: String? = nil, announcement: String? = nil) async throws -> ImGroup { + try await ApiClient.shared.request( + path: "/api/im/groups/\(groupId)", + method: "PUT", + body: ImUpdateGroupRequest(name: name, announcement: announcement) + ) + } + + public func addGroupMember(groupId: String, userId: String) async throws { + let _: EmptyResponse = try await ApiClient.shared.request( + path: "/api/im/groups/\(groupId)/members", + method: "POST", + body: ImMemberRequest(userId: userId) + ) + } + + public func removeGroupMember(groupId: String, targetUserId: String) async throws { + let _: EmptyResponse = try await ApiClient.shared.request( + path: "/api/im/groups/\(groupId)/members/\(targetUserId)", + method: "DELETE" + ) + } + + public func leaveGroup(groupId: String) async throws { + let _: EmptyResponse = try await ApiClient.shared.request( + path: "/api/im/groups/\(groupId)/members/me", + method: "DELETE" + ) + } + + public func setGroupRole(groupId: String, userId: String, role: String) async throws -> ImGroup { + try await ApiClient.shared.request( + path: "/api/im/groups/\(groupId)/roles", + method: "POST", + body: ImSetRoleRequest(userId: userId, role: role) + ) + } + + public func muteGroupMember(groupId: String, userId: String, minutes: Int64) async throws -> ImGroup { + try await ApiClient.shared.request( + path: "/api/im/groups/\(groupId)/mute", + method: "POST", + body: ImMuteMemberRequest(userId: userId, minutes: minutes) + ) + } + + public func dismissGroup(groupId: String) async throws { + let _: EmptyResponse = try await ApiClient.shared.request( + path: "/api/im/groups/\(groupId)", + method: "DELETE" + ) + } + + public func sendGroupJoinRequest(groupId: String, remark: String? = nil) async throws -> GroupJoinRequest { + let config = XuqmSDK.shared.requireConfig() + var items = [URLQueryItem(name: "appId", value: config.appId)] + if let remark { items.append(URLQueryItem(name: "remark", value: remark)) } + return try await ApiClient.shared.request( + path: "/api/im/groups/\(groupId)/join-requests", + method: "POST", + queryItems: items + ) + } + + public func listGroupJoinRequests(groupId: String) async throws -> [GroupJoinRequest] { + let config = XuqmSDK.shared.requireConfig() + return try await ApiClient.shared.request( + path: "/api/im/groups/\(groupId)/join-requests", + queryItems: [URLQueryItem(name: "appId", value: config.appId)] + ) + } + + public func acceptGroupJoinRequest(groupId: String, requestId: String) async throws -> GroupJoinRequest { + let config = XuqmSDK.shared.requireConfig() + return try await ApiClient.shared.request( + path: "/api/im/groups/\(groupId)/join-requests/\(requestId)/accept", + method: "POST", + queryItems: [URLQueryItem(name: "appId", value: config.appId)] + ) + } + + public func rejectGroupJoinRequest(groupId: String, requestId: String) async throws -> GroupJoinRequest { + let config = XuqmSDK.shared.requireConfig() + return try await ApiClient.shared.request( + path: "/api/im/groups/\(groupId)/join-requests/\(requestId)/reject", + method: "POST", + queryItems: [URLQueryItem(name: "appId", value: config.appId)] + ) + } + + public func listFriends() async throws -> [String] { + let config = XuqmSDK.shared.requireConfig() + return try await ApiClient.shared.request( + path: "/api/im/friends", + queryItems: [URLQueryItem(name: "appId", value: config.appId)] + ) + } + + public func addFriend(friendId: String) async throws { + let config = XuqmSDK.shared.requireConfig() + let _: EmptyResponse = try await ApiClient.shared.request( + path: "/api/im/friends", + method: "POST", + queryItems: [ + URLQueryItem(name: "appId", value: config.appId), + URLQueryItem(name: "friendId", value: friendId), + ] + ) + } + + public func removeFriend(friendId: String) async throws { + let config = XuqmSDK.shared.requireConfig() + let _: EmptyResponse = try await ApiClient.shared.request( + path: "/api/im/friends/\(friendId)", + method: "DELETE", + queryItems: [URLQueryItem(name: "appId", value: config.appId)] + ) + } + + public func listFriendRequests(direction: String = "incoming") async throws -> [FriendRequest] { + let config = XuqmSDK.shared.requireConfig() + return try await ApiClient.shared.request( + path: "/api/im/friend-requests", + queryItems: [ + URLQueryItem(name: "appId", value: config.appId), + URLQueryItem(name: "direction", value: direction), + ] + ) + } + + public func sendFriendRequest(toUserId: String, remark: String? = nil) async throws -> FriendRequest { + let config = XuqmSDK.shared.requireConfig() + var items = [ + URLQueryItem(name: "appId", value: config.appId), + URLQueryItem(name: "toUserId", value: toUserId), + ] + if let remark { items.append(URLQueryItem(name: "remark", value: remark)) } + return try await ApiClient.shared.request( + path: "/api/im/friend-requests", + method: "POST", + queryItems: items + ) + } + + public func acceptFriendRequest(requestId: String) async throws -> FriendRequest { + let config = XuqmSDK.shared.requireConfig() + return try await ApiClient.shared.request( + path: "/api/im/friend-requests/\(requestId)/accept", + method: "POST", + queryItems: [URLQueryItem(name: "appId", value: config.appId)] + ) + } + + public func rejectFriendRequest(requestId: String) async throws -> FriendRequest { + let config = XuqmSDK.shared.requireConfig() + return try await ApiClient.shared.request( + path: "/api/im/friend-requests/\(requestId)/reject", + method: "POST", + queryItems: [URLQueryItem(name: "appId", value: config.appId)] + ) + } + + public func listBlacklist() async throws -> [BlacklistEntry] { + let config = XuqmSDK.shared.requireConfig() + return try await ApiClient.shared.request( + path: "/api/im/blacklist", + queryItems: [URLQueryItem(name: "appId", value: config.appId)] + ) + } + + public func addToBlacklist(blockedUserId: String) async throws -> BlacklistEntry { + let config = XuqmSDK.shared.requireConfig() + return try await ApiClient.shared.request( + path: "/api/im/blacklist", + method: "POST", + queryItems: [ + URLQueryItem(name: "appId", value: config.appId), + URLQueryItem(name: "blockedUserId", value: blockedUserId), + ] + ) + } + + public func removeFromBlacklist(blockedUserId: String) async throws { + let config = XuqmSDK.shared.requireConfig() + let _: EmptyResponse = try await ApiClient.shared.request( + path: "/api/im/blacklist", + method: "DELETE", + queryItems: [ + URLQueryItem(name: "appId", value: config.appId), + URLQueryItem(name: "blockedUserId", value: blockedUserId), + ] + ) + } + + public func listConversations() async throws -> [ConversationData] { + let config = XuqmSDK.shared.requireConfig() + return try await ApiClient.shared.request( + path: "/api/im/conversations", + queryItems: [URLQueryItem(name: "appId", value: config.appId)] + ) + } + + public func markRead(targetId: String, chatType: ChatType = .single) async throws { + let config = XuqmSDK.shared.requireConfig() + let _: EmptyResponse = try await ApiClient.shared.request( + path: "/api/im/conversations/\(targetId)/read", + method: "PUT", + queryItems: [ + URLQueryItem(name: "appId", value: config.appId), + URLQueryItem(name: "chatType", value: chatType.rawValue), + ] + ) + } + + public func setConversationPinned(targetId: String, chatType: ChatType, pinned: Bool) async throws { + let config = XuqmSDK.shared.requireConfig() + let _: EmptyResponse = try await ApiClient.shared.request( + path: "/api/im/conversations/\(targetId)/pinned", + method: "PUT", + queryItems: [ + URLQueryItem(name: "appId", value: config.appId), + URLQueryItem(name: "chatType", value: chatType.rawValue), + URLQueryItem(name: "pinned", value: String(pinned)), + ] + ) + } + + public func setConversationMuted(targetId: String, chatType: ChatType, muted: Bool) async throws { + let config = XuqmSDK.shared.requireConfig() + let _: EmptyResponse = try await ApiClient.shared.request( + path: "/api/im/conversations/\(targetId)/muted", + method: "PUT", + queryItems: [ + URLQueryItem(name: "appId", value: config.appId), + URLQueryItem(name: "chatType", value: chatType.rawValue), + URLQueryItem(name: "muted", value: String(muted)), + ] + ) + } + + public func setDraft(targetId: String, chatType: ChatType, draft: String) async throws { + let config = XuqmSDK.shared.requireConfig() + let _: EmptyResponse = try await ApiClient.shared.request( + path: "/api/im/conversations/\(targetId)/draft", + method: "PUT", + queryItems: [ + URLQueryItem(name: "appId", value: config.appId), + URLQueryItem(name: "chatType", value: chatType.rawValue), + URLQueryItem(name: "draft", value: draft), + ] + ) + } + + public func deleteConversation(targetId: String, chatType: ChatType) async throws { + let config = XuqmSDK.shared.requireConfig() + let _: EmptyResponse = try await ApiClient.shared.request( + path: "/api/im/conversations/\(targetId)", + method: "DELETE", + queryItems: [ + URLQueryItem(name: "appId", value: config.appId), + URLQueryItem(name: "chatType", value: chatType.rawValue), + ] + ) + } + + public func getTotalUnreadCount() async throws -> Int { + let conversations = try await listConversations() + return conversations.reduce(0) { $0 + $1.unreadCount } + } + public func disconnect() { client?.disconnect() client = nil } + + private func fetchHistoryInternal( + toId: String? = nil, + groupId: String? = nil, + page: Int, + size: Int, + msgType: MsgType?, + keyword: String?, + startTime: Date?, + endTime: Date?, + isGroup: Bool = false + ) async throws -> [ImMessage] { + let config = XuqmSDK.shared.requireConfig() + var items = [ + URLQueryItem(name: "appId", value: config.appId), + URLQueryItem(name: "page", value: String(page)), + URLQueryItem(name: "size", value: String(size)), + ] + if let msgType { items.append(URLQueryItem(name: "msgType", value: msgType.rawValue)) } + if let keyword { items.append(URLQueryItem(name: "keyword", value: keyword)) } + if let startTime { items.append(URLQueryItem(name: "startTime", value: isoLocalDateTime(startTime))) } + if let endTime { items.append(URLQueryItem(name: "endTime", value: isoLocalDateTime(endTime))) } + + let path = isGroup + ? "/api/im/messages/group-history/\(groupId ?? "")" + : "/api/im/messages/history/\(toId ?? "")" + return try await ApiClient.shared.request(path: path, queryItems: items) + } + + private func jsonString(from object: [String: Any?]) -> String { + var payload: [String: Any] = [:] + for (key, value) in object { + if let value { payload[key] = value } + } + guard JSONSerialization.isValidJSONObject(payload), + let data = try? JSONSerialization.data(withJSONObject: payload, options: []), + let text = String(data: data, encoding: .utf8) else { + return "{}" + } + return text + } + + private func isoLocalDateTime(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = .current + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + return formatter.string(from: date) + } } diff --git a/Sources/XuqmSDK/IM/ImTypes.swift b/Sources/XuqmSDK/IM/ImTypes.swift index 67caa58..3a6e3b8 100644 --- a/Sources/XuqmSDK/IM/ImTypes.swift +++ b/Sources/XuqmSDK/IM/ImTypes.swift @@ -14,14 +14,21 @@ public enum MsgType: String, Codable, Sendable { case custom = "CUSTOM" case location = "LOCATION" case notify = "NOTIFY" - case revoked = "REVOKED" + case richText = "RICH_TEXT" + case callAudio = "CALL_AUDIO" + case callVideo = "CALL_VIDEO" + case quote = "QUOTE" + case merge = "MERGE" case forward = "FORWARD" + case revoked = "REVOKED" } public enum MsgStatus: String, Codable, Sendable { + case sending = "SENDING" case sent = "SENT" case delivered = "DELIVERED" case read = "READ" + case failed = "FAILED" case revoked = "REVOKED" } @@ -35,7 +42,8 @@ public struct ImMessage: Codable, Sendable { public let content: String public let status: MsgStatus public let mentionedUserIds: String? - public let createdAt: String + public let groupReadCount: Int? + public let createdAt: Int64 } public protocol ImEventDelegate: AnyObject { @@ -45,3 +53,93 @@ public protocol ImEventDelegate: AnyObject { func imClientDidReceiveGroupMessage(_ message: ImMessage) func imClientDidError(_ error: String) } + +public struct ImGroup: Codable, Sendable { + public let id: String + public let appId: String + public let name: String + public let groupType: String? + public let creatorId: String + public let memberIds: String + public let adminIds: String + public let announcement: String? + public let createdAt: Int64 +} + +public struct ConversationData: Codable, Sendable { + public let targetId: String + public let chatType: ChatType + public let lastMsgContent: String? + public let lastMsgType: String? + public let lastMsgTime: Int64 + public let unreadCount: Int + public let isMuted: Bool + public let isPinned: Bool +} + +public struct FriendRequest: Codable, Sendable { + public let id: String + public let appId: String + public let fromUserId: String + public let toUserId: String + public let remark: String? + public let status: String + public let createdAt: Int64 + public let reviewedAt: Int64? +} + +public struct GroupJoinRequest: Codable, Sendable { + public let id: String + public let appId: String + public let groupId: String + public let requesterId: String + public let remark: String? + public let status: String + public let createdAt: Int64 + public let reviewedAt: Int64? +} + +public struct BlacklistEntry: Codable, Sendable { + public let id: String + public let appId: String + public let userId: String + public let blockedUserId: String + public let createdAt: Int64 +} + +public struct ImSendMessageRequest: Encodable, Sendable { + public let toId: String + public let chatType: ChatType + public let msgType: MsgType + public let content: String + public let mentionedUserIds: String? +} + +public struct ImLoginResponse: Decodable, Sendable { + public let token: String +} + +public struct ImCreateGroupRequest: Encodable, Sendable { + public let name: String + public let memberIds: [String] + public let groupType: String? +} + +public struct ImUpdateGroupRequest: Encodable, Sendable { + public let name: String? + public let announcement: String? +} + +public struct ImMemberRequest: Encodable, Sendable { + public let userId: String +} + +public struct ImSetRoleRequest: Encodable, Sendable { + public let userId: String + public let role: String +} + +public struct ImMuteMemberRequest: Encodable, Sendable { + public let userId: String + public let minutes: Int64 +} diff --git a/Sources/XuqmSDK/Push/PushSDK.swift b/Sources/XuqmSDK/Push/PushSDK.swift index 8b35a89..a290174 100644 --- a/Sources/XuqmSDK/Push/PushSDK.swift +++ b/Sources/XuqmSDK/Push/PushSDK.swift @@ -25,5 +25,3 @@ public final class PushSDK { ) } } - -private struct EmptyResponse: Decodable {} diff --git a/Sources/XuqmSDK/Update/UpdateSDK.swift b/Sources/XuqmSDK/Update/UpdateSDK.swift index a60d341..a662115 100644 --- a/Sources/XuqmSDK/Update/UpdateSDK.swift +++ b/Sources/XuqmSDK/Update/UpdateSDK.swift @@ -1,5 +1,10 @@ import Foundation + +#if canImport(UIKit) import UIKit +#elseif canImport(AppKit) +import AppKit +#endif public struct AppUpdateInfo: Decodable, Sendable { public let needsUpdate: Bool @@ -39,7 +44,11 @@ public final class UpdateSDK { public func openAppStore(url: String) { guard let storeURL = URL(string: url) else { return } + #if canImport(UIKit) UIApplication.shared.open(storeURL) + #elseif canImport(AppKit) + NSWorkspace.shared.open(storeURL) + #endif } public func checkRnUpdate(moduleId: String, currentVersion: String) async throws -> RnUpdateInfo { diff --git a/Tests/XuqmSDKTests/SmokeTests.swift b/Tests/XuqmSDKTests/SmokeTests.swift new file mode 100644 index 0000000..e240c57 --- /dev/null +++ b/Tests/XuqmSDKTests/SmokeTests.swift @@ -0,0 +1,8 @@ +import XCTest +@testable import XuqmSDK + +final class SmokeTests: XCTestCase { + func testPlaceholder() { + XCTAssertTrue(true) + } +}