257 lines
11 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|