From dc94c36d308e7fe2323dbcc0ab8097d47705693b Mon Sep 17 00:00:00 2001 From: Omar Shahine <10343873+omarshahine@users.noreply.github.com> Date: Thu, 14 May 2026 21:58:53 -0700 Subject: [PATCH 1/2] feat: emit reply_to_text and reply_to_sender for threaded replies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iMessage Threader replies (and non-reaction associations like sticker replies) round-trip through imsg carrying only the parent's guid. Downstream JSON consumers that want to quote the parent body have to open chat.db themselves — and sandboxed ones (e.g. OpenClaw's iMessage channel) can't, so a one-word reply like "Calendar" reaches the agent with no quoted context at all. Resolve the reply parent inside the messages / messagesAfter / latestSentMessage / search paths by joining thread_originator_guid first, then a non-reaction normalized associated_message_guid, back to its row. The parent is decoded through the existing decodeMessageRow so attributedBody fallback and sender resolution match a top-level message; reactions (associated type 2000-3006) are excluded. Expose the result as Message.replyToText / Message.replyToSender and emit it as reply_to_text / reply_to_sender on the JSON payload (history, search, rpc, watch). Absent parents leave the fields nil so the wire shape stays additive — OpenClaw's existing reader picks the fields up with no gateway-side change. A per-query-loop ReplyParentCache memoizes both hits and misses so a thread with many replies to the same parent issues one SELECT per distinct parent guid rather than one per row. The private query structs move to MessageStore+Queries.swift to keep MessageStore+Messages.swift under the file-length lint cap. Tests: 230 passing (3 new MessagePayload JSON-contract assertions, 5 new MessageStoreReplyContextTests covering threaded reply, sticker-style associated reply, reaction-not-enriched, missing-parent-nil, and shared-parent cache). make lint clean on touched files. --- CHANGELOG.md | 8 + README.md | 3 + Sources/IMsgCore/MessageStore+Messages.swift | 176 +++-------- Sources/IMsgCore/MessageStore+Queries.swift | 130 +++++++++ .../IMsgCore/MessageStore+ReplyContext.swift | 63 ++++ Sources/IMsgCore/MessageStore+Search.swift | 16 +- Sources/IMsgCore/Models.swift | 23 +- Sources/imsg/OutputModels.swift | 10 + .../MessageStoreReplyContextTests.swift | 276 ++++++++++++++++++ Tests/imsgTests/RPCPayloadsTests.swift | 64 ++++ 10 files changed, 627 insertions(+), 142 deletions(-) create mode 100644 Sources/IMsgCore/MessageStore+Queries.swift create mode 100644 Sources/IMsgCore/MessageStore+ReplyContext.swift create mode 100644 Tests/IMsgCoreTests/MessageStoreReplyContextTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 63ce975..d5c9464 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## 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. + ### Private API Bridge - fix: support threaded attachment replies via `send-rich --file` and `send-attachment --reply-to`, including the macOS 26 attachment staging 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..5722a0c 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) @@ -339,6 +222,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 + ) let reaction = decodeReaction( associatedType: decoded.associatedType, associatedGUID: decoded.associatedGUID, @@ -359,10 +250,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 +292,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 +314,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..4a77e00 --- /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 `replyToGUID` then `threadOriginatorGUID` 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 [replyToGUID, threadOriginatorGUID] { + 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/Tests/IMsgCoreTests/MessageStoreReplyContextTests.swift b/Tests/IMsgCoreTests/MessageStoreReplyContextTests.swift new file mode 100644 index 0000000..94a0433 --- /dev/null +++ b/Tests/IMsgCoreTests/MessageStoreReplyContextTests.swift @@ -0,0 +1,276 @@ +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 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 + ) + + 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.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/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( From de38b68411e8745153cd0299d331e253c2f74718 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 16 May 2026 15:01:22 +0100 Subject: [PATCH 2/2] fix: harden inbound reply context --- Sources/IMsgCore/MessageStore+Messages.swift | 28 +++++++++++-------- .../MessageStoreReplyContextTests.swift | 4 ++- .../RPCBridgeMessageHandlersTests.swift | 7 +++-- 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/Sources/IMsgCore/MessageStore+Messages.swift b/Sources/IMsgCore/MessageStore+Messages.swift index 5722a0c..cc9ee1f 100644 --- a/Sources/IMsgCore/MessageStore+Messages.swift +++ b/Sources/IMsgCore/MessageStore+Messages.swift @@ -218,23 +218,27 @@ extension MessageStore { continue } - let replyToGUID = replyToGUID( - associatedGuid: decoded.associatedGUID, - associatedType: decoded.associatedType - ) - let threadOriginatorGUID = - decoded.threadOriginatorGUID.isEmpty ? nil : decoded.threadOriginatorGUID - let parent = enrichedReplyContext( - db, - replyToGUID: replyToGUID, - threadOriginatorGUID: threadOriginatorGUID, - cache: &parentCache - ) 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( diff --git a/Tests/IMsgCoreTests/MessageStoreReplyContextTests.swift b/Tests/IMsgCoreTests/MessageStoreReplyContextTests.swift index 0d216df..34e3062 100644 --- a/Tests/IMsgCoreTests/MessageStoreReplyContextTests.swift +++ b/Tests/IMsgCoreTests/MessageStoreReplyContextTests.swift @@ -209,7 +209,8 @@ func reactionsDoNotProduceReplyContext() throws { guid: "reaction-guid", date: now, associatedGuid: "p:0/parent-guid", - associatedType: 2000 + associatedType: 2000, + threadOriginatorGuid: "parent-guid" ) let store = try MessageStore(connection: db, path: ":memory:") @@ -219,6 +220,7 @@ func reactionsDoNotProduceReplyContext() throws { #expect(reaction.isReaction) #expect(reaction.replyToGUID == nil) + #expect(reaction.threadOriginatorGUID == nil) #expect(reaction.replyToText == nil) #expect(reaction.replyToSender == nil) } diff --git a/Tests/imsgTests/RPCBridgeMessageHandlersTests.swift b/Tests/imsgTests/RPCBridgeMessageHandlersTests.swift index 1e81e7c..fcbfdd0 100644 --- a/Tests/imsgTests/RPCBridgeMessageHandlersTests.swift +++ b/Tests/imsgTests/RPCBridgeMessageHandlersTests.swift @@ -102,9 +102,10 @@ 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,"reply_to":"parent-guid"}}"# - ) + 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")