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] = [] private init() {} public func addConnectionStateListener(_ listener: @escaping (ImConnectionState) -> Void) { connectionStateListeners.append(listener) } private func updateConnectionState(_ state: ImConnectionState) { connectionState = state for listener in connectionStateListeners { listener(state) } } public func login(_ userId: String, _ userSig: String) async throws { let config = XuqmSDK.shared.requireConfig() currentUserId = userId XuqmSDK.shared.tokenStore?.save(userSig) client?.disconnect() client = ImClient(token: userSig, appId: config.appId) client?.setCurrentUserId(userId) client?.delegate = self updateConnectionState(.connecting) client?.connect() } public func setDelegate(_ delegate: ImEventDelegate) { self.delegate = delegate 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) } public func sendTextMessage(toId: String, chatType: ChatType, content: String) -> ImMessage { sendMessage(toId: toId, chatType: chatType, msgType: .text, content: content) } 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, thumbnailUrl: String? = nil, width: Int? = nil, height: Int? = nil, size: Int64? = nil ) -> ImMessage { sendMessage( toId: toId, chatType: chatType, msgType: .image, content: jsonString(from: [ "url": url, "thumbnailUrl": thumbnailUrl as Any, "width": width as Any, "height": height as Any, "size": size as Any, ]) ) } 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, thumbnailUrl: String? = nil, duration: Int64? = nil, size: Int64? = nil ) -> ImMessage { sendMessage( toId: toId, chatType: chatType, msgType: .video, content: jsonString(from: [ "url": url, "thumbnailUrl": thumbnailUrl as Any, "duration": duration as Any, "size": size as Any, ]) ) } 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, duration: Int64? = nil, size: Int64? = nil ) -> ImMessage { sendMessage( toId: toId, chatType: chatType, msgType: .audio, content: jsonString(from: [ "url": url, "duration": duration as Any, "size": size as Any, ]) ) } 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, name: String, size: Int64 ) -> ImMessage { sendMessage( toId: toId, chatType: chatType, msgType: .file, content: jsonString(from: [ "url": url, "name": name, "size": size, ]) ) } public func sendLocationMessage( toId: String, chatType: ChatType, lat: Double, lng: Double, title: String? = nil, address: String? = nil ) -> ImMessage { sendMessage( toId: toId, chatType: chatType, msgType: .location, content: jsonString(from: [ "lat": lat, "lng": lng, "title": title as Any, "address": address as Any, ]) ) } public func sendCustomMessage(toId: String, chatType: ChatType, data: String) -> ImMessage { sendMessage( toId: toId, chatType: chatType, msgType: .custom, content: jsonString(from: ["data": data]) ) } public func sendRichTextMessage(toId: String, chatType: ChatType, html: String) -> ImMessage { sendMessage( toId: toId, chatType: chatType, msgType: .richText, content: jsonString(from: ["html": html]) ) } public func sendNotifyMessage(toId: String, chatType: ChatType, title: String, content: String) -> ImMessage { sendMessage( toId: toId, chatType: chatType, msgType: .notify, content: jsonString(from: ["title": title, "content": content]) ) } public func sendQuoteMessage( toId: String, chatType: ChatType, quotedMsgId: String, quotedContent: String, text: String ) -> ImMessage { sendMessage( toId: toId, chatType: chatType, msgType: .quote, content: jsonString(from: [ "quotedMsgId": quotedMsgId, "quotedContent": quotedContent, "text": text, ]) ) } public func sendMergeMessage( toId: String, chatType: ChatType, title: String, msgList: [String] ) -> ImMessage { sendMessage( toId: toId, chatType: chatType, msgType: .merge, content: jsonString(from: [ "title": title, "msgList": msgList, ]) ) } public func sendForwardMessage(toId: String, chatType: ChatType, originalSender: String, originalContent: String) -> ImMessage { sendMessage( toId: toId, chatType: chatType, msgType: .forward, content: jsonString(from: [ "originalSender": originalSender, "originalContent": originalContent, ]) ) } public func sendCallAudioMessage(toId: String, chatType: ChatType, action: String) -> ImMessage { sendMessage( toId: toId, chatType: chatType, msgType: .callAudio, content: jsonString(from: ["action": action]) ) } public func sendCallVideoMessage(toId: String, chatType: ChatType, action: String) -> ImMessage { sendMessage( toId: toId, chatType: chatType, msgType: .callVideo, content: jsonString(from: ["action": action]) ) } public func revokeMessage(messageId: String) async throws -> ImMessage { let config = XuqmSDK.shared.requireConfig() return try await ApiClient.shared.request( path: "/api/im/messages/\(messageId)/revoke", method: "POST", queryItems: [URLQueryItem(name: "appId", value: config.appId)] ) } public func editMessage(messageId: String, content: String) async throws -> ImMessage { let config = XuqmSDK.shared.requireConfig() return try await ApiClient.shared.request( path: "/api/im/messages/\(messageId)", method: "PUT", queryItems: [URLQueryItem(name: "appId", value: config.appId)], body: EditMessageRequest(content: content) ) } public func subscribeGroup(_ groupId: String) { client?.subscribeGroup(groupId) } public func unsubscribeGroup(_ groupId: String) { 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 ?? "", fromId: currentUserId, toId: toId, chatType: chatType, msgType: msgType, content: content, status: .failed, mentionedUserIds: mentionedUserIds, groupReadCount: nil, revoked: false, createdAt: Int64(Date().timeIntervalSince1970 * 1000), editedAt: nil ) } public func fetchHistory( toId: 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( 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 locateHistoryPage( toId: String, messageId: String, pageSize: Int = 20, maxPages: Int = 20 ) async throws -> [ImMessage]? { try await locatePage( messageId: messageId, maxPages: maxPages, pageSize: pageSize, loadPage: { page in try await self.fetchHistory(toId: toId, page: page, size: pageSize) } ) } public func locateGroupHistoryPage( groupId: String, messageId: String, pageSize: Int = 20, maxPages: Int = 20 ) async throws -> [ImMessage]? { try await locatePage( messageId: messageId, maxPages: maxPages, pageSize: pageSize, loadPage: { page in try await self.fetchGroupHistory(groupId: groupId, page: page, size: pageSize) } ) } private func locatePage( messageId: String, maxPages: Int, pageSize: Int, loadPage: @escaping (Int) async throws -> [ImMessage] ) async throws -> [ImMessage]? { let pageCount = max(maxPages, 1) for page in 0.. [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 searchUsers(keyword: String, size: Int = 20) async throws -> [UserProfile] { let config = XuqmSDK.shared.requireConfig() return try await ApiClient.shared.request( path: "/api/im/admin/users/search", queryItems: [ URLQueryItem(name: "appId", value: config.appId), URLQueryItem(name: "keyword", value: keyword), URLQueryItem(name: "size", value: String(size)), ] ) } public func searchGroups(keyword: String, size: Int = 20) async throws -> [ImGroup] { let config = XuqmSDK.shared.requireConfig() return try await ApiClient.shared.request( path: "/api/im/admin/groups/search", queryItems: [ URLQueryItem(name: "appId", value: config.appId), URLQueryItem(name: "keyword", value: keyword), URLQueryItem(name: "size", value: String(size)), ] ) } public func searchMessages( keyword: String? = nil, chatType: String? = nil, msgType: String? = nil, startTime: Date? = nil, endTime: Date? = nil, page: Int = 0, size: Int = 20 ) async throws -> PageResult { let config = XuqmSDK.shared.requireConfig() var items: [URLQueryItem] = [ URLQueryItem(name: "appId", value: config.appId), URLQueryItem(name: "page", value: String(page)), URLQueryItem(name: "size", value: String(size)), ] if let keyword, !keyword.isEmpty { items.append(URLQueryItem(name: "keyword", value: keyword)) } if let chatType, !chatType.isEmpty { items.append(URLQueryItem(name: "chatType", value: chatType)) } if let msgType, !msgType.isEmpty { items.append(URLQueryItem(name: "msgType", value: msgType)) } if let startTime { items.append(URLQueryItem(name: "startTime", value: isoLocalDateTime(startTime))) } if let endTime { items.append(URLQueryItem(name: "endTime", value: isoLocalDateTime(endTime))) } return try await ApiClient.shared.request( path: "/api/im/admin/messages/search", queryItems: items ) } 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 listGroupMembers(groupId: String) async throws -> [UserProfile] { let config = XuqmSDK.shared.requireConfig() return try await ApiClient.shared.request( path: "/api/im/groups/\(groupId)/members", queryItems: [URLQueryItem(name: "appId", value: config.appId)] ) } public func searchGroupMembers(groupId: String, keyword: String, size: Int = 20) async throws -> [UserProfile] { let config = XuqmSDK.shared.requireConfig() return try await ApiClient.shared.request( path: "/api/im/groups/\(groupId)/members/search", queryItems: [ URLQueryItem(name: "appId", value: config.appId), URLQueryItem(name: "keyword", value: keyword), URLQueryItem(name: "size", value: String(size)) ] ) } 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 transferGroupOwner(groupId: String, newOwnerId: String) async throws -> ImGroup { try await ApiClient.shared.request( path: "/api/im/groups/\(groupId)/owner", method: "POST", body: ImTransferOwnerRequest(newOwnerId: newOwnerId) ) } public func updateGroupAttributes(groupId: String, attributes: [String: ImAttributeValue]) async throws -> ImGroup { try await ApiClient.shared.request( path: "/api/im/groups/\(groupId)/attributes", method: "PUT", body: attributes ) } public func removeGroupAttributes(groupId: String, keys: [String]) async throws -> ImGroup { try await ApiClient.shared.request( path: "/api/im/groups/\(groupId)/attributes/delete", method: "POST", body: ImAttributeKeysRequest(keys: keys) ) } 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 removeAllFriends() async throws { let config = XuqmSDK.shared.requireConfig() let _: EmptyResponse = try await ApiClient.shared.request( path: "/api/im/friends", method: "DELETE", queryItems: [URLQueryItem(name: "appId", value: config.appId)] ) } 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 setFriendGroup(friendId: String, groupName: String? = nil) async throws { let config = XuqmSDK.shared.requireConfig() var items = [URLQueryItem(name: "appId", value: config.appId)] if let groupName { items.append(URLQueryItem(name: "groupName", value: groupName)) } let _: EmptyResponse = try await ApiClient.shared.request( path: "/api/im/friends/\(friendId)/group", method: "PUT", queryItems: items ) } public func listFriendGroups() async throws -> [String] { let config = XuqmSDK.shared.requireConfig() return try await ApiClient.shared.request( path: "/api/im/friends/groups", queryItems: [URLQueryItem(name: "appId", value: config.appId)] ) } public func listFriendsByGroup(_ groupName: String) async throws -> [String] { let config = XuqmSDK.shared.requireConfig() return try await ApiClient.shared.request( path: "/api/im/friends/groups/\(groupName)", 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 checkBlacklist(targetUserId: String) async throws -> BlacklistCheckResult { let config = XuqmSDK.shared.requireConfig() return try await ApiClient.shared.request( path: "/api/im/blacklist/check", queryItems: [ URLQueryItem(name: "appId", value: config.appId), URLQueryItem(name: "targetUserId", value: targetUserId), ] ) } 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() 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 { 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 setConversationHidden(targetId: String, chatType: ChatType, hidden: Bool) async throws { let config = XuqmSDK.shared.requireConfig() let _: EmptyResponse = try await ApiClient.shared.request( path: "/api/im/conversations/\(targetId)/hidden", method: "PUT", queryItems: [ URLQueryItem(name: "appId", value: config.appId), URLQueryItem(name: "chatType", value: chatType.rawValue), URLQueryItem(name: "hidden", value: String(hidden)), ] ) } public func setConversationGroup(targetId: String, chatType: ChatType, groupName: String? = nil) async throws { let config = XuqmSDK.shared.requireConfig() var items = [ URLQueryItem(name: "appId", value: config.appId), URLQueryItem(name: "chatType", value: chatType.rawValue), ] if let groupName { items.append(URLQueryItem(name: "groupName", value: groupName)) } let _: EmptyResponse = try await ApiClient.shared.request( path: "/api/im/conversations/\(targetId)/group", method: "PUT", queryItems: items ) } public func listConversationGroups() async throws -> [String] { let config = XuqmSDK.shared.requireConfig() return try await ApiClient.shared.request( path: "/api/im/conversation-groups", queryItems: [URLQueryItem(name: "appId", value: config.appId)] ) } public func listConversationGroupItems(_ groupName: String) async throws -> [ConversationGroupItem] { let config = XuqmSDK.shared.requireConfig() return try await ApiClient.shared.request( path: "/api/im/conversation-groups/\(groupName)", queryItems: [URLQueryItem(name: "appId", value: config.appId)] ) } 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 offlineMessageCount() async throws -> Int { let config = XuqmSDK.shared.requireConfig() let result: [String: Int] = try await ApiClient.shared.request( path: "/api/im/messages/offline/count", queryItems: [URLQueryItem(name: "appId", value: config.appId)] ) return result["count"] ?? 0 } public func syncOfflineMessages() async throws -> [ImMessage] { let config = XuqmSDK.shared.requireConfig() return try await ApiClient.shared.request( path: "/api/im/messages/offline", method: "POST", queryItems: [URLQueryItem(name: "appId", value: config.appId)] ) } public func adminGroupReadReceipts(groupId: String, messageIds: [String]) async throws -> [GroupReadReceiptSummary] { let config = XuqmSDK.shared.requireConfig() return try await ApiClient.shared.request( path: "/api/im/admin/groups/\(groupId)/read-receipts", method: "POST", queryItems: [URLQueryItem(name: "appId", value: config.appId)], body: ImGroupReadReceiptRequest(messageIds: messageIds) ) } public func batchAddFriends(friendIds: [String]) async throws { let config = XuqmSDK.shared.requireConfig() let _: EmptyResponse = try await ApiClient.shared.request( path: "/api/im/friends/batch", method: "POST", queryItems: [URLQueryItem(name: "appId", value: config.appId)], body: BatchFriendIdsRequest(friendIds: friendIds) ) } public func batchRemoveFriends(friendIds: [String]) async throws { let config = XuqmSDK.shared.requireConfig() let _: EmptyResponse = try await ApiClient.shared.request( path: "/api/im/friends/batch/remove", method: "POST", queryItems: [URLQueryItem(name: "appId", value: config.appId)], body: BatchFriendIdsRequest(friendIds: friendIds) ) } public func batchAcceptFriendRequests(requestIds: [String]) async throws { let config = XuqmSDK.shared.requireConfig() let _: EmptyResponse = try await ApiClient.shared.request( path: "/api/im/friend-requests/batch/accept", method: "POST", queryItems: [URLQueryItem(name: "appId", value: config.appId)], body: BatchRequestIdsRequest(requestIds: requestIds) ) } public func batchRejectFriendRequests(requestIds: [String]) async throws { let config = XuqmSDK.shared.requireConfig() let _: EmptyResponse = try await ApiClient.shared.request( path: "/api/im/friend-requests/batch/reject", method: "POST", queryItems: [URLQueryItem(name: "appId", value: config.appId)], body: BatchRequestIdsRequest(requestIds: requestIds) ) } public func batchAddGroupMembers(groupId: String, userIds: [String]) async throws { let config = XuqmSDK.shared.requireConfig() let _: EmptyResponse = try await ApiClient.shared.request( path: "/api/im/groups/\(groupId)/members/batch", method: "POST", queryItems: [URLQueryItem(name: "appId", value: config.appId)], body: BatchUserIdsRequest(userIds: userIds) ) } public func batchRemoveGroupMembers(groupId: String, userIds: [String]) async throws { let config = XuqmSDK.shared.requireConfig() let _: EmptyResponse = try await ApiClient.shared.request( path: "/api/im/groups/\(groupId)/members/batch/remove", method: "POST", queryItems: [URLQueryItem(name: "appId", value: config.appId)], body: BatchUserIdsRequest(userIds: userIds) ) } public func batchAcceptGroupJoinRequests(groupId: String, requestIds: [String]) async throws { let config = XuqmSDK.shared.requireConfig() let _: EmptyResponse = try await ApiClient.shared.request( path: "/api/im/groups/\(groupId)/join-requests/batch/accept", method: "POST", queryItems: [URLQueryItem(name: "appId", value: config.appId)], body: BatchRequestIdsRequest(requestIds: requestIds) ) } public func batchRejectGroupJoinRequests(groupId: String, requestIds: [String]) async throws { let config = XuqmSDK.shared.requireConfig() let _: EmptyResponse = try await ApiClient.shared.request( path: "/api/im/groups/\(groupId)/join-requests/batch/reject", method: "POST", queryItems: [URLQueryItem(name: "appId", value: config.appId)], body: BatchRequestIdsRequest(requestIds: requestIds) ) } public func modifyGroupMemberInfo(groupId: String, userId: String, nickname: String? = nil, role: String? = nil) async throws { let config = XuqmSDK.shared.requireConfig() let _: EmptyResponse = try await ApiClient.shared.request( path: "/api/im/groups/\(groupId)/members/\(userId)/info", method: "PUT", queryItems: [URLQueryItem(name: "appId", value: config.appId)], body: ModifyMemberInfoRequest(nickname: nickname, role: role) ) } public func disconnect() { client?.disconnect() client = nil updateConnectionState(.disconnected) } 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) } 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 extension ImSDK: ImEventDelegate { public func imClientDidConnect() { updateConnectionState(.connected) delegate?.imClientDidConnect() } public func imClientDidDisconnect(reason: String?) { updateConnectionState(.disconnected) delegate?.imClientDidDisconnect(reason: reason) } 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) { updateConnectionState(.disconnected) delegate?.imClientDidError(error) } }