From 48217173a412f8b351fe9de9ed95b79ea2b8de47 Mon Sep 17 00:00:00 2001 From: Natalie Date: Thu, 21 May 2026 21:06:28 -0700 Subject: [PATCH] =?UTF-8?q?feat(imessage):=20=E2=9C=A8=20add=20fallback=20?= =?UTF-8?q?sms=20retry=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../Sources/IMessageSync/Sender.swift | 158 ++++++++++++------ 1 file changed, 104 insertions(+), 54 deletions(-) diff --git a/@packages/imessage/Sources/IMessageSync/Sender.swift b/@packages/imessage/Sources/IMessageSync/Sender.swift index 4083f13..bb8bde1 100644 --- a/@packages/imessage/Sources/IMessageSync/Sender.swift +++ b/@packages/imessage/Sources/IMessageSync/Sender.swift @@ -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) {