feat(im): 添加即时消息SDK核心功能实现

- 实现了聊天消息发送功能,支持文本、图片、视频、音频、文件等多种消息类型
- 集成了文件上传下载功能,支持多媒体文件的传输和管理
- 添加了群组管理功能,包括创建群组、成员管理、权限控制等操作
- 实现了好友系统,支持好友添加、删除、分组等功能
- 集成了黑名单管理,提供用户屏蔽和解除屏蔽功能
- 添加了会话管理功能,支持对话列表、未读消息统计等
- 实现了历史消息查询和搜索功能
- 添加了实时连接状态管理和自动重连机制
这个提交包含在:
XuqmGroup 2026-05-03 00:11:06 +08:00
父节点 8fee092c44
当前提交 44bb4c2ebe
共有 3 个文件被更改,包括 254 次插入6 次删除

查看文件

@ -2,16 +2,13 @@ import Foundation
public struct SDKConfig: Sendable {
public let appKey: String
public let appSecret: String
public let debug: Bool
public init(
appKey: String,
appSecret: String,
debug: Bool = false
) {
self.appKey = appKey
self.appSecret = appSecret
self.debug = debug
}
}
@ -19,12 +16,10 @@ public struct SDKConfig: Sendable {
public extension SDKConfig {
static func development(
appKey: String,
appSecret: String,
debug: Bool = false
) -> SDKConfig {
SDKConfig(
appKey: appKey,
appSecret: appSecret,
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
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] = []
@ -41,6 +47,10 @@ public final class ImSDK {
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)
@ -51,6 +61,25 @@ public final class ImSDK {
}
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,
@ -74,6 +103,23 @@ public final class ImSDK {
}
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,
@ -95,6 +141,22 @@ public final class ImSDK {
}
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,
@ -114,6 +176,21 @@ public final class ImSDK {
}
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,
@ -789,10 +866,13 @@ public final class ImSDK {
public func listConversations() async throws -> [ConversationData] {
let config = XuqmSDK.shared.requireConfig()
return try await ApiClient.shared.request(
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 {
@ -1081,6 +1161,90 @@ public final class ImSDK {
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
@ -1097,18 +1261,22 @@ extension ImSDK: ImEventDelegate {
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) {