From 9bf5e3e236dd876853828c6e9b712c21429a0455 Mon Sep 17 00:00:00 2001 From: Ru Date: Wed, 4 Feb 2026 10:20:56 -0600 Subject: [PATCH 1/2] feat: add thread_originator_guid to message output Adds thread_originator_guid field to JSON output for history, watch, and RPC. This field contains the GUID of the message being replied to when users use iMessage's inline reply feature. This is the correct field for reply detection - it matches the UI's reply target, unlike reply_to_guid which can point to different messages. Closes #30 Co-Authored-By: Claude --- Sources/IMsgCore/MessageStore+Messages.swift | 16 ++++++++++++---- Sources/IMsgCore/Models.swift | 5 ++++- Sources/imsg/OutputModels.swift | 3 +++ Sources/imsg/RPCPayloads.swift | 3 +++ 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/Sources/IMsgCore/MessageStore+Messages.swift b/Sources/IMsgCore/MessageStore+Messages.swift index 0ccee4a..2694331 100644 --- a/Sources/IMsgCore/MessageStore+Messages.swift +++ b/Sources/IMsgCore/MessageStore+Messages.swift @@ -13,6 +13,7 @@ extension MessageStore { let associatedTypeColumn = hasReactionColumns ? "m.associated_message_type" : "NULL" let destinationCallerColumn = hasDestinationCallerID ? "m.destination_caller_id" : "NULL" let audioMessageColumn = hasAudioMessageColumn ? "m.is_audio_message" : "0" + let threadOriginatorColumn = hasReactionColumns ? "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)" @@ -22,7 +23,8 @@ extension MessageStore { \(audioMessageColumn) AS is_audio_message, \(destinationCallerColumn) AS destination_caller_id, \(guidColumn) AS guid, \(associatedGuidColumn) AS associated_guid, \(associatedTypeColumn) AS associated_type, (SELECT COUNT(*) FROM message_attachment_join maj WHERE maj.message_id = m.ROWID) AS attachments, - \(bodyColumn) AS body + \(bodyColumn) AS body, + \(threadOriginatorColumn) AS thread_originator_guid FROM message m JOIN chat_message_join cmj ON m.ROWID = cmj.message_id LEFT JOIN handle h ON m.handle_id = h.ROWID @@ -74,6 +76,7 @@ extension MessageStore { let associatedType = intValue(row[11]) let attachments = intValue(row[12]) ?? 0 let body = dataValue(row[13]) + let threadOriginatorGUID = stringValue(row[14]) var resolvedText = text.isEmpty ? TypedStreamParser.parseAttributedBody(body) : text if isAudioMessage, let transcription = try audioTranscription(for: rowID) { resolvedText = transcription @@ -94,7 +97,8 @@ extension MessageStore { handleID: handleID, attachmentsCount: attachments, guid: guid, - replyToGUID: replyToGUID + replyToGUID: replyToGUID, + threadOriginatorGUID: threadOriginatorGUID.isEmpty ? nil : threadOriginatorGUID )) } return messages @@ -108,6 +112,7 @@ extension MessageStore { let associatedTypeColumn = hasReactionColumns ? "m.associated_message_type" : "NULL" let destinationCallerColumn = hasDestinationCallerID ? "m.destination_caller_id" : "NULL" let audioMessageColumn = hasAudioMessageColumn ? "m.is_audio_message" : "0" + let threadOriginatorColumn = hasReactionColumns ? "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)" @@ -117,7 +122,8 @@ extension MessageStore { \(audioMessageColumn) AS is_audio_message, \(destinationCallerColumn) AS destination_caller_id, \(guidColumn) AS guid, \(associatedGuidColumn) AS associated_guid, \(associatedTypeColumn) AS associated_type, (SELECT COUNT(*) FROM message_attachment_join maj WHERE maj.message_id = m.ROWID) AS attachments, - \(bodyColumn) AS body + \(bodyColumn) AS body, + \(threadOriginatorColumn) AS thread_originator_guid 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 @@ -152,6 +158,7 @@ extension MessageStore { let associatedType = intValue(row[12]) let attachments = intValue(row[13]) ?? 0 let body = dataValue(row[14]) + let threadOriginatorGUID = stringValue(row[15]) var resolvedText = text.isEmpty ? TypedStreamParser.parseAttributedBody(body) : text if isAudioMessage, let transcription = try audioTranscription(for: rowID) { resolvedText = transcription @@ -172,7 +179,8 @@ extension MessageStore { handleID: handleID, attachmentsCount: attachments, guid: guid, - replyToGUID: replyToGUID + replyToGUID: replyToGUID, + threadOriginatorGUID: threadOriginatorGUID.isEmpty ? nil : threadOriginatorGUID )) } return messages diff --git a/Sources/IMsgCore/Models.swift b/Sources/IMsgCore/Models.swift index ca15e9d..de2e893 100644 --- a/Sources/IMsgCore/Models.swift +++ b/Sources/IMsgCore/Models.swift @@ -221,6 +221,7 @@ public struct Message: Sendable, Equatable { public let chatID: Int64 public let guid: String public let replyToGUID: String? + public let threadOriginatorGUID: String? public let sender: String public let text: String public let date: Date @@ -240,12 +241,14 @@ public struct Message: Sendable, Equatable { handleID: Int64?, attachmentsCount: Int, guid: String = "", - replyToGUID: String? = nil + replyToGUID: String? = nil, + threadOriginatorGUID: String? = nil ) { self.rowID = rowID self.chatID = chatID self.guid = guid self.replyToGUID = replyToGUID + self.threadOriginatorGUID = threadOriginatorGUID self.sender = sender self.text = text self.date = date diff --git a/Sources/imsg/OutputModels.swift b/Sources/imsg/OutputModels.swift index ef8e477..25dd1b7 100644 --- a/Sources/imsg/OutputModels.swift +++ b/Sources/imsg/OutputModels.swift @@ -30,6 +30,7 @@ struct MessagePayload: Codable { let chatID: Int64 let guid: String let replyToGUID: String? + let threadOriginatorGUID: String? let sender: String let isFromMe: Bool let text: String @@ -42,6 +43,7 @@ struct MessagePayload: Codable { self.chatID = message.chatID self.guid = message.guid self.replyToGUID = message.replyToGUID + self.threadOriginatorGUID = message.threadOriginatorGUID self.sender = message.sender self.isFromMe = message.isFromMe self.text = message.text @@ -55,6 +57,7 @@ struct MessagePayload: Codable { case chatID = "chat_id" case guid case replyToGUID = "reply_to_guid" + case threadOriginatorGUID = "thread_originator_guid" case sender case isFromMe = "is_from_me" case text diff --git a/Sources/imsg/RPCPayloads.swift b/Sources/imsg/RPCPayloads.swift index ae71090..5e3c8ad 100644 --- a/Sources/imsg/RPCPayloads.swift +++ b/Sources/imsg/RPCPayloads.swift @@ -51,6 +51,9 @@ func messagePayload( if let replyToGUID = message.replyToGUID, !replyToGUID.isEmpty { payload["reply_to_guid"] = replyToGUID } + if let threadOriginatorGUID = message.threadOriginatorGUID, !threadOriginatorGUID.isEmpty { + payload["thread_originator_guid"] = threadOriginatorGUID + } return payload } From ab8855a37cefc1b108689ad691666561b401cee0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 14:13:35 +0100 Subject: [PATCH 2/2] fix: detect thread_originator_guid column (#39) (thanks @ruthmade) --- CHANGELOG.md | 1 + Sources/IMsgCore/MessageStore+Helpers.swift | 16 +++++++ Sources/IMsgCore/MessageStore+Messages.swift | 6 ++- Sources/IMsgCore/MessageStore.swift | 12 ++++++ Tests/IMsgCoreTests/MessageStoreTests.swift | 44 ++++++++++++++++++++ Tests/imsgTests/RPCPayloadsTests.swift | 5 ++- Tests/imsgTests/UtilitiesTests.swift | 4 +- 7 files changed, 84 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c03458..d4c92e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - fix: prefer handle sends when chat identifier is a direct handle - fix: apply history filters before limit (#20, thanks @tommybananas) +- feat: include `thread_originator_guid` in message output (#39, thanks @ruthmade) ## 0.4.0 - 2026-01-07 - feat: surface audio message transcriptions (thanks @antons) diff --git a/Sources/IMsgCore/MessageStore+Helpers.swift b/Sources/IMsgCore/MessageStore+Helpers.swift index c567566..94d2ea7 100644 --- a/Sources/IMsgCore/MessageStore+Helpers.swift +++ b/Sources/IMsgCore/MessageStore+Helpers.swift @@ -2,6 +2,22 @@ import Foundation import SQLite extension MessageStore { + static func detectThreadOriginatorGUIDColumn(connection: Connection) -> Bool { + do { + let rows = try connection.prepare("PRAGMA table_info(message)") + for row in rows { + if let name = row[1] as? String, + name.caseInsensitiveCompare("thread_originator_guid") == .orderedSame + { + return true + } + } + } catch { + return false + } + return false + } + static func detectAttributedBody(connection: Connection) -> Bool { do { let rows = try connection.prepare("PRAGMA table_info(message)") diff --git a/Sources/IMsgCore/MessageStore+Messages.swift b/Sources/IMsgCore/MessageStore+Messages.swift index 2694331..18716b6 100644 --- a/Sources/IMsgCore/MessageStore+Messages.swift +++ b/Sources/IMsgCore/MessageStore+Messages.swift @@ -13,7 +13,8 @@ extension MessageStore { let associatedTypeColumn = hasReactionColumns ? "m.associated_message_type" : "NULL" let destinationCallerColumn = hasDestinationCallerID ? "m.destination_caller_id" : "NULL" let audioMessageColumn = hasAudioMessageColumn ? "m.is_audio_message" : "0" - let threadOriginatorColumn = hasReactionColumns ? "m.thread_originator_guid" : "NULL" + 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)" @@ -112,7 +113,8 @@ extension MessageStore { let associatedTypeColumn = hasReactionColumns ? "m.associated_message_type" : "NULL" let destinationCallerColumn = hasDestinationCallerID ? "m.destination_caller_id" : "NULL" let audioMessageColumn = hasAudioMessageColumn ? "m.is_audio_message" : "0" - let threadOriginatorColumn = hasReactionColumns ? "m.thread_originator_guid" : "NULL" + 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)" diff --git a/Sources/IMsgCore/MessageStore.swift b/Sources/IMsgCore/MessageStore.swift index 7a4ff63..b4e7755 100644 --- a/Sources/IMsgCore/MessageStore.swift +++ b/Sources/IMsgCore/MessageStore.swift @@ -16,6 +16,7 @@ public final class MessageStore: @unchecked Sendable { private let queueKey = DispatchSpecificKey() let hasAttributedBody: Bool let hasReactionColumns: Bool + let hasThreadOriginatorGUIDColumn: Bool let hasDestinationCallerID: Bool let hasAudioMessageColumn: Bool let hasAttachmentUserInfo: Bool @@ -32,6 +33,9 @@ public final class MessageStore: @unchecked Sendable { self.connection.busyTimeout = 5 self.hasAttributedBody = MessageStore.detectAttributedBody(connection: self.connection) self.hasReactionColumns = MessageStore.detectReactionColumns(connection: self.connection) + self.hasThreadOriginatorGUIDColumn = MessageStore.detectThreadOriginatorGUIDColumn( + connection: self.connection + ) self.hasDestinationCallerID = MessageStore.detectDestinationCallerID( connection: self.connection ) @@ -51,6 +55,7 @@ public final class MessageStore: @unchecked Sendable { path: String, hasAttributedBody: Bool? = nil, hasReactionColumns: Bool? = nil, + hasThreadOriginatorGUIDColumn: Bool? = nil, hasDestinationCallerID: Bool? = nil, hasAudioMessageColumn: Bool? = nil, hasAttachmentUserInfo: Bool? = nil @@ -70,6 +75,13 @@ public final class MessageStore: @unchecked Sendable { } else { self.hasReactionColumns = MessageStore.detectReactionColumns(connection: connection) } + if let hasThreadOriginatorGUIDColumn { + self.hasThreadOriginatorGUIDColumn = hasThreadOriginatorGUIDColumn + } else { + self.hasThreadOriginatorGUIDColumn = MessageStore.detectThreadOriginatorGUIDColumn( + connection: connection + ) + } if let hasDestinationCallerID { self.hasDestinationCallerID = hasDestinationCallerID } else { diff --git a/Tests/IMsgCoreTests/MessageStoreTests.swift b/Tests/IMsgCoreTests/MessageStoreTests.swift index 0cd9202..bdc59b7 100644 --- a/Tests/IMsgCoreTests/MessageStoreTests.swift +++ b/Tests/IMsgCoreTests/MessageStoreTests.swift @@ -315,6 +315,50 @@ func messagesReplyToGuidHandlesNoPrefix() throws { #expect(reply?.replyToGUID == "msg-guid-1") } +@Test +func messagesExposeThreadOriginatorGuidWhenAvailable() throws { + 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);") + + let now = Date() + try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')") + 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 (1, 1, 'hello', 'msg-guid-1', NULL, 0, 'thread-guid-1', ?, 0, 'iMessage') + """, + TestDatabase.appleEpoch(now) + ) + try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 1)") + + let store = try MessageStore(connection: db, path: ":memory:") + let messages = try store.messages(chatID: 1, limit: 10) + let message = messages.first { $0.rowID == 1 } + #expect(message?.threadOriginatorGUID == "thread-guid-1") +} + @Test func attachmentsByMessageReturnsMetadata() throws { let store = try TestDatabase.makeStore() diff --git a/Tests/imsgTests/RPCPayloadsTests.swift b/Tests/imsgTests/RPCPayloadsTests.swift index f4deb6a..f290df6 100644 --- a/Tests/imsgTests/RPCPayloadsTests.swift +++ b/Tests/imsgTests/RPCPayloadsTests.swift @@ -42,7 +42,8 @@ func messagePayloadIncludesChatFields() { handleID: nil, attachmentsCount: 1, guid: "msg-guid-5", - replyToGUID: "msg-guid-1" + replyToGUID: "msg-guid-1", + threadOriginatorGUID: "thread-guid-5" ) let chatInfo = ChatInfo( id: 10, @@ -79,6 +80,7 @@ func messagePayloadIncludesChatFields() { #expect(payload["chat_id"] as? Int64 == 10) #expect(payload["guid"] as? String == "msg-guid-5") #expect(payload["reply_to_guid"] as? String == "msg-guid-1") + #expect(payload["thread_originator_guid"] as? String == "thread-guid-5") #expect(payload["chat_identifier"] as? String == "iMessage;+;chat123") #expect(payload["chat_name"] as? String == "Group") #expect(payload["is_group"] as? Bool == true) @@ -111,6 +113,7 @@ func messagePayloadOmitsEmptyReplyToGuid() { reactions: [] ) #expect(payload["reply_to_guid"] == nil) + #expect(payload["thread_originator_guid"] == nil) #expect(payload["guid"] as? String == "msg-guid-6") } diff --git a/Tests/imsgTests/UtilitiesTests.swift b/Tests/imsgTests/UtilitiesTests.swift index 1087171..753db1e 100644 --- a/Tests/imsgTests/UtilitiesTests.swift +++ b/Tests/imsgTests/UtilitiesTests.swift @@ -83,7 +83,8 @@ func outputModelsEncodeExpectedKeys() throws { handleID: nil, attachmentsCount: 0, guid: "msg-guid-7", - replyToGUID: "msg-guid-1" + replyToGUID: "msg-guid-1", + threadOriginatorGUID: "thread-guid-7" ) let attachment = AttachmentMeta( filename: "file.dat", @@ -110,6 +111,7 @@ func outputModelsEncodeExpectedKeys() throws { #expect(messageObject?["chat_id"] as? Int64 == 1) #expect(messageObject?["guid"] as? String == "msg-guid-7") #expect(messageObject?["reply_to_guid"] as? String == "msg-guid-1") + #expect(messageObject?["thread_originator_guid"] as? String == "thread-guid-7") #expect(messageObject?["created_at"] != nil) let attachmentPayload = AttachmentPayload(meta: attachment)