2026-05-15 17:05:13 -07:00
|
|
|
import Alamofire
|
|
|
|
|
import Foundation
|
|
|
|
|
import LilithAgentCore
|
|
|
|
|
import LilithLogging
|
|
|
|
|
import MacSyncShared
|
|
|
|
|
import SwiftyJSON
|
|
|
|
|
|
|
|
|
|
private let log = AppLogger.logger(for: "IMessage.API")
|
|
|
|
|
|
|
|
|
|
// MARK: - Protocol
|
|
|
|
|
|
2026-05-15 18:06:23 -07:00
|
|
|
protocol APIClientProtocol: AnyObject, Sendable {
|
2026-05-15 17:05:13 -07:00
|
|
|
var isAuthenticated: Bool { get }
|
|
|
|
|
func syncMessages(_ payload: SyncMessagesPayload) async throws -> Int
|
2026-05-15 17:05:39 -07:00
|
|
|
func syncMessagesBatch(_ payloads: [SyncMessagesPayload]) async throws -> Int
|
2026-05-15 17:05:13 -07:00
|
|
|
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 = {
|
2026-05-15 17:05:39 -07:00
|
|
|
// 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)
|
2026-05-15 17:05:13 -07:00
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 17:05:39 -07:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 17:05:13 -07:00
|
|
|
// 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)
|
|
|
|
|
}
|
2026-05-15 18:35:50 -07:00
|
|
|
return json["data"]["items"].arrayValue.map { msg in
|
2026-05-15 17:05:13 -07:00
|
|
|
PendingSendMessage(
|
|
|
|
|
id: msg["id"].stringValue,
|
2026-05-15 18:35:50 -07:00
|
|
|
toHandle: msg["toHandle"].stringValue,
|
2026-05-15 17:05:13 -07:00
|
|
|
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
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-06-28 21:10:13 -04:00
|
|
|
|
|
|
|
|
// 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
|
2026-05-15 17:05:13 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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?
|
|
|
|
|
|
2026-05-15 17:05:39 -07:00
|
|
|
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
|
2026-05-15 17:05:13 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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]
|
|
|
|
|
|
2026-05-15 17:05:39 -07:00
|
|
|
var dictionary: [String: Any] {
|
|
|
|
|
var d: [String: Any] = [
|
2026-05-15 17:05:13 -07:00
|
|
|
"imessageGuid": imessageGuid,
|
|
|
|
|
"direction": direction,
|
|
|
|
|
"sentAt": sentAt,
|
|
|
|
|
"isAudioMessage": isAudioMessage,
|
|
|
|
|
"attachments": attachments.map { $0.dictionary },
|
|
|
|
|
"attachmentsCount": attachmentsCount,
|
|
|
|
|
"attachmentsTotalSize": attachmentsTotalSize,
|
|
|
|
|
"attachmentsFiletypes": attachmentsFiletypes,
|
|
|
|
|
]
|
2026-05-15 17:05:39 -07:00
|
|
|
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
|
2026-05-15 17:05:13 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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?
|
|
|
|
|
|
2026-05-15 17:05:39 -07:00
|
|
|
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
|
2026-05-15 17:05:13 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 18:06:23 -07:00
|
|
|
struct PendingSendMessage: Sendable {
|
2026-05-15 17:05:13 -07:00
|
|
|
let id: String
|
2026-05-15 18:35:50 -07:00
|
|
|
let toHandle: String
|
2026-05-15 17:05:13 -07:00
|
|
|
let body: String
|
|
|
|
|
let createdAt: String
|
|
|
|
|
}
|