feat(imessage): add fallback sms retry logic

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-21 21:06:28 -07:00
parent 5290e1de2f
commit 48217173a4

View file

@ -63,6 +63,12 @@ class SendService {
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()
@ -80,31 +86,65 @@ class SendService {
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 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.
// 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 iMessage and (enabled of s) then
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 iMessage service"
if targetService is missing value then error "no enabled \(svc) service"
set targetBuddy to buddy "\(sanitizedBuddy)" of targetService
send "\(sanitizedBody)" to targetBuddy
end tell
@ -113,26 +153,19 @@ class SendService {
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)...")
osLog.notice("send: launching osascript (\(svc, privacy: .public)) 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)
return (false, svc, 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.
// 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()
@ -142,41 +175,30 @@ class SendService {
}
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")
return (false, svc, "osascript send timed out after \(Int(sendTimeout))s")
}
if process.terminationStatus != 0 {
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
let errorMessage = String(data: errorData, encoding: .utf8)?
let msg = 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)
return (false, svc, msg)
}
// 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)")
// 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)
}
}
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)
// No error observed within the poll window accept as sent.
return (true, svc, nil)
}
func checkDelivery(messageId: String) -> String {
@ -217,15 +239,15 @@ class SendService {
}
}
/// 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? {
/// 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))
let value: Int? = (try? dbQueue.read { db -> Int? in
return (try? dbQueue.read { db -> (Int, Bool)? in
guard let row = try Row.fetchOne(db, sql: """
SELECT m.error AS err
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 ?
@ -233,9 +255,37 @@ class SendService {
LIMIT 1
""", arguments: ["%\(suffix)"])
else { return nil }
return (row["err"] as? Int64).map(Int.init)
let err = (row["err"] as? Int64).map(Int.init) ?? 0
let sent = ((row["sent"] as? Int64) ?? 0) != 0
return (err, sent)
}) ?? nil
return value
}
/// 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) {