import Foundation @MainActor public final class ImSDK { public static let shared = ImSDK() private var client: ImClient? private weak var delegate: ImEventDelegate? private var currentUserId: String? private init() {} public func login(userId: String, nickname: String? = nil, avatar: String? = nil) async throws { let config = XuqmSDK.shared.requireConfig() var items = [ URLQueryItem(name: "appId", value: config.appId), URLQueryItem(name: "userId", value: userId), ] if let nickname { items.append(URLQueryItem(name: "nickname", value: nickname)) } if let avatar { items.append(URLQueryItem(name: "avatar", value: avatar)) } let res: ImLoginResponse = try await ApiClient.shared.request( path: "/api/im/auth/login", 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() } public func setDelegate(_ delegate: ImEventDelegate) { self.delegate = delegate client?.delegate = 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, 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, 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, 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, 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) { client?.revoke(messageId: messageId) } 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 ?? "", 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, 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 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 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 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 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( 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) } }