macsync/@packages/imessage/Sources/IMessageSync/Sender.swift

182 lines
6.6 KiB
Swift

import AppKit
import Foundation
import GRDB
import LilithLogging
import MacSyncShared
private let log = AppLogger.logger(for: "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: "\\\"")
let script = """
tell application "Messages"
set targetService to 1st service whose service type = iMessage
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()
do {
try process.run()
process.waitUntilExit()
} catch {
activityLog.error("iMessage send failed to \(logPrefix)...: \(error.localizedDescription)")
return (false, nil, error.localizedDescription)
}
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)"
activityLog.error("iMessage send failed to \(logPrefix)...: \(errorMessage)")
return (false, nil, errorMessage)
}
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)")
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"
}
}
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
}
}
}