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 0ccee4a..18716b6 100644 --- a/Sources/IMsgCore/MessageStore+Messages.swift +++ b/Sources/IMsgCore/MessageStore+Messages.swift @@ -13,6 +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 = + 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)" @@ -22,7 +24,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 +77,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 +98,8 @@ extension MessageStore { handleID: handleID, attachmentsCount: attachments, guid: guid, - replyToGUID: replyToGUID + replyToGUID: replyToGUID, + threadOriginatorGUID: threadOriginatorGUID.isEmpty ? nil : threadOriginatorGUID )) } return messages @@ -108,6 +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 = + 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)" @@ -117,7 +124,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 +160,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 +181,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/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/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 } 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)