macsync/@packages/imessage/Sources/IMessageSync/APIClient.swift

278 lines
10 KiB
Swift

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 {
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"]["messages"].arrayValue.map { msg in
PendingSendMessage(
id: msg["id"].stringValue,
phoneNumber: msg["phoneNumber"].stringValue,
body: msg["body"].stringValue,
requestedBy: msg["requestedBy"].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: - 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 {
let id: String
let phoneNumber: String
let body: String
let requestedBy: String
let createdAt: String
}