feat(sdk): 添加即时通讯和推送功能
- 新增 ApiClient 类用于处理 API 请求和响应 - 实现 ImClient 类支持 WebSocket 连接和消息收发 - 添加 ImSDK 类提供完整的即时通讯功能接口 - 定义 ImTypes.swift 包含聊天类型、消息类型等相关数据结构 - 实现 PushSDK 类支持推送通知令牌注册 - 添加基础的 UpdateSDK 框架结构 - 集成登录认证和聊天室订阅功能 - 实现群组管理、好友关系和会话功能 - 支持多种消息类型包括文本、图片、视频、音频等 - 提供历史消息查询和黑名单管理功能
这个提交包含在:
父节点
2c7ca601ff
当前提交
1ea512ae10
1
.gitignore
vendored
1
.gitignore
vendored
@ -8,3 +8,4 @@ build/
|
||||
*.iml
|
||||
.idea/
|
||||
*.log
|
||||
/.build/
|
||||
|
||||
@ -7,6 +7,10 @@ public struct ApiResponse<T: Decodable>: 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<T>.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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<String>()
|
||||
|
||||
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[..<index])
|
||||
let value = String(line[line.index(after: index)...])
|
||||
return (key, value)
|
||||
})
|
||||
return StompFrame(command: command, headers: headers, body: body)
|
||||
}
|
||||
}
|
||||
|
||||
public func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask,
|
||||
didOpenWithProtocol protocol: String?) {
|
||||
delegate?.imClientDidConnect()
|
||||
guard let token = activeToken else {
|
||||
delegate?.imClientDidError("IM token not found")
|
||||
return
|
||||
}
|
||||
activeToken = token
|
||||
sendFrame(command: "CONNECT", headers: [
|
||||
"accept-version": "1.2",
|
||||
"Authorization": "Bearer \(token)",
|
||||
"heart-beat": "10000,10000",
|
||||
])
|
||||
}
|
||||
|
||||
public func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask,
|
||||
didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
||||
let reasonStr = reason.flatMap { String(data: $0, encoding: .utf8) }
|
||||
delegate?.imClientDidDisconnect(reason: reasonStr)
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private struct StompFrame {
|
||||
let command: String
|
||||
let headers: [String: String]
|
||||
let body: String
|
||||
}
|
||||
|
||||
@ -5,27 +5,34 @@ public final class ImSDK {
|
||||
|
||||
public static let shared = ImSDK()
|
||||
private var client: ImClient?
|
||||
private weak var delegate: ImEventDelegate?
|
||||
|
||||
private init() {}
|
||||
|
||||
public func login(userId: String, nickname: String? = nil, avatar: String? = nil) async throws {
|
||||
let config = XuqmSDK.shared.requireConfig()
|
||||
|
||||
struct LoginResponse: Decodable { let token: String }
|
||||
var items = [URLQueryItem(name: "appId", value: config.appId),
|
||||
URLQueryItem(name: "userId", value: userId)]
|
||||
if let n = nickname { items.append(URLQueryItem(name: "nickname", value: n)) }
|
||||
if let a = avatar { items.append(URLQueryItem(name: "avatar", value: a)) }
|
||||
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: LoginResponse = try await ApiClient.shared.request(
|
||||
path: "/api/im/auth/login", method: "POST", queryItems: items
|
||||
let res: ImLoginResponse = try await ApiClient.shared.request(
|
||||
path: "/api/im/auth/login",
|
||||
method: "POST",
|
||||
queryItems: items
|
||||
)
|
||||
XuqmSDK.shared.tokenStore?.save(res.token)
|
||||
client?.disconnect()
|
||||
client = ImClient(wsURL: config.imWebSocketURL, token: res.token, appId: config.appId)
|
||||
client?.delegate = delegate
|
||||
client?.connect()
|
||||
}
|
||||
|
||||
public func setDelegate(_ delegate: ImEventDelegate) {
|
||||
self.delegate = delegate
|
||||
client?.delegate = delegate
|
||||
}
|
||||
|
||||
@ -33,8 +40,615 @@ public final class ImSDK {
|
||||
client?.sendMessage(toId: toId, chatType: chatType, msgType: msgType, content: content)
|
||||
}
|
||||
|
||||
public func sendTextMessage(toId: String, chatType: ChatType, content: String) {
|
||||
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
|
||||
) {
|
||||
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
|
||||
) {
|
||||
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
|
||||
) {
|
||||
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
|
||||
) {
|
||||
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
|
||||
) {
|
||||
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) {
|
||||
sendMessage(
|
||||
toId: toId,
|
||||
chatType: chatType,
|
||||
msgType: .custom,
|
||||
content: jsonString(from: ["data": data])
|
||||
)
|
||||
}
|
||||
|
||||
public func sendRichTextMessage(toId: String, chatType: ChatType, html: String) {
|
||||
sendMessage(
|
||||
toId: toId,
|
||||
chatType: chatType,
|
||||
msgType: .richText,
|
||||
content: jsonString(from: ["html": html])
|
||||
)
|
||||
}
|
||||
|
||||
public func sendNotifyMessage(toId: String, chatType: ChatType, title: String, content: String) {
|
||||
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
|
||||
) {
|
||||
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]
|
||||
) {
|
||||
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) {
|
||||
sendMessage(
|
||||
toId: toId,
|
||||
chatType: chatType,
|
||||
msgType: .forward,
|
||||
content: jsonString(from: [
|
||||
"originalSender": originalSender,
|
||||
"originalContent": originalContent,
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
public func sendCallAudioMessage(toId: String, chatType: ChatType, action: String) {
|
||||
sendMessage(
|
||||
toId: toId,
|
||||
chatType: chatType,
|
||||
msgType: .callAudio,
|
||||
content: jsonString(from: ["action": action])
|
||||
)
|
||||
}
|
||||
|
||||
public func sendCallVideoMessage(toId: String, chatType: ChatType, action: String) {
|
||||
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)
|
||||
}
|
||||
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -25,5 +25,3 @@ public final class PushSDK {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct EmptyResponse: Decodable {}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
import XCTest
|
||||
@testable import XuqmSDK
|
||||
|
||||
final class SmokeTests: XCTestCase {
|
||||
func testPlaceholder() {
|
||||
XCTAssertTrue(true)
|
||||
}
|
||||
}
|
||||
正在加载...
在新工单中引用
屏蔽一个用户