macsync/@packages/imessage/Sources/IMessageSync/Sender.swift
Natalie 48217173a4 feat(imessage): add fallback sms retry logic
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-05-21 21:06:28 -07:00

307 lines
13 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))"
}
/// Messages service a send is routed through.
enum MessageServiceKind: String {
case iMessage = "iMessage"
case sms = "SMS"
}
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))
// Route by chat.db history: SMS-only history SMS; iMessage history,
// or a brand-new number iMessage.
let preferred = Self.preferredService(buddyId: buddyId)
var attempt = sendOnce(serviceType: preferred, buddyId: buddyId, body: body, logPrefix: logPrefix)
// Fallback: an iMessage-routed number that didn't actually deliver
// (not iMessage-reachable, service issue, ) retry once over SMS.
if !attempt.sent, preferred == .iMessage {
osLog.notice("send: iMessage to \(logPrefix, privacy: .public) failed (\(attempt.error ?? "?", privacy: .public)) — retrying via SMS")
attempt = sendOnce(serviceType: .sms, buddyId: buddyId, body: body, logPrefix: logPrefix)
}
if attempt.sent {
dailySendCount += 1
hourlySendCount += 1
let messageId = Self.generateMessageId(buddyId: buddyId, isEmail: isEmail)
activityLog.success("Message sent to \(logPrefix)... via \(attempt.usedService)")
log.info("Message sent to \(logPrefix)..., id: \(messageId), via \(attempt.usedService)")
osLog.notice("send: delivered to \(logPrefix, privacy: .public) via \(attempt.usedService, privacy: .public)")
return (true, messageId, nil)
}
activityLog.error("Message send failed to \(logPrefix)...: \(attempt.error ?? "unknown error")")
osLog.error("send: FAILED for \(logPrefix, privacy: .public): \(attempt.error ?? "unknown", privacy: .public)")
return (false, nil, attempt.error)
}
/// Send `body` to `buddyId` over one specific Messages service, then
/// verify the result against chat.db `osascript` exiting 0 only means
/// the command was accepted, not that the message actually transmitted.
private func sendOnce(
serviceType: MessageServiceKind,
buddyId: String,
body: String,
logPrefix: String
) -> (sent: Bool, usedService: String, error: String?) {
let sanitizedBody = body
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
let sanitizedBuddy = buddyId
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
let svc = serviceType.rawValue
// Resolve the service by iterating with a per-service `try`: a plain
// `1st service whose service type = ` throws (-1728) when any stale
// service errors on `service type`. Require an *enabled* service.
let script = """
tell application "Messages"
set targetService to missing value
repeat with s in services
try
if (service type of s) is \(svc) 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 \(svc) 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 (\(svc, privacy: .public)) for \(logPrefix, privacy: .public)...")
do {
try process.run()
} catch {
return (false, svc, error.localizedDescription)
}
// Bound the wait off the @MainActor so a wedged Messages.app can't
// freeze the send-queue poller (which is also @MainActor).
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() }
return (false, svc, "osascript send timed out after \(Int(sendTimeout))s")
}
if process.terminationStatus != 0 {
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
let msg = String(data: errorData, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
?? "osascript exited with code \(process.terminationStatus)"
return (false, svc, msg)
}
// Verify against chat.db. Poll a delivery error (e.g. error 22)
// can take several seconds to register after osascript returns.
for _ in 0..<8 {
Thread.sleep(forTimeInterval: 2)
guard let status = Self.chatDBSendStatus(buddyId: buddyId) else { continue }
if status.error != 0 {
return (false, svc, "Messages error \(status.error)")
}
if status.isSent {
return (true, svc, nil)
}
}
// No error observed within the poll window accept as sent.
return (true, svc, 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"
}
}
/// Lifecycle of the most-recent outbound message to `buddyId` in chat.db.
/// `error != 0` means Messages failed the send even when `osascript`
/// exited 0; `isSent` is the ground-truth sent flag.
nonisolated static func chatDBSendStatus(buddyId: String) -> (error: Int, isSent: Bool)? {
guard let dbQueue = iMessageReader.shared.getDatabaseQueue() else { return nil }
let suffix = String(buddyId.suffix(7))
return (try? dbQueue.read { db -> (Int, Bool)? in
guard let row = try Row.fetchOne(db, sql: """
SELECT m.error AS err, m.is_sent AS sent
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 }
let err = (row["err"] as? Int64).map(Int.init) ?? 0
let sent = ((row["sent"] as? Int64) ?? 0) != 0
return (err, sent)
}) ?? nil
}
/// Chooses the Messages service for a recipient. Per Quinn: if chat.db
/// shows SMS-only history (SMS messages, zero iMessage) send via SMS;
/// iMessage history or a brand-new number defaults to iMessage.
nonisolated static func preferredService(buddyId: String) -> MessageServiceKind {
guard let dbQueue = iMessageReader.shared.getDatabaseQueue() else { return .iMessage }
let suffix = String(buddyId.suffix(7))
let kind: MessageServiceKind? = try? dbQueue.read { db -> MessageServiceKind in
var imessage = 0
var sms = 0
let rows = try Row.fetchAll(db, sql: """
SELECT m.service AS svc, COUNT(*) AS c
FROM message m
JOIN handle h ON h.ROWID = m.handle_id
WHERE h.id LIKE ?
GROUP BY m.service
""", arguments: ["%\(suffix)"])
for row in rows {
let svc = (row["svc"] as? String) ?? ""
let count = (row["c"] as? Int64).map(Int.init) ?? 0
if svc == "iMessage" { imessage += count }
if svc == "SMS" { sms += count }
}
return (sms > 0 && imessage == 0) ? .sms : .iMessage
}
return kind ?? .iMessage
}
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
}
}
}