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 ?? "" ) } }