Merge pull request #31 from pangu25/feat/reaction-events-and-send
feat: reaction events in watch + react command
This commit is contained in:
commit
deb01a0ef7
@ -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)
|
||||
|
||||
@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
136
Sources/IMsgCore/MessageStore+ReactionEvents.swift
Normal file
136
Sources/IMsgCore/MessageStore+ReactionEvents.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -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 "),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ struct CommandRouter {
|
||||
HistoryCommand.spec,
|
||||
WatchCommand.spec,
|
||||
SendCommand.spec,
|
||||
ReactCommand.spec,
|
||||
RpcCommand.spec,
|
||||
]
|
||||
let descriptor = CommandDescriptor(
|
||||
|
||||
229
Sources/imsg/Commands/ReactCommand.swift
Normal file
229
Sources/imsg/Commands/ReactCommand.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user