Merge pull request #31 from pangu25/feat/reaction-events-and-send

feat: reaction events in watch + react command
This commit is contained in:
Peter Steinberger 2026-02-16 04:04:52 +01:00 committed by GitHub
commit deb01a0ef7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 575 additions and 11 deletions

View File

@ -2,6 +2,9 @@
## 0.4.1 - Unreleased
- feat: `--reactions` flag for `watch` command to include tapback events in stream (#26)
- feat: reaction events include `is_reaction`, `reaction_type`, `reaction_emoji`, `is_reaction_add`, `reacted_to_guid` fields
- feat: `imsg react` command to send tapback reactions via UI automation (#24)
- fix: prefer handle sends when chat identifier is a direct handle
- fix: apply history filters before limit (#20, thanks @tommybananas)
- fix: flush watch output immediately when stdout is buffered (#43, thanks @ccaum)

View File

@ -6,6 +6,8 @@ public enum IMsgError: LocalizedError, Sendable {
case invalidService(String)
case invalidChatTarget(String)
case appleScriptFailure(String)
case invalidReaction(String)
case chatNotFound(chatID: Int64)
public var errorDescription: String? {
switch self {
@ -34,6 +36,15 @@ public enum IMsgError: LocalizedError, Sendable {
return "Invalid chat target: \(value)"
case .appleScriptFailure(let message):
return "AppleScript failed: \(message)"
case .invalidReaction(let value):
return """
Invalid reaction: \(value)
Valid reactions: love, like, dislike, laugh, emphasis, question
Or use an emoji for custom reactions (e.g., 🎉)
"""
case .chatNotFound(let chatID):
return "Chat not found: \(chatID)"
}
}
}

View File

@ -123,6 +123,10 @@ extension MessageStore {
}
public func messagesAfter(afterRowID: Int64, chatID: Int64?, limit: Int) throws -> [Message] {
return try messagesAfter(afterRowID: afterRowID, chatID: chatID, limit: limit, includeReactions: false)
}
public func messagesAfter(afterRowID: Int64, chatID: Int64?, limit: Int, includeReactions: Bool) throws -> [Message] {
let bodyColumn = hasAttributedBody ? "m.attributedBody" : "NULL"
let guidColumn = hasReactionColumns ? "m.guid" : "NULL"
let associatedGuidColumn = hasReactionColumns ? "m.associated_message_guid" : "NULL"
@ -131,10 +135,15 @@ extension MessageStore {
let audioMessageColumn = hasAudioMessageColumn ? "m.is_audio_message" : "0"
let threadOriginatorColumn =
hasThreadOriginatorGUIDColumn ? "m.thread_originator_guid" : "NULL"
let reactionFilter =
hasReactionColumns
? " AND (m.associated_message_type IS NULL OR m.associated_message_type < 2000 OR m.associated_message_type > 3006)"
: ""
// Only filter out reactions if includeReactions is false
let reactionFilter: String
if includeReactions {
reactionFilter = ""
} else {
reactionFilter = hasReactionColumns
? " AND (m.associated_message_type IS NULL OR m.associated_message_type < 2000 OR m.associated_message_type > 3006)"
: ""
}
var sql = """
SELECT m.ROWID, cmj.chat_id, m.handle_id, h.id, IFNULL(m.text, '') AS text, m.date, m.is_from_me, m.service,
\(audioMessageColumn) AS is_audio_message, \(destinationCallerColumn) AS destination_caller_id,
@ -202,6 +211,22 @@ extension MessageStore {
associatedGuid: associatedGuid,
associatedType: associatedType
)
// Determine if this is a reaction event
let typeValue = associatedType ?? 0
let isReactionEvent = ReactionType.isReaction(typeValue)
var reactionType: ReactionType? = nil
var isReactionAdd: Bool? = nil
var reactedToGUID: String? = nil
if isReactionEvent {
isReactionAdd = ReactionType.isReactionAdd(typeValue)
let rawType = (isReactionAdd ?? true) ? typeValue : typeValue - 1000
let customEmoji: String? = (rawType == 2006) ? extractCustomEmoji(from: resolvedText) : nil
reactionType = ReactionType(rawValue: rawType, customEmoji: customEmoji)
reactedToGUID = normalizeAssociatedGUID(associatedGuid)
}
messages.append(
Message(
rowID: rowID,
@ -215,7 +240,11 @@ extension MessageStore {
attachmentsCount: attachments,
guid: guid,
replyToGUID: replyToGUID,
threadOriginatorGUID: threadOriginatorGUID.isEmpty ? nil : threadOriginatorGUID
threadOriginatorGUID: threadOriginatorGUID.isEmpty ? nil : threadOriginatorGUID,
isReaction: isReactionEvent,
reactionType: reactionType,
isReactionAdd: isReactionAdd,
reactedToGUID: reactedToGUID
))
}
return messages

View File

@ -0,0 +1,136 @@
import Foundation
import SQLite
/// A reaction event represents when someone adds or removes a reaction to a message.
/// Unlike `Reaction` which represents the current state, this captures the event itself.
public struct ReactionEvent: Sendable, Equatable {
/// The ROWID of the reaction message in the database
public let rowID: Int64
/// The chat ID where the reaction occurred
public let chatID: Int64
/// The type of reaction
public let reactionType: ReactionType
/// Whether this is adding (true) or removing (false) a reaction
public let isAdd: Bool
/// The sender of the reaction (phone number or email)
public let sender: String
/// Whether the reaction was sent by the current user
public let isFromMe: Bool
/// When the reaction event occurred
public let date: Date
/// The GUID of the message being reacted to
public let reactedToGUID: String
/// The ROWID of the message being reacted to (if available)
public let reactedToID: Int64?
/// The original text of the reaction message (e.g., "Liked \"hello\"")
public let text: String
public init(
rowID: Int64,
chatID: Int64,
reactionType: ReactionType,
isAdd: Bool,
sender: String,
isFromMe: Bool,
date: Date,
reactedToGUID: String,
reactedToID: Int64?,
text: String
) {
self.rowID = rowID
self.chatID = chatID
self.reactionType = reactionType
self.isAdd = isAdd
self.sender = sender
self.isFromMe = isFromMe
self.date = date
self.reactedToGUID = reactedToGUID
self.reactedToID = reactedToID
self.text = text
}
}
extension MessageStore {
/// Fetch reaction events (add/remove) after a given rowID.
/// These are the reaction messages themselves, useful for streaming reaction events in watch mode.
public func reactionEventsAfter(afterRowID: Int64, chatID: Int64?, limit: Int) throws -> [ReactionEvent] {
guard hasReactionColumns else { return [] }
let bodyColumn = hasAttributedBody ? "m.attributedBody" : "NULL"
let destinationCallerColumn = hasDestinationCallerID ? "m.destination_caller_id" : "NULL"
var sql = """
SELECT m.ROWID, cmj.chat_id, m.associated_message_type, m.associated_message_guid,
m.handle_id, h.id, m.is_from_me, m.date, IFNULL(m.text, '') AS text,
\(destinationCallerColumn) AS destination_caller_id,
\(bodyColumn) AS body,
orig.ROWID AS orig_rowid
FROM message m
LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
LEFT JOIN handle h ON m.handle_id = h.ROWID
LEFT JOIN message orig ON (orig.guid = m.associated_message_guid
OR m.associated_message_guid LIKE '%/' || orig.guid)
WHERE m.ROWID > ?
AND m.associated_message_type >= 2000
AND m.associated_message_type <= 3006
"""
var bindings: [Binding?] = [afterRowID]
if let chatID {
sql += " AND cmj.chat_id = ?"
bindings.append(chatID)
}
sql += " ORDER BY m.ROWID ASC LIMIT ?"
bindings.append(limit)
return try withConnection { db in
var events: [ReactionEvent] = []
for row in try db.prepare(sql, bindings) {
let rowID = int64Value(row[0]) ?? 0
let resolvedChatID = int64Value(row[1]) ?? chatID ?? 0
let typeValue = intValue(row[2]) ?? 0
let associatedGUID = stringValue(row[3])
// let handleID = int64Value(row[4])
var sender = stringValue(row[5])
let isFromMe = boolValue(row[6])
let date = appleDate(from: int64Value(row[7]))
let text = stringValue(row[8])
let destinationCallerID = stringValue(row[9])
let body = dataValue(row[10])
let origRowID = int64Value(row[11])
if sender.isEmpty && !destinationCallerID.isEmpty {
sender = destinationCallerID
}
let resolvedText = text.isEmpty ? TypedStreamParser.parseAttributedBody(body) : text
let isAdd = ReactionType.isReactionAdd(typeValue)
let rawType = isAdd ? typeValue : typeValue - 1000
// Extract custom emoji for type 2006/3006
let customEmoji: String? = (rawType == 2006) ? extractCustomEmoji(from: resolvedText) : nil
guard let reactionType = ReactionType(rawValue: rawType, customEmoji: customEmoji) else {
continue
}
// Normalize the associated GUID (remove "p:X/" prefix)
let reactedToGUID = normalizeAssociatedGUID(associatedGUID)
events.append(ReactionEvent(
rowID: rowID,
chatID: resolvedChatID,
reactionType: reactionType,
isAdd: isAdd,
sender: sender,
isFromMe: isFromMe,
date: date,
reactedToGUID: reactedToGUID,
reactedToID: origRowID,
text: resolvedText
))
}
return events
}
}
}

View File

@ -353,7 +353,7 @@ extension MessageStore {
}
/// Extract custom emoji from reaction message text like "Reacted 🎉 to "original message""
private func extractCustomEmoji(from text: String) -> String? {
func extractCustomEmoji(from text: String) -> String? {
// Format: "Reacted X to "..." where X is the emoji. Fallback to first emoji in text.
guard
let reactedRange = text.range(of: "Reacted "),

View File

@ -4,10 +4,13 @@ import Foundation
public struct MessageWatcherConfiguration: Sendable, Equatable {
public var debounceInterval: TimeInterval
public var batchLimit: Int
/// When true, reaction events (tapback add/remove) are included in the stream
public var includeReactions: Bool
public init(debounceInterval: TimeInterval = 0.25, batchLimit: Int = 100) {
public init(debounceInterval: TimeInterval = 0.25, batchLimit: Int = 100, includeReactions: Bool = false) {
self.debounceInterval = debounceInterval
self.batchLimit = batchLimit
self.includeReactions = includeReactions
}
}
@ -128,7 +131,8 @@ private final class WatchState: @unchecked Sendable {
let messages = try store.messagesAfter(
afterRowID: cursor,
chatID: chatID,
limit: configuration.batchLimit
limit: configuration.batchLimit,
includeReactions: configuration.includeReactions
)
for message in messages {
continuation.yield(message)

View File

@ -229,6 +229,16 @@ public struct Message: Sendable, Equatable {
public let service: String
public let handleID: Int64?
public let attachmentsCount: Int
// Reaction metadata (populated when message is a reaction event)
/// Whether this message is a reaction event (tapback add/remove)
public let isReaction: Bool
/// The type of reaction (only set when isReaction is true)
public let reactionType: ReactionType?
/// Whether this is adding (true) or removing (false) a reaction (only set when isReaction is true)
public let isReactionAdd: Bool?
/// The GUID of the message being reacted to (only set when isReaction is true)
public let reactedToGUID: String?
public init(
rowID: Int64,
@ -242,7 +252,11 @@ public struct Message: Sendable, Equatable {
attachmentsCount: Int,
guid: String = "",
replyToGUID: String? = nil,
threadOriginatorGUID: String? = nil
threadOriginatorGUID: String? = nil,
isReaction: Bool = false,
reactionType: ReactionType? = nil,
isReactionAdd: Bool? = nil,
reactedToGUID: String? = nil
) {
self.rowID = rowID
self.chatID = chatID
@ -256,6 +270,10 @@ public struct Message: Sendable, Equatable {
self.service = service
self.handleID = handleID
self.attachmentsCount = attachmentsCount
self.isReaction = isReaction
self.reactionType = reactionType
self.isReactionAdd = isReactionAdd
self.reactedToGUID = reactedToGUID
}
}

View File

@ -14,6 +14,7 @@ struct CommandRouter {
HistoryCommand.spec,
WatchCommand.spec,
SendCommand.spec,
ReactCommand.spec,
RpcCommand.spec,
]
let descriptor = CommandDescriptor(

View File

@ -0,0 +1,229 @@
import Commander
import Foundation
import IMsgCore
enum ReactCommand {
static let spec = CommandSpec(
name: "react",
abstract: "Send a tapback reaction to the most recent message",
discussion: """
Sends a tapback reaction to the most recent incoming message in the specified chat.
IMPORTANT LIMITATIONS:
- Only reacts to the MOST RECENT incoming message in the conversation
- Requires Messages.app to be running
- Uses UI automation (System Events) which requires accessibility permissions
Reaction types:
love (), like (👍), dislike (👎), laugh (😂), emphasis (), question ()
Or any single emoji for custom reactions (iOS 17+ / macOS 14+)
""",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chatID", names: [.long("chat-id")], help: "chat rowid to react in"),
.make(label: "reaction", names: [.long("reaction"), .short("r")],
help: "reaction type: love, like, dislike, laugh, emphasis, question, or emoji"),
],
flags: []
)
),
usageExamples: [
"imsg react --chat-id 1 --reaction like",
"imsg react --chat-id 1 -r love",
"imsg react --chat-id 1 -r 🎉",
]
) { values, runtime in
try await run(values: values, runtime: runtime)
}
static func run(
values: ParsedValues,
runtime: RuntimeOptions,
storeFactory: @escaping (String) throws -> MessageStore = { try MessageStore(path: $0) },
appleScriptRunner: @escaping (String, [String]) throws -> Void = { source, arguments in
try runAppleScript(source, arguments: arguments)
}
) async throws {
guard let chatID = values.optionInt64("chatID") else {
throw ParsedValuesError.missingOption("chat-id")
}
guard let reactionString = values.option("reaction") else {
throw ParsedValuesError.missingOption("reaction")
}
guard let reactionType = ReactionType.parse(reactionString) else {
throw IMsgError.invalidReaction(reactionString)
}
if case let .custom(emoji) = reactionType, !isSingleEmoji(emoji) {
throw IMsgError.invalidReaction(reactionString)
}
// Get chat info for the GUID
let dbPath = values.option("db") ?? MessageStore.defaultPath
let store = try storeFactory(dbPath)
guard let chatInfo = try store.chatInfo(chatID: chatID) else {
throw IMsgError.chatNotFound(chatID: chatID)
}
let chatLookup = preferredChatLookup(chatInfo: chatInfo)
// Send the reaction via AppleScript + System Events
try sendReaction(
reactionType: reactionType,
chatGUID: chatInfo.guid,
chatLookup: chatLookup,
appleScriptRunner: appleScriptRunner
)
if runtime.jsonOutput {
let result = ReactResult(
success: true,
chatID: chatID,
reactionType: reactionType.name,
reactionEmoji: reactionType.emoji
)
try JSONLines.print(result)
} else {
print("Sent \(reactionType.emoji) reaction to chat \(chatID)")
}
}
private static func sendReaction(
reactionType: ReactionType,
chatGUID: String,
chatLookup: String,
appleScriptRunner: @escaping (String, [String]) throws -> Void
) throws {
let keyNumber: Int
switch reactionType {
case .love: keyNumber = 1
case .like: keyNumber = 2
case .dislike: keyNumber = 3
case .laugh: keyNumber = 4
case .emphasis: keyNumber = 5
case .question: keyNumber = 6
case .custom:
let script = """
on run argv
set chatGUID to item 1 of argv
set chatLookup to item 2 of argv
set customEmoji to item 3 of argv
tell application "Messages"
activate
set targetChat to chat id chatGUID
end tell
delay 0.3
tell application "System Events"
tell process "Messages"
keystroke "f" using command down
delay 0.15
keystroke "a" using command down
keystroke chatLookup
delay 0.25
key code 36
delay 0.35
keystroke "t" using command down
delay 0.2
keystroke customEmoji
delay 0.1
key code 36
end tell
end tell
end run
"""
try appleScriptRunner(script, [chatGUID, chatLookup, reactionType.emoji])
return
}
let script = """
on run argv
set chatGUID to item 1 of argv
set chatLookup to item 2 of argv
set reactionKey to item 3 of argv
tell application "Messages"
activate
set targetChat to chat id chatGUID
end tell
delay 0.3
tell application "System Events"
tell process "Messages"
keystroke "f" using command down
delay 0.15
keystroke "a" using command down
keystroke chatLookup
delay 0.25
key code 36
delay 0.35
keystroke "t" using command down
delay 0.2
keystroke reactionKey
end tell
end tell
end run
"""
try appleScriptRunner(script, [chatGUID, chatLookup, "\(keyNumber)"])
}
private static func preferredChatLookup(chatInfo: ChatInfo) -> String {
let preferred = chatInfo.name.trimmingCharacters(in: .whitespacesAndNewlines)
if !preferred.isEmpty {
return preferred
}
let identifier = chatInfo.identifier.trimmingCharacters(in: .whitespacesAndNewlines)
if !identifier.isEmpty {
return identifier
}
return chatInfo.guid
}
private static func isSingleEmoji(_ value: String) -> Bool {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.count == 1 else { return false }
guard let scalar = trimmed.unicodeScalars.first else { return false }
return scalar.properties.isEmoji || scalar.properties.isEmojiPresentation
}
private static func runAppleScript(_ source: String, arguments: [String]) throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
process.arguments = ["-l", "AppleScript", "-"] + arguments
let stdinPipe = Pipe()
let stderrPipe = Pipe()
process.standardInput = stdinPipe
process.standardError = stderrPipe
try process.run()
if let data = source.data(using: .utf8) {
stdinPipe.fileHandleForWriting.write(data)
}
stdinPipe.fileHandleForWriting.closeFile()
process.waitUntilExit()
if process.terminationStatus != 0 {
let data = stderrPipe.fileHandleForReading.readDataToEndOfFile()
let message = String(data: data, encoding: .utf8) ?? "Unknown AppleScript error"
throw IMsgError.appleScriptFailure(message.trimmingCharacters(in: .whitespacesAndNewlines))
}
}
}
struct ReactResult: Codable {
let success: Bool
let chatID: Int64
let reactionType: String
let reactionEmoji: String
enum CodingKeys: String, CodingKey {
case success
case chatID = "chat_id"
case reactionType = "reaction_type"
case reactionEmoji = "reaction_emoji"
}
}

View File

@ -26,6 +26,10 @@ enum WatchCommand {
flags: [
.make(
label: "attachments", names: [.long("attachments")], help: "include attachment metadata"
),
.make(
label: "reactions", names: [.long("reactions")],
help: "include reaction events (tapback add/remove) in the stream"
)
]
)
@ -60,6 +64,7 @@ enum WatchCommand {
}
let sinceRowID = values.optionInt64("sinceRowID")
let showAttachments = values.flag("attachments")
let includeReactions = values.flag("reactions")
let participants = values.optionValues("participants")
.flatMap { $0.split(separator: ",").map { String($0) } }
.filter { !$0.isEmpty }
@ -73,7 +78,8 @@ enum WatchCommand {
let watcher = MessageWatcher(store: store)
let config = MessageWatcherConfiguration(
debounceInterval: debounceInterval,
batchLimit: 100
batchLimit: 100,
includeReactions: includeReactions
)
let stream = streamProvider(watcher, chatID, sinceRowID, config)
@ -94,6 +100,14 @@ enum WatchCommand {
}
let direction = message.isFromMe ? "sent" : "recv"
let timestamp = CLIISO8601.format(message.date)
if message.isReaction, let reactionType = message.reactionType {
let action = (message.isReactionAdd ?? true) ? "added" : "removed"
let targetGUID = message.reactedToGUID ?? "unknown"
StdoutWriter.writeLine(
"\(timestamp) [\(direction)] \(message.sender) \(action) \(reactionType.emoji) reaction to \(targetGUID)"
)
continue
}
StdoutWriter.writeLine("\(timestamp) [\(direction)] \(message.sender): \(message.text)")
if message.attachmentsCount > 0 {
if showAttachments {

View File

@ -37,6 +37,13 @@ struct MessagePayload: Codable {
let createdAt: String
let attachments: [AttachmentPayload]
let reactions: [ReactionPayload]
// Reaction event metadata (populated when this message is a reaction event)
let isReaction: Bool?
let reactionType: String?
let reactionEmoji: String?
let isReactionAdd: Bool?
let reactedToGUID: String?
init(message: Message, attachments: [AttachmentMeta], reactions: [Reaction] = []) {
self.id = message.rowID
@ -50,6 +57,21 @@ struct MessagePayload: Codable {
self.createdAt = CLIISO8601.format(message.date)
self.attachments = attachments.map { AttachmentPayload(meta: $0) }
self.reactions = reactions.map { ReactionPayload(reaction: $0) }
// Reaction event metadata
if message.isReaction {
self.isReaction = true
self.reactionType = message.reactionType?.name
self.reactionEmoji = message.reactionType?.emoji
self.isReactionAdd = message.isReactionAdd
self.reactedToGUID = message.reactedToGUID
} else {
self.isReaction = nil
self.reactionType = nil
self.reactionEmoji = nil
self.isReactionAdd = nil
self.reactedToGUID = nil
}
}
enum CodingKeys: String, CodingKey {
@ -64,6 +86,11 @@ struct MessagePayload: Codable {
case createdAt = "created_at"
case attachments
case reactions
case isReaction = "is_reaction"
case reactionType = "reaction_type"
case reactionEmoji = "reaction_emoji"
case isReactionAdd = "is_reaction_add"
case reactedToGUID = "reacted_to_guid"
}
}

View File

@ -51,6 +51,20 @@ func messagePayload(
if let replyToGUID = message.replyToGUID, !replyToGUID.isEmpty {
payload["reply_to_guid"] = replyToGUID
}
// Add reaction event metadata if this message is a reaction
if message.isReaction {
payload["is_reaction"] = true
if let reactionType = message.reactionType {
payload["reaction_type"] = reactionType.name
payload["reaction_emoji"] = reactionType.emoji
}
if let isReactionAdd = message.isReactionAdd {
payload["is_reaction_add"] = isReactionAdd
}
if let reactedToGUID = message.reactedToGUID, !reactedToGUID.isEmpty {
payload["reacted_to_guid"] = reactedToGUID
}
}
if let threadOriginatorGUID = message.threadOriginatorGUID, !threadOriginatorGUID.isEmpty {
payload["thread_originator_guid"] = threadOriginatorGUID
}

View File

@ -128,12 +128,13 @@ final class RPCServer {
let startISO = stringParam(params["start"])
let endISO = stringParam(params["end"])
let includeAttachments = boolParam(params["attachments"]) ?? false
let includeReactions = boolParam(params["include_reactions"]) ?? false
let filter = try MessageFilter.fromISO(
participants: participants,
startISO: startISO,
endISO: endISO
)
let config = MessageWatcherConfiguration()
let config = MessageWatcherConfiguration(includeReactions: includeReactions)
let subID = nextSubscriptionID
nextSubscriptionID += 1
let localStore = store

View File

@ -203,6 +203,83 @@ func sendCommandResolvesChatID() async throws {
#expect(captured?.recipient.isEmpty == true)
}
@Test
func reactCommandRejectsMultiCharacterEmojiInput() async {
do {
let path = try CommandTestDatabase.makePath()
let values = ParsedValues(
positional: [],
options: ["db": [path], "chatID": ["1"], "reaction": ["🎉 party"]],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
try await ReactCommand.run(values: values, runtime: runtime)
#expect(Bool(false))
} catch let error as IMsgError {
switch error {
case .invalidReaction(let value):
#expect(value == "🎉 party")
default:
#expect(Bool(false))
}
} catch {
#expect(Bool(false))
}
}
@Test
func reactCommandBuildsParameterizedAppleScriptForStandardTapback() async throws {
let path = try CommandTestDatabase.makePath()
let values = ParsedValues(
positional: [],
options: ["db": [path], "chatID": ["1"], "reaction": ["like"]],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
var capturedScript = ""
var capturedArguments: [String] = []
try await ReactCommand.run(
values: values,
runtime: runtime,
appleScriptRunner: { source, arguments in
capturedScript = source
capturedArguments = arguments
}
)
#expect(capturedArguments == ["iMessage;+;chat123", "Test Chat", "2"])
#expect(capturedScript.contains("on run argv"))
#expect(capturedScript.contains("keystroke \"f\" using command down"))
#expect(capturedScript.contains("set targetChat to chat id chatGUID"))
#expect(capturedScript.contains("keystroke reactionKey"))
#expect(capturedScript.contains("chat123") == false)
}
@Test
func reactCommandBuildsParameterizedAppleScriptForCustomEmoji() async throws {
let path = try CommandTestDatabase.makePath()
let values = ParsedValues(
positional: [],
options: ["db": [path], "chatID": ["1"], "reaction": ["🎉"]],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
var capturedScript = ""
var capturedArguments: [String] = []
try await ReactCommand.run(
values: values,
runtime: runtime,
appleScriptRunner: { source, arguments in
capturedScript = source
capturedArguments = arguments
}
)
#expect(capturedArguments == ["iMessage;+;chat123", "Test Chat", "🎉"])
#expect(capturedScript.contains("on run argv"))
#expect(capturedScript.contains("keystroke customEmoji"))
#expect(capturedScript.contains("key code 36"))
#expect(capturedScript.contains("chat123") == false)
}
@Test
func watchCommandRejectsInvalidDebounce() async {
let values = ParsedValues(