import Alamofire import Foundation import LilithAgentCore import LilithLogging import MacSyncShared import SwiftyJSON private let log = AppLogger.logger(for: "IMessage.API") // MARK: - Protocol protocol APIClientProtocol: AnyObject, Sendable { var isAuthenticated: Bool { get } func syncMessages(_ payload: SyncMessagesPayload) async throws -> Int func syncMessagesBatch(_ payloads: [SyncMessagesPayload]) async throws -> Int func syncContacts(_ contacts: [SyncContactPayload]) async throws -> Int func getStats() async throws -> SyncStatsResponse func getPendingSends() async throws -> [PendingSendMessage] func reportSendResult(messageId: String, status: String, error: String?) async throws func resetSync() async throws -> ResetSyncResponse } // MARK: - APIClient final class APIClient: BaseAPIClient, APIClientProtocol, @unchecked Sendable { static let shared: APIClient = { // Long timeouts for bulk initial-sync batches. Server does per-message // upserts, so a ~1500-message batch can take tens of seconds to commit. let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 300 // 5 min per request config.timeoutIntervalForResource = 900 // 15 min total including retries let session = Session(configuration: config) return APIClient(baseURL: macSyncResolveServerURL(), keychain: macSyncSharedKeychain, session: session) }() var isAuthenticated: Bool { (try? getAuthToken()) != nil } // MARK: - Message Sync func syncMessages(_ payload: SyncMessagesPayload) async throws -> Int { let data = try await authenticatedRequest( "/client/imessage/sync", method: .post, parameters: payload.dictionary ) let json = JSON(data) log.info("syncMessages success=\(json["success"].boolValue) synced=\(json["data"]["synced"].intValue)") guard json["success"].boolValue else { let msg = json["error"]["message"].stringValue throw APIError.serverError(statusCode: json["statusCode"].intValue, message: msg.isEmpty ? "Server error" : msg) } return json["data"]["synced"].intValue } func syncMessagesBatch(_ payloads: [SyncMessagesPayload]) async throws -> Int { let params: [String: Any] = [ "conversations": payloads.map { $0.dictionary } ] let data = try await authenticatedRequest( "/client/imessage/sync/batch", method: .post, parameters: params ) let json = JSON(data) log.info("syncMessagesBatch success=\(json["success"].boolValue) synced=\(json["data"]["synced"].intValue)") guard json["success"].boolValue else { let msg = json["error"]["message"].stringValue throw APIError.serverError(statusCode: json["statusCode"].intValue, message: msg.isEmpty ? "Server error" : msg) } return json["data"]["synced"].intValue } // MARK: - Contacts func syncContacts(_ contacts: [SyncContactPayload]) async throws -> Int { let params: [String: Any] = ["contacts": contacts.map { $0.dictionary }] let data = try await authenticatedRequest("/client/imessage/contacts", method: .post, parameters: params) let json = JSON(data) guard json["success"].boolValue else { throw APIError.serverError(statusCode: 0, message: json["error"]["message"].stringValue) } return json["data"]["synced"].intValue } // MARK: - Stats func getStats() async throws -> SyncStatsResponse { let data = try await authenticatedRequest("/client/imessage/stats", method: .get) let json = JSON(data) guard json["success"].boolValue else { throw APIError.serverError(statusCode: 0, message: json["error"]["message"].stringValue) } return SyncStatsResponse( totalMessages: json["data"]["totalMessages"].intValue, totalConversations: json["data"]["totalConversations"].intValue, totalContacts: json["data"]["totalContacts"].intValue, lastSyncAt: json["data"]["lastSyncAt"].string.flatMap { ISO8601DateFormatter().date(from: $0) } ) } // MARK: - Send Queue func getPendingSends() async throws -> [PendingSendMessage] { let data = try await authenticatedRequest("/client/imessage/send-queue/pending", method: .get) let json = JSON(data) guard json["success"].boolValue else { throw APIError.serverError(statusCode: 0, message: json["error"]["message"].stringValue) } return json["data"]["items"].arrayValue.map { msg in PendingSendMessage( id: msg["id"].stringValue, toHandle: msg["toHandle"].stringValue, body: msg["body"].stringValue, createdAt: msg["createdAt"].stringValue ) } } func reportSendResult(messageId: String, status: String, error: String?) async throws { var params: [String: Any] = ["status": status] if let err = error { params["error"] = err } let data = try await authenticatedRequest("/client/imessage/send-queue/\(messageId)/result", method: .post, parameters: params) let json = JSON(data) guard json["success"].boolValue else { throw APIError.serverError(statusCode: 0, message: json["error"]["message"].stringValue) } } func resetSync() async throws -> ResetSyncResponse { let data = try await authenticatedRequest("/client/imessage/reset", method: .post) let json = JSON(data) guard json["success"].boolValue else { throw APIError.serverError(statusCode: 0, message: json["error"]["message"].stringValue) } return ResetSyncResponse( deletedMessages: json["data"]["deletedMessages"].intValue, deletedConversations: json["data"]["deletedConversations"].intValue ) } // MARK: - Outbox (paced/prioritized outbound — Handoff 03) func fetchOutboxDue(limit: Int) async throws -> [OutboxDueItem] { let data = try await authenticatedRequest("/client/outbox/due?limit=\(limit)", method: .get) let json = JSON(data) guard json["success"].boolValue else { throw APIError.serverError(statusCode: 0, message: json["error"]["message"].stringValue) } return json["data"]["items"].arrayValue.map { item in OutboxDueItem( id: item["id"].stringValue, recipient: item["recipient"].stringValue, body: item["body"].stringValue, channelPref: item["channelPref"].string ?? "auto" ) } } func claimOutbox(id: String) async throws { let data = try await authenticatedRequest("/client/outbox/\(id)/sending", method: .post) let json = JSON(data) guard json["success"].boolValue else { throw APIError.serverError(statusCode: 0, message: json["error"]["message"].stringValue) } } func reportOutboxResult(id: String, status: String, channelUsed: String?, error: String?) async throws { var params: [String: Any] = ["status": status] if let channelUsed { params["channelUsed"] = channelUsed } if let error { params["error"] = error } let data = try await authenticatedRequest("/client/outbox/\(id)/result", method: .post, parameters: params) let json = JSON(data) guard json["success"].boolValue else { throw APIError.serverError(statusCode: 0, message: json["error"]["message"].stringValue) } } } /// Protocol the outbox drain depends on — lets `OutboxSendManager` be exercised /// against a fake without the live `APIClient`. protocol OutboxAPI: AnyObject, Sendable { func fetchOutboxDue(limit: Int) async throws -> [OutboxDueItem] func claimOutbox(id: String) async throws func reportOutboxResult(id: String, status: String, channelUsed: String?, error: String?) async throws } extension APIClient: OutboxAPI {} struct OutboxDueItem: Sendable { let id: String let recipient: String let body: String let channelPref: String } // MARK: - Payload Types struct SyncMessagesPayload: Encodable { let conversationImessageId: String let conversationDisplayName: String let isGroup: Bool let participantIds: [String] let messages: [SyncMessagePayload] var dictionary: [String: Any] { [ "conversationImessageId": conversationImessageId, "conversationDisplayName": conversationDisplayName, "isGroup": isGroup, "participantIds": participantIds, "messages": messages.map { $0.dictionary }, ] } } struct SyncAttachmentPayload: Encodable { let filename: String? let mimeType: String? let transferName: String? let size: Int let data: String? var dictionary: [String: Any] { var d: [String: Any] = ["size": size] if let v = filename { d["filename"] = v } if let v = mimeType { d["mimeType"] = v } if let v = transferName { d["transferName"] = v } if let v = data { d["data"] = v } return d } } struct SyncMessagePayload: Encodable { let imessageGuid: String let senderId: String? let direction: String let sentAt: String let deliveredAt: String? let readAt: String? let text: String? let attributedBody: String? let associatedMessageType: Int? let associatedMessageGuid: String? let isAudioMessage: Bool let expressiveSendStyleId: String? let replyToGuid: String? let threadOriginatorGuid: String? let groupTitle: String? let balloonBundleId: String? let service: String? let senderIdentifier: String? let senderDisplayName: String? let senderPhoneNumber: String? let senderEmail: String? let attachments: [SyncAttachmentPayload] let attachmentsCount: Int let attachmentsTotalSize: Int let attachmentsFiletypes: [String] var dictionary: [String: Any] { var d: [String: Any] = [ "imessageGuid": imessageGuid, "direction": direction, "sentAt": sentAt, "isAudioMessage": isAudioMessage, "attachments": attachments.map { $0.dictionary }, "attachmentsCount": attachmentsCount, "attachmentsTotalSize": attachmentsTotalSize, "attachmentsFiletypes": attachmentsFiletypes, ] if let v = senderId { d["senderId"] = v } if let v = deliveredAt { d["deliveredAt"] = v } if let v = readAt { d["readAt"] = v } if let v = text { d["text"] = v } if let v = attributedBody { d["attributedBody"] = v } if let v = associatedMessageType { d["associatedMessageType"] = v } if let v = associatedMessageGuid { d["associatedMessageGuid"] = v } if let v = expressiveSendStyleId { d["expressiveSendStyleId"] = v } if let v = replyToGuid { d["replyToGuid"] = v } if let v = threadOriginatorGuid { d["threadOriginatorGuid"] = v } if let v = groupTitle { d["groupTitle"] = v } if let v = balloonBundleId { d["balloonBundleId"] = v } if let v = service { d["service"] = v } if let v = senderIdentifier { d["senderIdentifier"] = v } if let v = senderDisplayName { d["senderDisplayName"] = v } if let v = senderPhoneNumber { d["senderPhoneNumber"] = v } if let v = senderEmail { d["senderEmail"] = v } return d } } struct SyncStatsResponse { let totalMessages: Int let totalConversations: Int let totalContacts: Int let lastSyncAt: Date? } struct ResetSyncResponse { let deletedMessages: Int let deletedConversations: Int } struct SyncContactPayload: Encodable { let appleId: String? let phoneNumber: String? let email: String? let displayName: String let avatarHash: String? let birthday: String? var dictionary: [String: Any] { var d: [String: Any] = ["displayName": displayName] if let v = appleId { d["appleId"] = v } if let v = phoneNumber { d["phoneNumber"] = v } if let v = email { d["email"] = v } if let v = avatarHash { d["avatarHash"] = v } if let v = birthday { d["birthday"] = v } return d } } struct PendingSendMessage: Sendable { let id: String let toHandle: String let body: String let createdAt: String }