feat(im): 添加即时消息SDK核心功能实现
- 实现了聊天消息发送功能,支持文本、图片、视频、音频、文件等多种消息类型 - 集成了文件上传下载功能,支持多媒体文件的传输和管理 - 添加了群组管理功能,包括创建群组、成员管理、权限控制等操作 - 实现了好友系统,支持好友添加、删除、分组等功能 - 集成了黑名单管理,提供用户屏蔽和解除屏蔽功能 - 添加了会话管理功能,支持对话列表、未读消息统计等 - 实现了历史消息查询和搜索功能 - 添加了实时连接状态管理和自动重连机制
这个提交包含在:
父节点
8fee092c44
当前提交
44bb4c2ebe
@ -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) {
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户