feat(ios): add cross-instance message center
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
import Foundation
|
||||
|
||||
public enum MessageServiceError: Error, Equatable {
|
||||
case invalidInput
|
||||
case invalidResponse
|
||||
case unauthorized
|
||||
}
|
||||
|
||||
public struct MessageService: Sendable {
|
||||
private let client: any SimAdminClientProtocol
|
||||
private let sessions: InstanceSessionStore
|
||||
private let credentials: (any CredentialStore)?
|
||||
|
||||
public init(
|
||||
client: any SimAdminClientProtocol,
|
||||
sessions: InstanceSessionStore,
|
||||
credentials: (any CredentialStore)? = nil
|
||||
) {
|
||||
self.client = client
|
||||
self.sessions = sessions
|
||||
self.credentials = credentials
|
||||
}
|
||||
|
||||
public func messages(
|
||||
_ instances: [Instance],
|
||||
limit: Int = 50,
|
||||
direction: SMSDirection? = nil
|
||||
) async throws -> MessagePage {
|
||||
let directionText: String?
|
||||
switch direction {
|
||||
case .incoming: directionText = "incoming"
|
||||
case .outgoing: directionText = "outgoing"
|
||||
default: directionText = nil
|
||||
}
|
||||
|
||||
var collected: [SMSMessage] = []
|
||||
var total = 0
|
||||
var lastError: Error?
|
||||
for instance in instances {
|
||||
do {
|
||||
let response = try await request(.smsList(limit: limit, offset: 0, direction: directionText), instance)
|
||||
let page = try Self.parsePage(response, instance)
|
||||
collected.append(contentsOf: page.messages)
|
||||
total += page.total
|
||||
} catch {
|
||||
lastError = error
|
||||
}
|
||||
}
|
||||
if collected.isEmpty && total == 0, let lastError {
|
||||
throw lastError
|
||||
}
|
||||
collected.sort { $0.timestamp > $1.timestamp }
|
||||
return MessagePage(messages: collected, total: total)
|
||||
}
|
||||
|
||||
public func send(
|
||||
_ instance: Instance,
|
||||
phoneNumber rawPhoneNumber: String,
|
||||
content rawContent: String
|
||||
) async throws {
|
||||
let phoneNumber = rawPhoneNumber.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let content = rawContent.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !phoneNumber.isEmpty, !content.isEmpty else {
|
||||
throw MessageServiceError.invalidInput
|
||||
}
|
||||
_ = try await request(.smsSend(phoneNumber: phoneNumber, content: content), instance)
|
||||
}
|
||||
|
||||
public func delete(_ ids: [String], from instance: Instance) async throws {
|
||||
let numericIDs = ids.compactMap(Int.init)
|
||||
guard !numericIDs.isEmpty else {
|
||||
throw MessageServiceError.invalidInput
|
||||
}
|
||||
_ = try await request(.smsBatchDelete(ids: numericIDs), instance)
|
||||
}
|
||||
|
||||
private func request(_ route: UpstreamRoute, _ instance: Instance) async throws -> UpstreamResponse {
|
||||
var cookie = await sessions.cookie(for: instance.id, origin: instance.origin)
|
||||
do {
|
||||
return try await client.send(route, baseURL: instance.origin, cookie: cookie)
|
||||
} catch UpstreamError.unauthorized, UpstreamError.forbidden {
|
||||
cookie = try await login(instance)
|
||||
return try await client.send(route, baseURL: instance.origin, cookie: cookie)
|
||||
}
|
||||
}
|
||||
|
||||
private func login(_ instance: Instance) async throws -> String {
|
||||
guard let credentials,
|
||||
let password = try credentials.get(instanceID: instance.id),
|
||||
!password.isEmpty
|
||||
else {
|
||||
throw MessageServiceError.unauthorized
|
||||
}
|
||||
let sessions = InstanceSessionService(store: sessions, client: client)
|
||||
let result = try await sessions.login(instance, password: password)
|
||||
guard result.authenticated,
|
||||
let cookie = await sessions.cookie(for: instance)
|
||||
else {
|
||||
throw MessageServiceError.unauthorized
|
||||
}
|
||||
return cookie
|
||||
}
|
||||
|
||||
static func parsePage(_ response: UpstreamResponse, _ instance: Instance) throws -> MessagePage {
|
||||
let root = try JSONSerialization.jsonObject(with: response.data)
|
||||
guard let container = Self.dataContainer(root),
|
||||
let data = container["data"] as? [String: Any] ?? container["response"] as? [String: Any]
|
||||
else {
|
||||
throw MessageServiceError.invalidResponse
|
||||
}
|
||||
let rawMessages = (data["messages"] as? [Any]) ?? []
|
||||
let messages = rawMessages.compactMap { Self.message($0, instance) }
|
||||
let total = (data["total"] as? Int) ?? messages.count
|
||||
return MessagePage(messages: messages, total: total)
|
||||
}
|
||||
|
||||
private static func dataContainer(_ root: Any) -> [String: Any]? {
|
||||
root as? [String: Any]
|
||||
}
|
||||
|
||||
private static func message(_ raw: Any, _ instance: Instance) -> SMSMessage? {
|
||||
guard let value = raw as? [String: Any] else { return nil }
|
||||
let id: String
|
||||
if let number = value["id"] as? Int {
|
||||
id = String(number)
|
||||
} else if let text = value["id"] as? String {
|
||||
id = text
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
let directionText = (value["direction"] as? String)?.lowercased() ?? ""
|
||||
let direction: SMSDirection = directionText == "incoming" ? .incoming
|
||||
: directionText == "outgoing" ? .outgoing
|
||||
: .unknown
|
||||
return SMSMessage(
|
||||
id: id,
|
||||
instanceID: instance.id,
|
||||
instanceName: instance.name,
|
||||
phoneNumber: value["phone_number"] as? String ?? "",
|
||||
content: value["content"] as? String ?? "",
|
||||
direction: direction,
|
||||
status: value["status"] as? String ?? "",
|
||||
timestamp: value["timestamp"] as? String ?? ""
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user