macsync/@packages/imessage/Sources/IMessageSync/Sender.swift
Natalie 5290e1de2f feat(imessage): improve iMessage service detection and error handling
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-05-21 20:24:45 -07:00

257 lines
11 KiB
Swift

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
}
}
}