diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b99d08..361ad9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## 0.8.3 - Unreleased +### JSON Output +- feat: include `reply_to_text` and `reply_to_sender` on message payloads + emitted by `history`, `search`, `watch`, and `rpc` so consumers can quote + the parent of a threaded reply (or non-reaction association) without a + follow-up chat.db lookup. The parent is resolved by joining + `thread_originator_guid` or the non-reaction `associated_message_guid` + back to the message table; absent parents leave the fields nil (#115, thanks + @omarshahine). + ### JSON-RPC - feat: expose bridge-backed message RPC methods for rich sends, attachments, tapbacks, edits, unsends, deletes, and notify-anyways; include the CLI version in `imsg status --json` so callers can gate newer RPC action surfaces. diff --git a/README.md b/README.md index 06a7f90..71040ef 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,9 @@ Message objects include: - `id`, `chat_id`, `chat_identifier`, `chat_guid`, `chat_name` - `participants`, `is_group` - `guid`, `reply_to_guid`, `thread_originator_guid`, `destination_caller_id` +- `reply_to_text`, `reply_to_sender` (parent body + handle for threaded + replies and non-reaction associations, when the parent row is still in + chat.db) - `sender`, `sender_name`, `is_from_me`, `text`, `created_at` - `attachments`, `reactions` diff --git a/Sources/IMsgCore/MessageStore+Messages.swift b/Sources/IMsgCore/MessageStore+Messages.swift index 0028cdf..cc9ee1f 100644 --- a/Sources/IMsgCore/MessageStore+Messages.swift +++ b/Sources/IMsgCore/MessageStore+Messages.swift @@ -101,134 +101,6 @@ struct MessageRowSelection { } } -private struct ChatMessagesQuery { - let sql: String - let bindings: [Binding?] - let selection: MessageRowSelection - let fallbackChatID: Int64 - - init(store: MessageStore, chatID: ChatID, limit: Int, filter: MessageFilter?) { - self.selection = MessageRowSelection(store: store, includeChatID: false) - let destinationCallerColumn = - store.schema.hasDestinationCallerID ? "m.destination_caller_id" : "NULL" - let reactionFilter = - store.schema.hasReactionColumns - ? " AND (m.associated_message_type IS NULL OR m.associated_message_type < 2000 OR m.associated_message_type > 3006)" - : "" - var sql = """ - SELECT \(selection.selectList) - FROM message m - JOIN chat_message_join cmj ON m.ROWID = cmj.message_id - LEFT JOIN handle h ON m.handle_id = h.ROWID - WHERE cmj.chat_id = ?\(reactionFilter) - """ - var bindings: [Binding?] = [chatID.rawValue] - - if let filter { - if let startDate = filter.startDate { - sql += " AND m.date >= ?" - bindings.append(MessageStore.appleEpoch(startDate)) - } - if let endDate = filter.endDate { - sql += " AND m.date < ?" - bindings.append(MessageStore.appleEpoch(endDate)) - } - if !filter.participants.isEmpty { - let placeholders = Array(repeating: "?", count: filter.participants.count).joined( - separator: ",") - sql += - " AND COALESCE(NULLIF(h.id,''), \(destinationCallerColumn)) COLLATE NOCASE IN (\(placeholders))" - for participant in filter.participants { - bindings.append(participant) - } - } - } - - sql += " ORDER BY m.date DESC LIMIT ?" - bindings.append(limit) - - self.sql = sql - self.bindings = bindings - self.fallbackChatID = chatID.rawValue - } -} - -private struct MessagesAfterQuery { - let sql: String - let bindings: [Binding?] - let selection: MessageRowSelection - let fallbackChatID: Int64? - - init( - store: MessageStore, - afterRowID: MessageID, - chatID: ChatID?, - limit: Int, - includeReactions: Bool - ) { - self.selection = MessageRowSelection( - store: store, - includeChatID: true, - includeBalloonBundleID: true - ) - let reactionFilter: String - if includeReactions || !store.schema.hasReactionColumns { - reactionFilter = "" - } else { - reactionFilter = - " AND (m.associated_message_type IS NULL OR m.associated_message_type < 2000 OR m.associated_message_type > 3006)" - } - var sql = """ - SELECT \(selection.selectList) - 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 - WHERE m.ROWID > ?\(reactionFilter) - """ - var bindings: [Binding?] = [afterRowID.rawValue] - if let chatID { - sql += " AND cmj.chat_id = ?" - bindings.append(chatID.rawValue) - } - sql += " ORDER BY m.ROWID ASC LIMIT ?" - bindings.append(limit) - - self.sql = sql - self.bindings = bindings - self.fallbackChatID = chatID?.rawValue - } -} - -private struct LatestSentMessageQuery { - let sql: String - let bindings: [Binding?] - let selection: MessageRowSelection - let fallbackChatID: Int64? - - init(store: MessageStore, text: String, chatID: ChatID?, since date: Date) { - self.selection = MessageRowSelection(store: store, includeChatID: true) - var sql = """ - SELECT \(selection.selectList) - 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 - WHERE m.is_from_me = 1 - AND IFNULL(m.text, '') = ? - AND m.date >= ? - """ - var bindings: [Binding?] = [text, MessageStore.appleEpoch(date)] - if let chatID { - sql += " AND cmj.chat_id = ?" - bindings.append(chatID.rawValue) - } - sql += " ORDER BY m.date DESC, m.ROWID DESC LIMIT 1" - - self.sql = sql - self.bindings = bindings - self.fallbackChatID = chatID?.rawValue - } -} - extension MessageStore { public func maxRowID() throws -> Int64 { return try withConnection { db in @@ -251,6 +123,7 @@ extension MessageStore { return try withConnection { db in var messages: [Message] = [] + var parentCache: ReplyParentCache = [:] let rows = try db.prepareRowIterator(query.sql, bindings: query.bindings) while let row = try rows.failableNext() { let decoded = try decodeMessageRow( @@ -262,6 +135,14 @@ extension MessageStore { associatedGuid: decoded.associatedGUID, associatedType: decoded.associatedType ) + let threadOriginatorGUID = + decoded.threadOriginatorGUID.isEmpty ? nil : decoded.threadOriginatorGUID + let parent = enrichedReplyContext( + db, + replyToGUID: replyToGUID, + threadOriginatorGUID: threadOriginatorGUID, + cache: &parentCache + ) messages.append( Message( rowID: decoded.rowID, @@ -276,10 +157,11 @@ extension MessageStore { guid: decoded.guid, routing: Message.RoutingMetadata( replyToGUID: replyToGUID, - threadOriginatorGUID: decoded.threadOriginatorGUID.isEmpty - ? nil : decoded.threadOriginatorGUID, + threadOriginatorGUID: threadOriginatorGUID, destinationCallerID: decoded.destinationCallerID.isEmpty - ? nil : decoded.destinationCallerID + ? nil : decoded.destinationCallerID, + replyToText: parent?.text, + replyToSender: parent?.sender ) )) } @@ -312,6 +194,7 @@ extension MessageStore { return try withConnection { db in var messages: [Message] = [] + var parentCache: ReplyParentCache = [:] let urlBalloonProvider = "com.apple.messages.URLBalloonProvider" let rows = try db.prepareRowIterator(query.sql, bindings: query.bindings) @@ -335,15 +218,27 @@ extension MessageStore { continue } - let replyToGUID = replyToGUID( - associatedGuid: decoded.associatedGUID, - associatedType: decoded.associatedType - ) let reaction = decodeReaction( associatedType: decoded.associatedType, associatedGUID: decoded.associatedGUID, text: decoded.text ) + let replyToGUID = replyToGUID( + associatedGuid: decoded.associatedGUID, + associatedType: decoded.associatedType + ) + let threadOriginatorGUID = + reaction.isReaction || decoded.threadOriginatorGUID.isEmpty + ? nil : decoded.threadOriginatorGUID + let parent = + reaction.isReaction + ? nil + : enrichedReplyContext( + db, + replyToGUID: replyToGUID, + threadOriginatorGUID: threadOriginatorGUID, + cache: &parentCache + ) messages.append( Message( @@ -359,10 +254,11 @@ extension MessageStore { guid: decoded.guid, routing: Message.RoutingMetadata( replyToGUID: replyToGUID, - threadOriginatorGUID: decoded.threadOriginatorGUID.isEmpty - ? nil : decoded.threadOriginatorGUID, + threadOriginatorGUID: threadOriginatorGUID, destinationCallerID: decoded.destinationCallerID.isEmpty - ? nil : decoded.destinationCallerID + ? nil : decoded.destinationCallerID, + replyToText: parent?.text, + replyToSender: parent?.sender ), reaction: Message.ReactionMetadata( isReaction: reaction.isReaction, @@ -400,6 +296,15 @@ extension MessageStore { associatedGuid: decoded.associatedGUID, associatedType: decoded.associatedType ) + let threadOriginatorGUID = + decoded.threadOriginatorGUID.isEmpty ? nil : decoded.threadOriginatorGUID + var parentCache: ReplyParentCache = [:] + let parent = enrichedReplyContext( + db, + replyToGUID: replyToGUID, + threadOriginatorGUID: threadOriginatorGUID, + cache: &parentCache + ) return Message( rowID: decoded.rowID, chatID: decoded.chatID, @@ -413,10 +318,11 @@ extension MessageStore { guid: decoded.guid, routing: Message.RoutingMetadata( replyToGUID: replyToGUID, - threadOriginatorGUID: decoded.threadOriginatorGUID.isEmpty - ? nil : decoded.threadOriginatorGUID, + threadOriginatorGUID: threadOriginatorGUID, destinationCallerID: decoded.destinationCallerID.isEmpty - ? nil : decoded.destinationCallerID + ? nil : decoded.destinationCallerID, + replyToText: parent?.text, + replyToSender: parent?.sender ) ) } diff --git a/Sources/IMsgCore/MessageStore+Queries.swift b/Sources/IMsgCore/MessageStore+Queries.swift new file mode 100644 index 0000000..72bd485 --- /dev/null +++ b/Sources/IMsgCore/MessageStore+Queries.swift @@ -0,0 +1,130 @@ +import Foundation +import SQLite + +struct ChatMessagesQuery { + let sql: String + let bindings: [Binding?] + let selection: MessageRowSelection + let fallbackChatID: Int64 + + init(store: MessageStore, chatID: ChatID, limit: Int, filter: MessageFilter?) { + self.selection = MessageRowSelection(store: store, includeChatID: false) + let destinationCallerColumn = + store.schema.hasDestinationCallerID ? "m.destination_caller_id" : "NULL" + let reactionFilter = + store.schema.hasReactionColumns + ? " AND (m.associated_message_type IS NULL OR m.associated_message_type < 2000 OR m.associated_message_type > 3006)" + : "" + var sql = """ + SELECT \(selection.selectList) + FROM message m + JOIN chat_message_join cmj ON m.ROWID = cmj.message_id + LEFT JOIN handle h ON m.handle_id = h.ROWID + WHERE cmj.chat_id = ?\(reactionFilter) + """ + var bindings: [Binding?] = [chatID.rawValue] + + if let filter { + if let startDate = filter.startDate { + sql += " AND m.date >= ?" + bindings.append(MessageStore.appleEpoch(startDate)) + } + if let endDate = filter.endDate { + sql += " AND m.date < ?" + bindings.append(MessageStore.appleEpoch(endDate)) + } + if !filter.participants.isEmpty { + let placeholders = Array(repeating: "?", count: filter.participants.count).joined( + separator: ",") + sql += + " AND COALESCE(NULLIF(h.id,''), \(destinationCallerColumn)) COLLATE NOCASE IN (\(placeholders))" + for participant in filter.participants { + bindings.append(participant) + } + } + } + + sql += " ORDER BY m.date DESC LIMIT ?" + bindings.append(limit) + + self.sql = sql + self.bindings = bindings + self.fallbackChatID = chatID.rawValue + } +} + +struct MessagesAfterQuery { + let sql: String + let bindings: [Binding?] + let selection: MessageRowSelection + let fallbackChatID: Int64? + + init( + store: MessageStore, + afterRowID: MessageID, + chatID: ChatID?, + limit: Int, + includeReactions: Bool + ) { + self.selection = MessageRowSelection( + store: store, + includeChatID: true, + includeBalloonBundleID: true + ) + let reactionFilter: String + if includeReactions || !store.schema.hasReactionColumns { + reactionFilter = "" + } else { + reactionFilter = + " AND (m.associated_message_type IS NULL OR m.associated_message_type < 2000 OR m.associated_message_type > 3006)" + } + var sql = """ + SELECT \(selection.selectList) + 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 + WHERE m.ROWID > ?\(reactionFilter) + """ + var bindings: [Binding?] = [afterRowID.rawValue] + if let chatID { + sql += " AND cmj.chat_id = ?" + bindings.append(chatID.rawValue) + } + sql += " ORDER BY m.ROWID ASC LIMIT ?" + bindings.append(limit) + + self.sql = sql + self.bindings = bindings + self.fallbackChatID = chatID?.rawValue + } +} + +struct LatestSentMessageQuery { + let sql: String + let bindings: [Binding?] + let selection: MessageRowSelection + let fallbackChatID: Int64? + + init(store: MessageStore, text: String, chatID: ChatID?, since date: Date) { + self.selection = MessageRowSelection(store: store, includeChatID: true) + var sql = """ + SELECT \(selection.selectList) + 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 + WHERE m.is_from_me = 1 + AND IFNULL(m.text, '') = ? + AND m.date >= ? + """ + var bindings: [Binding?] = [text, MessageStore.appleEpoch(date)] + if let chatID { + sql += " AND cmj.chat_id = ?" + bindings.append(chatID.rawValue) + } + sql += " ORDER BY m.date DESC, m.ROWID DESC LIMIT 1" + + self.sql = sql + self.bindings = bindings + self.fallbackChatID = chatID?.rawValue + } +} diff --git a/Sources/IMsgCore/MessageStore+ReplyContext.swift b/Sources/IMsgCore/MessageStore+ReplyContext.swift new file mode 100644 index 0000000..b0c5ced --- /dev/null +++ b/Sources/IMsgCore/MessageStore+ReplyContext.swift @@ -0,0 +1,63 @@ +import Foundation +import SQLite + +typealias ReplyParent = (text: String, sender: String) + +/// Per-query-loop memoization for parent message lookups. Reused across rows +/// within one `messages()`/`messagesAfter()`/`searchMessages()` invocation so +/// large pulls with many replies that share a parent (common in active group +/// threads) issue a single SELECT per distinct parent guid rather than one per +/// reply row. +/// +/// Both hits and misses are cached: the outer optional records whether a guid +/// has been looked up; the inner optional records the result. Hits return the +/// parent body + sender; misses (absent parent, SQLite error) short-circuit +/// the next replies to the same guid without re-querying. +typealias ReplyParentCache = [String: ReplyParent?] + +extension MessageStore { + /// Resolves the text + sender handle of a reply parent referenced by either + /// `thread_originator_guid` or a non-reaction `associated_message_guid`. The + /// parent row is decoded through `decodeMessageRow` so the same attributedBody + /// fallback and sender resolution applies as for top-level messages. Returns + /// nil when the parent row is absent or the guid is empty. + func resolveReplyParent(_ db: Connection, guid: String) throws -> ReplyParent? { + guard !guid.isEmpty else { return nil } + let selection = MessageRowSelection(store: self, includeChatID: false) + let sql = """ + SELECT \(selection.selectList) + FROM message m + LEFT JOIN handle h ON m.handle_id = h.ROWID + WHERE m.guid = ? + LIMIT 1 + """ + let rows = try db.prepareRowIterator(sql, bindings: [guid]) + guard let row = try rows.failableNext() else { return nil } + let decoded = try decodeMessageRow(row, columns: selection.columns, fallbackChatID: nil) + return (text: decoded.text, sender: decoded.sender) + } + + /// Walks `threadOriginatorGUID` then `replyToGUID` and returns the first + /// successful parent resolution, consulting `cache` to amortize repeated + /// lookups within one query loop. Lookup failures (absent parent, SQLite + /// error) are swallowed and negatively memoized so a missing parent never + /// blocks the inbound notification and never re-queries. + func enrichedReplyContext( + _ db: Connection, + replyToGUID: String?, + threadOriginatorGUID: String?, + cache: inout ReplyParentCache + ) -> ReplyParent? { + for candidate in [threadOriginatorGUID, replyToGUID] { + guard let guid = candidate, !guid.isEmpty else { continue } + if let cached = cache[guid] { + if let parent = cached { return parent } + continue + } + let result = try? resolveReplyParent(db, guid: guid) + cache[guid] = result + if let result { return result } + } + return nil + } +} diff --git a/Sources/IMsgCore/MessageStore+Search.swift b/Sources/IMsgCore/MessageStore+Search.swift index 6aab3e5..3cefbad 100644 --- a/Sources/IMsgCore/MessageStore+Search.swift +++ b/Sources/IMsgCore/MessageStore+Search.swift @@ -56,6 +56,7 @@ extension MessageStore { return try withConnection { db in var messages: [Message] = [] + var parentCache: ReplyParentCache = [:] let rows = try db.prepareRowIterator(query.sql, bindings: query.bindings) while let row = try rows.failableNext() { let decoded = try decodeMessageRow( @@ -67,6 +68,14 @@ extension MessageStore { associatedGuid: decoded.associatedGUID, associatedType: decoded.associatedType ) + let threadOriginatorGUID = + decoded.threadOriginatorGUID.isEmpty ? nil : decoded.threadOriginatorGUID + let parent = enrichedReplyContext( + db, + replyToGUID: replyToGUID, + threadOriginatorGUID: threadOriginatorGUID, + cache: &parentCache + ) messages.append( Message( rowID: decoded.rowID, @@ -81,10 +90,11 @@ extension MessageStore { guid: decoded.guid, routing: Message.RoutingMetadata( replyToGUID: replyToGUID, - threadOriginatorGUID: decoded.threadOriginatorGUID.isEmpty - ? nil : decoded.threadOriginatorGUID, + threadOriginatorGUID: threadOriginatorGUID, destinationCallerID: decoded.destinationCallerID.isEmpty - ? nil : decoded.destinationCallerID + ? nil : decoded.destinationCallerID, + replyToText: parent?.text, + replyToSender: parent?.sender ) )) } diff --git a/Sources/IMsgCore/Models.swift b/Sources/IMsgCore/Models.swift index 902acd0..c2ca506 100644 --- a/Sources/IMsgCore/Models.swift +++ b/Sources/IMsgCore/Models.swift @@ -251,15 +251,21 @@ public struct Message: Sendable, Equatable { public let replyToGUID: String? public let threadOriginatorGUID: String? public let destinationCallerID: String? + public let replyToText: String? + public let replyToSender: String? public init( replyToGUID: String? = nil, threadOriginatorGUID: String? = nil, - destinationCallerID: String? = nil + destinationCallerID: String? = nil, + replyToText: String? = nil, + replyToSender: String? = nil ) { self.replyToGUID = replyToGUID self.threadOriginatorGUID = threadOriginatorGUID self.destinationCallerID = destinationCallerID + self.replyToText = replyToText + self.replyToSender = replyToSender } } @@ -287,6 +293,13 @@ public struct Message: Sendable, Equatable { public let guid: String public let replyToGUID: String? public let threadOriginatorGUID: String? + /// Text of the message this one replies to (Threader reply or non-reaction + /// association). Resolved by joining `replyToGUID` or `threadOriginatorGUID` + /// back to the parent row; nil when no parent exists or it is no longer + /// in chat.db. + public let replyToText: String? + /// Sender handle (`h.id`) of the message this one replies to. + public let replyToSender: String? public let sender: String public let text: String public let date: Date @@ -328,6 +341,8 @@ public struct Message: Sendable, Equatable { self.guid = guid self.replyToGUID = routing.replyToGUID self.threadOriginatorGUID = routing.threadOriginatorGUID + self.replyToText = routing.replyToText + self.replyToSender = routing.replyToSender self.sender = sender self.text = text self.date = date @@ -356,6 +371,8 @@ public struct Message: Sendable, Equatable { replyToGUID: String? = nil, threadOriginatorGUID: String? = nil, destinationCallerID: String? = nil, + replyToText: String? = nil, + replyToSender: String? = nil, isReaction: Bool = false, reactionType: ReactionType? = nil, isReactionAdd: Bool? = nil, @@ -375,7 +392,9 @@ public struct Message: Sendable, Equatable { routing: RoutingMetadata( replyToGUID: replyToGUID, threadOriginatorGUID: threadOriginatorGUID, - destinationCallerID: destinationCallerID + destinationCallerID: destinationCallerID, + replyToText: replyToText, + replyToSender: replyToSender ), reaction: ReactionMetadata( isReaction: isReaction, diff --git a/Sources/imsg/OutputModels.swift b/Sources/imsg/OutputModels.swift index 4e49aae..fcdca72 100644 --- a/Sources/imsg/OutputModels.swift +++ b/Sources/imsg/OutputModels.swift @@ -59,6 +59,12 @@ struct MessagePayload: Codable { let guid: String let replyToGUID: String? let threadOriginatorGUID: String? + /// Text of the message this one replies to, when the inbound message is a + /// Threader reply or a non-reaction association and the parent row is + /// resolvable in chat.db. + let replyToText: String? + /// Sender handle of the parent message resolved alongside `replyToText`. + let replyToSender: String? let sender: String let senderName: String? let isFromMe: Bool @@ -90,6 +96,8 @@ struct MessagePayload: Codable { self.guid = message.guid self.replyToGUID = message.replyToGUID self.threadOriginatorGUID = message.threadOriginatorGUID + self.replyToText = message.replyToText + self.replyToSender = message.replyToSender self.sender = message.sender self.senderName = senderName self.isFromMe = message.isFromMe @@ -123,6 +131,8 @@ struct MessagePayload: Codable { case guid case replyToGUID = "reply_to_guid" case threadOriginatorGUID = "thread_originator_guid" + case replyToText = "reply_to_text" + case replyToSender = "reply_to_sender" case sender case senderName = "sender_name" case isFromMe = "is_from_me" diff --git a/Sources/imsg/RPCServer+BridgeMessageHandlers.swift b/Sources/imsg/RPCServer+BridgeMessageHandlers.swift index ffae11b..ee826fa 100644 --- a/Sources/imsg/RPCServer+BridgeMessageHandlers.swift +++ b/Sources/imsg/RPCServer+BridgeMessageHandlers.swift @@ -30,7 +30,12 @@ extension RPCServer { let data = try await invokeBridge(action: .sendMessage, params: bridgeParams) var result: [String: Any] = ["ok": true] - if let guid = data["messageGuid"] as? String, !guid.isEmpty { + if let queued = data["queued"] as? Bool { + result["queued"] = queued + } + if data["queued"] as? Bool != true, + let guid = data["messageGuid"] as? String, !guid.isEmpty + { result["guid"] = guid result["message_id"] = guid } @@ -42,15 +47,18 @@ extension RPCServer { guard let file = stringParam(params["file"] ?? params["path"]), !file.isEmpty else { throw RPCError.invalidParams("file is required") } - let data = try await invokeBridge( - action: .sendAttachment, - params: [ - "chatGuid": chatGUID, - "filePath": try stageAttachment((file as NSString).expandingTildeInPath), - "isAudioMessage": boolParam(params["audio"] ?? params["is_audio"] ?? params["as_voice"]) - ?? false, - ] - ) + var bridgeParams: [String: Any] = [ + "chatGuid": chatGUID, + "filePath": try stageAttachment((file as NSString).expandingTildeInPath), + "isAudioMessage": boolParam(params["audio"] ?? params["is_audio"] ?? params["as_voice"]) + ?? false, + ] + if let reply = stringParam( + params["reply_to"] ?? params["replyTo"] ?? params["reply_to_guid"] ?? params["message_guid"] + ), !reply.isEmpty { + bridgeParams["selectedMessageGuid"] = reply + } + let data = try await invokeBridge(action: .sendAttachment, params: bridgeParams) var result: [String: Any] = ["ok": true] if let guid = data["messageGuid"] as? String, !guid.isEmpty { result["guid"] = guid diff --git a/Tests/IMsgCoreTests/MessageStoreReplyContextTests.swift b/Tests/IMsgCoreTests/MessageStoreReplyContextTests.swift new file mode 100644 index 0000000..34e3062 --- /dev/null +++ b/Tests/IMsgCoreTests/MessageStoreReplyContextTests.swift @@ -0,0 +1,320 @@ +import Foundation +import SQLite +import Testing + +@testable import IMsgCore + +/// Schema helper for reply-parent enrichment tests. Mirrors the +/// reaction-test fixture but includes the optional `thread_originator_guid` +/// column so we can exercise both the Threader-reply path and the +/// non-reaction `associated_message_guid` path. +private enum ReplyContextTestDatabase { + static func appleEpoch(_ date: Date) -> Int64 { + let seconds = date.timeIntervalSince1970 - MessageStore.appleEpochOffset + return Int64(seconds * 1_000_000_000) + } + + static func makeConnection() throws -> Connection { + let db = try Connection(.inMemory) + try db.execute( + """ + CREATE TABLE message ( + ROWID INTEGER PRIMARY KEY, + handle_id INTEGER, + text TEXT, + guid TEXT, + associated_message_guid TEXT, + associated_message_type INTEGER, + thread_originator_guid TEXT, + date INTEGER, + is_from_me INTEGER, + service TEXT + ); + """ + ) + try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);") + try db.execute("CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);") + try db.execute( + "CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);") + try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123'), (2, '+456')") + return db + } + + static func insertMessage( + _ db: Connection, + rowID: Int64, + handleID: Int64, + text: String, + guid: String, + date: Date, + isFromMe: Bool = false, + associatedGuid: String? = nil, + associatedType: Int? = nil, + threadOriginatorGuid: String? = nil, + chatID: Int64 = 1 + ) throws { + let bindings: [Binding?] = [ + rowID, + handleID, + text, + guid, + associatedGuid, + associatedType.map { Int64($0) }, + threadOriginatorGuid, + appleEpoch(date), + isFromMe ? Int64(1) : Int64(0), + ] + try db.run( + """ + INSERT INTO message( + ROWID, handle_id, text, guid, + associated_message_guid, associated_message_type, thread_originator_guid, + date, is_from_me, service + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'iMessage') + """, + bindings + ) + try db.run( + "INSERT INTO chat_message_join(chat_id, message_id) VALUES (?, ?)", chatID, rowID) + } +} + +@Test +func messagesEnrichesThreaderReplyParent() throws { + let db = try ReplyContextTestDatabase.makeConnection() + let now = Date() + try ReplyContextTestDatabase.insertMessage( + db, + rowID: 1, + handleID: 1, + text: "Should I lead with calendar, family, or email?", + guid: "parent-guid", + date: now.addingTimeInterval(-60) + ) + try ReplyContextTestDatabase.insertMessage( + db, + rowID: 2, + handleID: 2, + text: "Calendar", + guid: "reply-guid", + date: now, + threadOriginatorGuid: "parent-guid" + ) + + let store = try MessageStore(connection: db, path: ":memory:") + let messages = try store.messages(chatID: 1, limit: 10) + let reply = try #require(messages.first { $0.rowID == 2 }) + + #expect(reply.threadOriginatorGUID == "parent-guid") + #expect(reply.replyToText == "Should I lead with calendar, family, or email?") + #expect(reply.replyToSender == "+123") +} + +@Test +func messagesAfterEnrichesAssociatedReplyParent() throws { + let db = try ReplyContextTestDatabase.makeConnection() + let now = Date() + try ReplyContextTestDatabase.insertMessage( + db, + rowID: 1, + handleID: 1, + text: "Original photo caption", + guid: "parent-guid", + date: now.addingTimeInterval(-60) + ) + // Sticker / non-reaction association — associated_message_type < 2000. + try ReplyContextTestDatabase.insertMessage( + db, + rowID: 2, + handleID: 2, + text: "Cool", + guid: "reply-guid", + date: now, + associatedGuid: "p:0/parent-guid", + associatedType: 1 + ) + + let store = try MessageStore(connection: db, path: ":memory:") + let messages = try store.messagesAfter(afterRowID: 0, chatID: 1, limit: 10) + let reply = try #require(messages.first { $0.rowID == 2 }) + + #expect(reply.replyToGUID == "parent-guid") + #expect(reply.replyToText == "Original photo caption") + #expect(reply.replyToSender == "+123") +} + +@Test +func threadOriginatorWinsWhenBothReplyReferencesExist() throws { + let db = try ReplyContextTestDatabase.makeConnection() + let now = Date() + try ReplyContextTestDatabase.insertMessage( + db, + rowID: 1, + handleID: 1, + text: "Associated parent", + guid: "associated-parent", + date: now.addingTimeInterval(-90) + ) + try ReplyContextTestDatabase.insertMessage( + db, + rowID: 2, + handleID: 2, + text: "Thread parent", + guid: "thread-parent", + date: now.addingTimeInterval(-60) + ) + try ReplyContextTestDatabase.insertMessage( + db, + rowID: 3, + handleID: 2, + text: "Reply", + guid: "reply-guid", + date: now, + associatedGuid: "p:0/associated-parent", + associatedType: 1, + threadOriginatorGuid: "thread-parent" + ) + + let store = try MessageStore(connection: db, path: ":memory:") + let messages = try store.messagesAfter(afterRowID: 0, chatID: 1, limit: 10) + let reply = try #require(messages.first { $0.rowID == 3 }) + + #expect(reply.replyToGUID == "associated-parent") + #expect(reply.threadOriginatorGUID == "thread-parent") + #expect(reply.replyToText == "Thread parent") + #expect(reply.replyToSender == "+456") +} + +@Test +func reactionsDoNotProduceReplyContext() throws { + let db = try ReplyContextTestDatabase.makeConnection() + let now = Date() + try ReplyContextTestDatabase.insertMessage( + db, + rowID: 1, + handleID: 1, + text: "Original message", + guid: "parent-guid", + date: now.addingTimeInterval(-60) + ) + // Love reaction. `messages()` filters reactions out entirely; this test + // confirms reactions reaching `messagesAfter(includeReactions: true)` + // don't get flagged as a reply. + try ReplyContextTestDatabase.insertMessage( + db, + rowID: 2, + handleID: 2, + text: "", + guid: "reaction-guid", + date: now, + associatedGuid: "p:0/parent-guid", + associatedType: 2000, + threadOriginatorGuid: "parent-guid" + ) + + let store = try MessageStore(connection: db, path: ":memory:") + let allMessages = try store.messagesAfter( + afterRowID: 0, chatID: 1, limit: 10, includeReactions: true) + let reaction = try #require(allMessages.first { $0.rowID == 2 }) + + #expect(reaction.isReaction) + #expect(reaction.replyToGUID == nil) + #expect(reaction.threadOriginatorGUID == nil) + #expect(reaction.replyToText == nil) + #expect(reaction.replyToSender == nil) +} + +@Test +func replyParentMissingFromChatDbLeavesContextNil() throws { + let db = try ReplyContextTestDatabase.makeConnection() + let now = Date() + // Reply references a parent that was purged / never landed locally. + try ReplyContextTestDatabase.insertMessage( + db, + rowID: 1, + handleID: 2, + text: "Calendar", + guid: "reply-guid", + date: now, + threadOriginatorGuid: "missing-parent-guid" + ) + + let store = try MessageStore(connection: db, path: ":memory:") + let messages = try store.messages(chatID: 1, limit: 10) + let reply = try #require(messages.first { $0.rowID == 1 }) + + #expect(reply.threadOriginatorGUID == "missing-parent-guid") + #expect(reply.replyToText == nil) + #expect(reply.replyToSender == nil) +} + +@Test +func multipleRepliesShareCachedParent() throws { + // Two distinct replies pointing at the same parent should both surface + // the parent's body + sender. The implementation memoizes parent + // lookups per query loop; this test pins the behavioural contract + // (two enrichments, identical text/sender) without reaching into the + // cache directly. + let db = try ReplyContextTestDatabase.makeConnection() + let now = Date() + try ReplyContextTestDatabase.insertMessage( + db, + rowID: 1, + handleID: 1, + text: "Parent message body", + guid: "parent-guid", + date: now.addingTimeInterval(-300) + ) + try ReplyContextTestDatabase.insertMessage( + db, + rowID: 2, + handleID: 2, + text: "Reply A", + guid: "reply-a", + date: now.addingTimeInterval(-200), + threadOriginatorGuid: "parent-guid" + ) + try ReplyContextTestDatabase.insertMessage( + db, + rowID: 3, + handleID: 2, + text: "Reply B", + guid: "reply-b", + date: now.addingTimeInterval(-100), + threadOriginatorGuid: "parent-guid" + ) + + let store = try MessageStore(connection: db, path: ":memory:") + let messages = try store.messages(chatID: 1, limit: 10) + let replyA = try #require(messages.first { $0.rowID == 2 }) + let replyB = try #require(messages.first { $0.rowID == 3 }) + + #expect(replyA.replyToText == "Parent message body") + #expect(replyA.replyToSender == "+123") + #expect(replyB.replyToText == "Parent message body") + #expect(replyB.replyToSender == "+123") +} + +@Test +func nonReplyMessagesLeaveContextNil() throws { + let db = try ReplyContextTestDatabase.makeConnection() + let now = Date() + try ReplyContextTestDatabase.insertMessage( + db, + rowID: 1, + handleID: 1, + text: "Just a regular message", + guid: "guid-1", + date: now + ) + + let store = try MessageStore(connection: db, path: ":memory:") + let messages = try store.messages(chatID: 1, limit: 10) + let message = try #require(messages.first) + + #expect(message.replyToGUID == nil) + #expect(message.threadOriginatorGUID == nil) + #expect(message.replyToText == nil) + #expect(message.replyToSender == nil) +} diff --git a/Tests/imsgTests/RPCBridgeMessageHandlersTests.swift b/Tests/imsgTests/RPCBridgeMessageHandlersTests.swift index ac3224b..fcbfdd0 100644 --- a/Tests/imsgTests/RPCBridgeMessageHandlersTests.swift +++ b/Tests/imsgTests/RPCBridgeMessageHandlersTests.swift @@ -59,6 +59,29 @@ func rpcSendRichInvokesBridgeWithResolvedChat() async throws { #expect(result?["guid"] as? String == "rich-guid") } +@Test +func rpcSendRichSuppressesQueuedBridgeGuid() async throws { + let store = try CommandTestDatabase.makeStoreForRPC() + let output = TestRPCOutput() + let server = RPCServer( + store: store, + verbose: false, + output: output, + invokeBridge: { _, _ in + ["messageGuid": "previous-guid", "queued": true] + } + ) + + await server.handleLineForTesting( + #"{"jsonrpc":"2.0","id":"rich","method":"send.rich","params":{"chat_id":1,"text":"boom"}}"# + ) + + let result = output.responses.first?["result"] as? [String: Any] + #expect(result?["queued"] as? Bool == true) + #expect(result?["guid"] == nil) + #expect(result?["message_id"] == nil) +} + @Test func rpcSendAttachmentStagesFileBeforeBridgeSend() async throws { let store = try CommandTestDatabase.makeStoreForRPC() @@ -79,13 +102,15 @@ func rpcSendAttachmentStagesFileBeforeBridgeSend() async throws { } ) - await server.handleLineForTesting( - #"{"jsonrpc":"2.0","id":"attachment","method":"send.attachment","params":{"chat_id":1,"file":"~/Desktop/file.png","audio":true}}"# - ) + let line = + #"{"jsonrpc":"2.0","id":"attachment","method":"send.attachment","params":{"# + + #""chat_id":1,"file":"~/Desktop/file.png","audio":true,"reply_to":"parent-guid"}}"# + await server.handleLineForTesting(line) #expect(stagedInput?.hasSuffix("/Desktop/file.png") == true) #expect(capturedParams["filePath"] as? String == "/tmp/staged-file.png") #expect(capturedParams["isAudioMessage"] as? Bool == true) + #expect(capturedParams["selectedMessageGuid"] as? String == "parent-guid") let result = output.responses.first?["result"] as? [String: Any] #expect(result?["message_id"] as? String == "attachment-guid") } diff --git a/Tests/imsgTests/RPCPayloadsTests.swift b/Tests/imsgTests/RPCPayloadsTests.swift index a5a98cb..97e3a3d 100644 --- a/Tests/imsgTests/RPCPayloadsTests.swift +++ b/Tests/imsgTests/RPCPayloadsTests.swift @@ -112,6 +112,70 @@ func messagePayloadIncludesChatFields() throws { == ReactionType.like.emoji) } +@Test +func messagePayloadExposesReplyParentSnakeCaseKeys() throws { + let message = Message( + rowID: 11, + chatID: 10, + sender: "+456", + text: "Calendar", + date: Date(timeIntervalSince1970: 3), + isFromMe: false, + service: "iMessage", + handleID: 2, + attachmentsCount: 0, + guid: "reply-guid", + threadOriginatorGUID: "parent-guid", + replyToText: "Should I lead with calendar, family, or email?", + replyToSender: "+123" + ) + let payload = try messagePayload( + message: message, + chatInfo: nil, + participants: [], + attachments: [], + reactions: [] + ) + + #expect( + payload["reply_to_text"] as? String == "Should I lead with calendar, family, or email?" + ) + #expect(payload["reply_to_sender"] as? String == "+123") + #expect(payload["thread_originator_guid"] as? String == "parent-guid") +} + +@Test +func messagePayloadOmitsReplyParentWhenAbsent() throws { + let message = Message( + rowID: 12, + chatID: 10, + sender: "+456", + text: "standalone", + date: Date(timeIntervalSince1970: 3), + isFromMe: false, + service: "iMessage", + handleID: 2, + attachmentsCount: 0, + guid: "msg-guid-12" + ) + let payload = try messagePayload( + message: message, + chatInfo: nil, + participants: [], + attachments: [], + reactions: [] + ) + + // JSONSerialization preserves Codable `nil` as a missing key (the bridging + // omits NSNull entries from `Encodable?` properties). Treat both + // "missing key" and "NSNull" as absent so the assertion stays robust to + // SQLite.swift / Foundation behaviour changes. + let replyText = payload["reply_to_text"] + let replySender = payload["reply_to_sender"] + #expect(replyText == nil || replyText is NSNull) + #expect(replySender == nil || replySender is NSNull) +} + @Test func messagePayloadIncludesSenderAndReactionNames() throws { let message = Message(