feat(imessage): ✨ add fallback sms retry logic
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
5290e1de2f
commit
48217173a4
1 changed files with 104 additions and 54 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue