feat(im): 添加即时消息SDK核心功能实现
- 实现了聊天消息发送功能,支持文本、图片、视频、音频、文件等多种消息类型 - 集成了文件上传下载功能,支持多媒体文件的传输和管理 - 添加了群组管理功能,包括创建群组、成员管理、权限控制等操作 - 实现了好友系统,支持好友添加、删除、分组等功能 - 集成了黑名单管理,提供用户屏蔽和解除屏蔽功能 - 添加了会话管理功能,支持对话列表、未读消息统计等 - 实现了历史消息查询和搜索功能 - 添加了实时连接状态管理和自动重连机制
这个提交包含在:
父节点
8fee092c44
当前提交
44bb4c2ebe
@ -2,16 +2,13 @@ import Foundation
|
|||||||
|
|
||||||
public struct SDKConfig: Sendable {
|
public struct SDKConfig: Sendable {
|
||||||
public let appKey: String
|
public let appKey: String
|
||||||
public let appSecret: String
|
|
||||||
public let debug: Bool
|
public let debug: Bool
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
appKey: String,
|
appKey: String,
|
||||||
appSecret: String,
|
|
||||||
debug: Bool = false
|
debug: Bool = false
|
||||||
) {
|
) {
|
||||||
self.appKey = appKey
|
self.appKey = appKey
|
||||||
self.appSecret = appSecret
|
|
||||||
self.debug = debug
|
self.debug = debug
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -19,12 +16,10 @@ public struct SDKConfig: Sendable {
|
|||||||
public extension SDKConfig {
|
public extension SDKConfig {
|
||||||
static func development(
|
static func development(
|
||||||
appKey: String,
|
appKey: String,
|
||||||
appSecret: String,
|
|
||||||
debug: Bool = false
|
debug: Bool = false
|
||||||
) -> SDKConfig {
|
) -> SDKConfig {
|
||||||
SDKConfig(
|
SDKConfig(
|
||||||
appKey: appKey,
|
appKey: appKey,
|
||||||
appSecret: appSecret,
|
|
||||||
debug: debug
|
debug: debug
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,85 @@
|
|||||||
|
import Foundation
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
public struct FileUploadResult: Codable, Sendable {
|
||||||
|
public let url: String
|
||||||
|
public let thumbnailUrl: String?
|
||||||
|
public let hash: String
|
||||||
|
public let size: Int64
|
||||||
|
public let originalName: String?
|
||||||
|
public let mimeType: String?
|
||||||
|
public let ext: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
public final class FileSDK: @unchecked Sendable {
|
||||||
|
public static let shared = FileSDK()
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
public func upload(fileURL: URL, thumbnailData: Data? = nil) async throws -> FileUploadResult {
|
||||||
|
let config = await XuqmSDK.shared.requireConfig()
|
||||||
|
let tokenStore = await XuqmSDK.shared.tokenStore
|
||||||
|
let boundary = "Boundary-\(UUID().uuidString)"
|
||||||
|
|
||||||
|
let url = SDKEndpoints.apiBaseURL.appendingPathComponent("api/file/upload")
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||||
|
if let token = tokenStore?.get() {
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = try createMultipartBody(fileURL: fileURL, boundary: boundary, thumbnailData: thumbnailData)
|
||||||
|
let (data, response) = try await URLSession.shared.upload(for: request, from: body)
|
||||||
|
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
|
||||||
|
throw URLError(.badServerResponse)
|
||||||
|
}
|
||||||
|
let wrapper = try JSONDecoder().decode(ApiResponse<FileUploadResult>.self, from: data)
|
||||||
|
guard let result = wrapper.data else {
|
||||||
|
throw URLError(.cannotDecodeContentData)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createMultipartBody(fileURL: URL, boundary: String, thumbnailData: Data?) throws -> Data {
|
||||||
|
var body = Data()
|
||||||
|
let filename = fileURL.lastPathComponent
|
||||||
|
|
||||||
|
let mimeType: String
|
||||||
|
if #available(iOS 14.0, macOS 11.0, *) {
|
||||||
|
if let uti = UTType(filenameExtension: (fileURL.path as NSString).pathExtension),
|
||||||
|
let preferred = uti.preferredMIMEType {
|
||||||
|
mimeType = preferred
|
||||||
|
} else {
|
||||||
|
mimeType = "application/octet-stream"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mimeType = "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
body.append("--\(boundary)\r\n")
|
||||||
|
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n")
|
||||||
|
body.append("Content-Type: \(mimeType)\r\n\r\n")
|
||||||
|
body.append(try Data(contentsOf: fileURL))
|
||||||
|
body.append("\r\n")
|
||||||
|
|
||||||
|
if let thumbnailData {
|
||||||
|
let thumbFilename = "\((filename as NSString).deletingPathExtension)_thumb.jpg"
|
||||||
|
body.append("--\(boundary)\r\n")
|
||||||
|
body.append("Content-Disposition: form-data; name=\"thumbnail\"; filename=\"\(thumbFilename)\"\r\n")
|
||||||
|
body.append("Content-Type: image/jpeg\r\n\r\n")
|
||||||
|
body.append(thumbnailData)
|
||||||
|
body.append("\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
body.append("--\(boundary)--\r\n")
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Data {
|
||||||
|
mutating func append(_ string: String) {
|
||||||
|
if let data = string.data(using: .utf8) {
|
||||||
|
append(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,12 +1,18 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
public protocol ConversationDelegate: AnyObject {
|
||||||
|
func conversationsDidChange(_ conversations: [ConversationData])
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public final class ImSDK {
|
public final class ImSDK {
|
||||||
|
|
||||||
public static let shared = ImSDK()
|
public static let shared = ImSDK()
|
||||||
private var client: ImClient?
|
private var client: ImClient?
|
||||||
private weak var delegate: ImEventDelegate?
|
private weak var delegate: ImEventDelegate?
|
||||||
|
private weak var conversationDelegate: ConversationDelegate?
|
||||||
private var currentUserId: String?
|
private var currentUserId: String?
|
||||||
|
private var _conversations: [ConversationData] = []
|
||||||
|
|
||||||
public private(set) var connectionState: ImConnectionState = .disconnected
|
public private(set) var connectionState: ImConnectionState = .disconnected
|
||||||
private var connectionStateListeners: [(ImConnectionState) -> Void] = []
|
private var connectionStateListeners: [(ImConnectionState) -> Void] = []
|
||||||
@ -41,6 +47,10 @@ public final class ImSDK {
|
|||||||
client?.delegate = self
|
client?.delegate = self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func setConversationDelegate(_ delegate: ConversationDelegate) {
|
||||||
|
self.conversationDelegate = delegate
|
||||||
|
}
|
||||||
|
|
||||||
public func sendMessage(toId: String, chatType: ChatType, msgType: MsgType, content: String) -> ImMessage {
|
public func sendMessage(toId: String, chatType: ChatType, msgType: MsgType, content: String) -> ImMessage {
|
||||||
client?.sendMessage(toId: toId, chatType: chatType, msgType: msgType, content: content)
|
client?.sendMessage(toId: toId, chatType: chatType, msgType: msgType, content: content)
|
||||||
?? fallbackFailedMessage(toId: toId, chatType: chatType, msgType: msgType, content: content, mentionedUserIds: nil)
|
?? fallbackFailedMessage(toId: toId, chatType: chatType, msgType: msgType, content: content, mentionedUserIds: nil)
|
||||||
@ -51,6 +61,25 @@ public final class ImSDK {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func sendImageMessage(
|
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,
|
toId: String,
|
||||||
chatType: ChatType,
|
chatType: ChatType,
|
||||||
url: String,
|
url: String,
|
||||||
@ -74,6 +103,23 @@ public final class ImSDK {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func sendVideoMessage(
|
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,
|
toId: String,
|
||||||
chatType: ChatType,
|
chatType: ChatType,
|
||||||
url: String,
|
url: String,
|
||||||
@ -95,6 +141,22 @@ public final class ImSDK {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func sendAudioMessage(
|
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,
|
toId: String,
|
||||||
chatType: ChatType,
|
chatType: ChatType,
|
||||||
url: String,
|
url: String,
|
||||||
@ -114,6 +176,21 @@ public final class ImSDK {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func sendFileMessage(
|
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,
|
toId: String,
|
||||||
chatType: ChatType,
|
chatType: ChatType,
|
||||||
url: String,
|
url: String,
|
||||||
@ -789,10 +866,13 @@ public final class ImSDK {
|
|||||||
|
|
||||||
public func listConversations() async throws -> [ConversationData] {
|
public func listConversations() async throws -> [ConversationData] {
|
||||||
let config = XuqmSDK.shared.requireConfig()
|
let config = XuqmSDK.shared.requireConfig()
|
||||||
return try await ApiClient.shared.request(
|
let result: [ConversationData] = try await ApiClient.shared.request(
|
||||||
path: "/api/im/conversations",
|
path: "/api/im/conversations",
|
||||||
queryItems: [URLQueryItem(name: "appId", value: config.appId)]
|
queryItems: [URLQueryItem(name: "appId", value: config.appId)]
|
||||||
)
|
)
|
||||||
|
_conversations = result
|
||||||
|
conversationDelegate?.conversationsDidChange(_conversations)
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
public func markRead(targetId: String, chatType: ChatType = .single) async throws {
|
public func markRead(targetId: String, chatType: ChatType = .single) async throws {
|
||||||
@ -1081,6 +1161,90 @@ public final class ImSDK {
|
|||||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
|
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
|
||||||
return formatter.string(from: date)
|
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
|
@MainActor
|
||||||
@ -1097,18 +1261,22 @@ extension ImSDK: ImEventDelegate {
|
|||||||
|
|
||||||
public func imClientDidReceiveMessage(_ message: ImMessage) {
|
public func imClientDidReceiveMessage(_ message: ImMessage) {
|
||||||
delegate?.imClientDidReceiveMessage(message)
|
delegate?.imClientDidReceiveMessage(message)
|
||||||
|
updateConversations(with: message)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func imClientDidReceiveGroupMessage(_ message: ImMessage) {
|
public func imClientDidReceiveGroupMessage(_ message: ImMessage) {
|
||||||
delegate?.imClientDidReceiveGroupMessage(message)
|
delegate?.imClientDidReceiveGroupMessage(message)
|
||||||
|
updateConversations(with: message)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func imClientDidReadMessage(_ message: ImMessage) {
|
public func imClientDidReadMessage(_ message: ImMessage) {
|
||||||
delegate?.imClientDidReadMessage(message)
|
delegate?.imClientDidReadMessage(message)
|
||||||
|
markConversationRead(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func imClientDidReceiveRevokedMessage(_ message: ImMessage) {
|
public func imClientDidReceiveRevokedMessage(_ message: ImMessage) {
|
||||||
delegate?.imClientDidReceiveRevokedMessage(message)
|
delegate?.imClientDidReceiveRevokedMessage(message)
|
||||||
|
handleRevokedMessage(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func imClientDidError(_ error: String) {
|
public func imClientDidError(_ error: String) {
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户