import AppKit import Foundation import GRDB import LilithLogging import MacSyncShared import os private let log = AppLogger.logger(for: "IMessage.Send") /// Unified-logging mirror so the AppleScript send path is observable via /// `log show --predicate 'subsystem == "com.lilith.mac-sync"'` (host /// journaling). AppLogger's sink is not reachable through `log show`. private let osLog = os.Logger(subsystem: "com.lilith.mac-sync", category: "IMessage.Send") /// Sends iMessages via AppleScript bridge to Messages.app /// and checks delivery status from chat.db @MainActor class SendService { static let shared = SendService() private let activityLog = ActivityLog.shared private var dailySendCount: Int = 0 private var hourlySendCount: Int = 0 private var lastHourReset: Date = Date() private var lastDayReset: Date = Date() private let maxDailyMessages: Int = 150 private let maxHourlyMessages: Int = 30 private init() {} // MARK: - Validation (nonisolated for testability) nonisolated static func validate(recipient: String) -> (buddyId: String, isEmail: Bool, error: String?) { let isEmail = recipient.contains("@") if isEmail { let parts = recipient.split(separator: "@") guard parts.count == 2, let domain = parts.last, domain.contains(".") else { return ("", true, "Invalid email address format") } return (recipient, true, nil) } else { let cleaned = recipient.filter { $0.isNumber || $0 == "+" } guard cleaned.count >= 10 else { return ("", false, "Invalid phone number format") } return (cleaned, false, nil) } } nonisolated static func generateMessageId(buddyId: String, isEmail: Bool) -> String { let idSuffix: String if isEmail { let localPart = String(buddyId.split(separator: "@").first ?? "") idSuffix = String(localPart.suffix(4)) } else { idSuffix = String(buddyId.suffix(4)) } return "send_\(idSuffix)_\(Int(Date().timeIntervalSince1970))" } func send(recipient: String, body: String) -> (success: Bool, messageId: String?, error: String?) { resetCountersIfNeeded() if dailySendCount >= maxDailyMessages { return (false, nil, "Daily send limit reached (\(maxDailyMessages)/day)") } if hourlySendCount >= maxHourlyMessages { return (false, nil, "Hourly send limit reached (\(maxHourlyMessages)/hour)") } let validation = Self.validate(recipient: recipient) if let error = validation.error { return (false, nil, error) } let buddyId = validation.buddyId let isEmail = validation.isEmail let logPrefix = String(buddyId.prefix(4)) let sanitizedBody = body .replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\"", with: "\\\"") let sanitizedBuddy = buddyId .replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\"", with: "\\\"") // Resolve the iMessage service by iterating with a per-service `try`. // A plain `1st service whose service type = iMessage` throws (-1728) // when any stale service errors on `service type`, and it would also // pick a *disabled* service. Require an enabled iMessage service; if // none, fail with a clear reason instead of a cryptic delivery error. let script = """ tell application "Messages" set targetService to missing value repeat with s in services try if (service type of s) is iMessage and (enabled of s) then set targetService to s exit repeat end if end try end repeat if targetService is missing value then error "no enabled iMessage service" set targetBuddy to buddy "\(sanitizedBuddy)" of targetService send "\(sanitizedBody)" to targetBuddy end tell """ let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") process.arguments = ["-e", script] let errorPipe = Pipe() process.standardError = errorPipe process.standardOutput = Pipe() osLog.notice("send: launching osascript for \(logPrefix, privacy: .public)...") do { try process.run() } catch { osLog.error("send: process.run() threw for \(logPrefix, privacy: .public): \(error.localizedDescription, privacy: .public)") activityLog.error("iMessage send failed to \(logPrefix)...: \(error.localizedDescription)") return (false, nil, error.localizedDescription) } // Bound the wait. A wedged Messages.app makes osascript hang on the // AppleEvent timeout (~2 min) — sometimes indefinitely. `send()` runs // on the @MainActor, as does the SendQueueClient poller, so an // unbounded `waitUntilExit()` freezes the whole send-queue poller. // Cap it: kill osascript past `sendTimeout` and return a retryable // failure rather than wedging the poller. let sendTimeout: TimeInterval = 45 let exitGroup = DispatchGroup() exitGroup.enter() DispatchQueue.global(qos: .userInitiated).async { process.waitUntilExit() exitGroup.leave() } if exitGroup.wait(timeout: .now() + sendTimeout) == .timedOut { if process.isRunning { process.terminate() } osLog.error("send: osascript timed out after \(Int(sendTimeout), privacy: .public)s for \(logPrefix, privacy: .public) — terminated") activityLog.error("iMessage send failed to \(logPrefix)...: timed out after \(Int(sendTimeout))s") return (false, nil, "osascript send timed out after \(Int(sendTimeout))s") } if process.terminationStatus != 0 { let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() let errorMessage = String(data: errorData, encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "osascript exited with code \(process.terminationStatus)" osLog.error("send: osascript failed for \(logPrefix, privacy: .public): \(errorMessage, privacy: .public)") activityLog.error("iMessage send failed to \(logPrefix)...: \(errorMessage)") return (false, nil, errorMessage) } // osascript exiting 0 only means Messages accepted the command — NOT // that the message transmitted. Verify against chat.db: a disabled // iMessage service still creates a message row, but with error != 0 // (e.g. error 33). Never report success the ground truth contradicts. Thread.sleep(forTimeInterval: 4) if let sendError = Self.chatDBSendError(buddyId: buddyId), sendError != 0 { osLog.error("send: chat.db reports error=\(sendError, privacy: .public) for \(logPrefix, privacy: .public) — NOT delivered") activityLog.error("iMessage send failed to \(logPrefix)...: Messages error \(sendError)") return (false, nil, "Messages reported error \(sendError) — not delivered (iMessage service may be disabled)") } dailySendCount += 1 hourlySendCount += 1 let messageId = Self.generateMessageId(buddyId: buddyId, isEmail: isEmail) activityLog.success("iMessage sent to \(logPrefix)...") log.info("Message sent to \(logPrefix)..., id: \(messageId)") osLog.notice("send: osascript ok for \(logPrefix, privacy: .public), id=\(messageId, privacy: .public)") return (true, messageId, nil) } func checkDelivery(messageId: String) -> String { let parts = messageId.split(separator: "_") guard parts.count >= 3, let timestamp = Double(parts.last ?? "") else { return "pending" } let phoneSuffix = String(parts[1]) let sentDate = Date(timeIntervalSince1970: timestamp) let reader = iMessageReader.shared guard let dbQueue = reader.getDatabaseQueue() else { return "pending" } do { return try dbQueue.read { db in let row = try Row.fetchOne(db, sql: """ SELECT m.date_delivered, m.date_read FROM message m JOIN chat_message_join cmj ON cmj.message_id = m.ROWID JOIN chat_handle_join chj ON chj.chat_id = cmj.chat_id JOIN handle h ON h.ROWID = chj.handle_id WHERE m.is_from_me = 1 AND h.id LIKE ? AND m.date > ? ORDER BY m.date DESC LIMIT 1 """, arguments: ["%\(phoneSuffix)", sentDate.timeIntervalSinceReferenceDate * 1_000_000_000]) guard let row = row else { return "pending" } if row["date_read"] != nil && (row["date_read"] as? Int64 ?? 0) > 0 { return "read" } if row["date_delivered"] != nil && (row["date_delivered"] as? Int64 ?? 0) > 0 { return "delivered" } return "sent" } } catch { log.warning("Failed to check delivery: \(error)") return "pending" } } /// Most-recent outbound `message.error` for `buddyId` from chat.db, or nil /// if no row is found. A non-zero value means Messages failed the send /// even when `osascript` exited 0 (e.g. disabled iMessage service → 33). nonisolated static func chatDBSendError(buddyId: String) -> Int? { guard let dbQueue = iMessageReader.shared.getDatabaseQueue() else { return nil } let suffix = String(buddyId.suffix(7)) let value: Int? = (try? dbQueue.read { db -> Int? in guard let row = try Row.fetchOne(db, sql: """ SELECT m.error AS err FROM message m JOIN handle h ON h.ROWID = m.handle_id WHERE m.is_from_me = 1 AND h.id LIKE ? ORDER BY m.date DESC LIMIT 1 """, arguments: ["%\(suffix)"]) else { return nil } return (row["err"] as? Int64).map(Int.init) }) ?? nil return value } func getRateLimitStatus() -> (dailyRemaining: Int, hourlyRemaining: Int) { resetCountersIfNeeded() return (max(0, maxDailyMessages - dailySendCount), max(0, maxHourlyMessages - hourlySendCount)) } private func resetCountersIfNeeded() { let now = Date() if now.timeIntervalSince(lastHourReset) >= 3600 { hourlySendCount = 0 lastHourReset = now } if !Calendar.current.isDate(now, inSameDayAs: lastDayReset) { dailySendCount = 0 lastDayReset = now } } }