From 696405988774592ddd73acb765e135f640bdff67 Mon Sep 17 00:00:00 2001 From: patp Date: Tue, 1 Sep 2026 13:57:40 -0400 Subject: [PATCH 1/2] Expose the chat a message belongs to and match own messages by chat Messages the current user sends from another device are synced to chat.db with handle_id = 0, so the participantHandles predicate (which only looked at message.handle_id) never returned them, and callers had no way to tell which conversation such a message belonged to. - Add Message.chatID, resolved through chat_message_join (the joined chat when the predicate already joins the chat tables, a per-message lookup otherwise). - participantHandles now also matches messages from the current user that belong to a chat with any of the given handles. - Add ChatPredicate.id to look a chat up by identifier. - Tests for all three, including a from-me message with handle_id = 0. --- README.md | 20 ++++ Sources/iMessage/Database.swift | 61 +++++++++-- Sources/iMessage/FetchRequest.swift | 6 +- Sources/iMessage/Message.swift | 7 ++ Tests/iMessageTests/DatabaseTests.swift | 137 ++++++++++++++++++++++-- 5 files changed, 212 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 873aa69..4d6b71d 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,26 @@ for message in try db.fetch(request) { print("From: \(message.sender)") print("Content: \(message.text)") print("Sent at: \(message.date)") + print("Chat: \(message.chatID)") +} +``` + +> [!NOTE] +> `participantHandles` matches messages sent by those handles +> _and_ messages you sent in chats they take part in. +> Messages you send from another device (iPhone, iPad) +> are synced without a sender handle, +> so they can only be tied to a conversation through their chat — +> which `Message.chatID` exposes. + +### Fetching Chats + +```swift +// Look a chat up by identifier, e.g. from a message's `chatID` +let request = FetchRequest(predicate: .id("iMessage;-;+18002752273")) +if let chat = try db.fetch(request).first { + print("Name: \(chat.displayName ?? "-")") + print("Participants: \(chat.participants)") } ``` diff --git a/Sources/iMessage/Database.swift b/Sources/iMessage/Database.swift index 44175df..787ad5c 100644 --- a/Sources/iMessage/Database.swift +++ b/Sources/iMessage/Database.swift @@ -247,6 +247,23 @@ public final class Database { parameters.append(try bindableInt32(request.limit, name: "limit")) parameters.append(try bindableInt32(request.offset, name: "offset")) + // When the predicate already joins the chat tables, + // report the chat that satisfied it; + // otherwise look the chat up per message. + let chatGuidColumn = + compiledPredicate.requiresChatJoin + ? "c.guid" + : """ + ( + SELECT c2.guid + FROM chat_message_join cmj2 + JOIN chat c2 ON cmj2.chat_id = c2.ROWID + WHERE cmj2.message_id = m.ROWID + ORDER BY cmj2.chat_id + LIMIT 1 + ) + """ + let query = """ SELECT m.guid, @@ -256,7 +273,8 @@ public final class Database { m.is_from_me, h.id, m.service, - m.date_read + m.date_read, + \(chatGuidColumn) AS chat_guid FROM message m \(compiledPredicate.requiresChatJoin ? "JOIN chat_message_join cmj ON m.ROWID = cmj.message_id" : "") \(compiledPredicate.requiresChatJoin ? "JOIN chat c ON cmj.chat_id = c.ROWID" : "") @@ -304,13 +322,17 @@ public final class Database { let senderText = sqlite3_column_text(statement, 5) let sender = senderText.map { Account.Handle(rawValue: String(cString: $0)) } + let chatGuidText = sqlite3_column_text(statement, 8) + let chatID = chatGuidText.map { Chat.ID(rawValue: String(cString: $0)) } + return Message( id: messageID, text: text, date: date, isFromMe: isFromMe, readAt: readAt, - sender: sender + sender: sender, + chatID: chatID ) } } @@ -390,17 +412,34 @@ public final class Database { } let handleValues = orderedHandleValues(handles) let placeholders = placeholders(handles.count) + // Messages from the current user only carry a handle + // when they were sent from this Mac; + // those sent from another device (and synced over iCloud) + // have no handle and can only be matched + // through the chat they belong to. let condition = """ - m.ROWID IN ( - SELECT m2.ROWID - FROM message m2 - JOIN handle h ON m2.handle_id = h.ROWID - WHERE h.id IN (\(placeholders)) + ( + m.ROWID IN ( + SELECT m2.ROWID + FROM message m2 + JOIN handle h ON m2.handle_id = h.ROWID + WHERE h.id IN (\(placeholders)) + ) + OR ( + m.is_from_me = 1 + AND m.ROWID IN ( + SELECT cmj.message_id + FROM chat_message_join cmj + JOIN chat_handle_join chj ON chj.chat_id = cmj.chat_id + JOIN handle h ON chj.handle_id = h.ROWID + WHERE h.id IN (\(placeholders)) + ) + ) ) """ return CompiledPredicate( whereClause: condition, - parameters: toBindableStrings(handleValues), + parameters: toBindableStrings(handleValues) + toBindableStrings(handleValues), requiresChatJoin: false ) case .dateRange(let dateRange): @@ -469,6 +508,12 @@ public final class Database { return CompiledPredicate(whereClause: nil, parameters: [], requiresChatJoin: false) case .none: return CompiledPredicate(whereClause: "1 = 0", parameters: [], requiresChatJoin: false) + case .id(let chatID): + return CompiledPredicate( + whereClause: "c.guid = ?", + parameters: [chatID.rawValue], + requiresChatJoin: false + ) case .participantHandles(let handles, let match): if handles.isEmpty { let whereClause = match == .all ? nil : "1 = 0" diff --git a/Sources/iMessage/FetchRequest.swift b/Sources/iMessage/FetchRequest.swift index c87289d..8e46a84 100644 --- a/Sources/iMessage/FetchRequest.swift +++ b/Sources/iMessage/FetchRequest.swift @@ -24,7 +24,9 @@ public indirect enum MessagePredicate: Sendable, Hashable { case none /// Match messages that belong to the specified chat. case chatID(Chat.ID) - /// Match messages sent by any of the provided handles. + /// Match messages sent by any of the provided handles, + /// as well as messages the current user sent + /// in chats that include any of the provided handles. case participantHandles(Set) /// Match messages in the half-open date range. case dateRange(Range) @@ -50,6 +52,8 @@ public indirect enum ChatPredicate: Sendable, Hashable { case all /// Match no chats. case none + /// Match the chat with the specified identifier. + case id(Chat.ID) /// Match chats by participant handles using the selected mode. case participantHandles(Set, match: ParticipantMatch) /// Match chats that contain message activity in the half-open date range. diff --git a/Sources/iMessage/Message.swift b/Sources/iMessage/Message.swift index f828cbe..d2d1b29 100644 --- a/Sources/iMessage/Message.swift +++ b/Sources/iMessage/Message.swift @@ -20,6 +20,13 @@ public struct Message: Identifiable, Hashable, Codable, Sendable { /// The sender handle for inbound messages. public let sender: Account.Handle? + /// The identifier of the chat this message belongs to, if any. + /// + /// Messages sent by the current user from another device + /// carry no sender handle, so this is the only way to tell + /// which conversation they are part of. + public let chatID: Chat.ID? + /// A Boolean value that indicates whether this message has been read. public var isRead: Bool { readAt != nil diff --git a/Tests/iMessageTests/DatabaseTests.swift b/Tests/iMessageTests/DatabaseTests.swift index b346b6b..89dae54 100644 --- a/Tests/iMessageTests/DatabaseTests.swift +++ b/Tests/iMessageTests/DatabaseTests.swift @@ -91,9 +91,11 @@ struct DatabaseTests { "person@example.com", ] let participantMessages = try db.fetchMessages(with: participants) - #expect(participantMessages.count == 3) + #expect(participantMessages.count == 4) #expect(participantMessages.contains { $0.sender?.rawValue == "+1234567890" }) #expect(participantMessages.contains { $0.sender?.rawValue == "person@example.com" }) + // The current user's reply in the chat with these participants is included too. + #expect(participantMessages.contains { $0.id.rawValue == "msg-guid-2" && $0.isFromMe }) // Test with date range let yesterday = Date().addingTimeInterval(-86400) @@ -109,15 +111,24 @@ struct DatabaseTests { func testFetchMessagesByParticipant() async throws { let handle: Account.Handle = "+1234567890" - // Test basic fetch + // Test basic fetch: the handle's own messages, + // plus the current user's message in the chat with that handle let messages = try db.fetchMessages(with: [handle], limit: 10) - #expect(messages.count == 2) - #expect(messages[0].id.rawValue == "msg-guid-1") - #expect(messages[0].text == "Hello!") - #expect(messages[0].isFromMe == false) - #expect(messages[0].isRead == false) - #expect(messages[0].readAt == nil) - #expect(messages[0].sender?.rawValue == "+1234567890") + #expect(messages.count == 3) + #expect(messages.map(\.id.rawValue) == ["msg-guid-2", "msg-guid-1", "msg-guid-4"]) + + #expect(messages[0].isFromMe == true) + #expect(messages[0].sender == nil) + #expect(messages[0].chatID?.rawValue == "chat-guid-1") + + #expect(messages[1].text == "Hello!") + #expect(messages[1].isFromMe == false) + #expect(messages[1].isRead == false) + #expect(messages[1].readAt == nil) + #expect(messages[1].sender?.rawValue == "+1234567890") + #expect(messages[1].chatID?.rawValue == "chat-guid-1") + + #expect(messages[2].chatID?.rawValue == "chat-guid-2") // Test with date range let yesterday = Date().addingTimeInterval(-86400) @@ -168,7 +179,113 @@ struct DatabaseTests { let messages = try db.fetch(request) let messageIDs = Set(messages.map(\.id.rawValue)) - #expect(messageIDs == ["msg-guid-3", "msg-guid-4", "msg-guid-5"]) + // msg-guid-2 is the current user's message in the chat with person@example.com. + #expect(messageIDs == ["msg-guid-2", "msg-guid-3", "msg-guid-4", "msg-guid-5"]) + } + + @Test + func testMessagesReportTheirChat() async throws { + // Plain fetch: the chat is looked up per message. + let allMessages = try db.fetch( + Database.MessageFetchRequest(sortDescriptors: [.id(.ascending)], limit: 10) + ) + #expect( + allMessages.map(\.chatID?.rawValue) == [ + "chat-guid-1", "chat-guid-1", "chat-guid-1", "chat-guid-2", "chat-guid-2", + ] + ) + + // Chat-scoped fetch: the joined chat is reported. + let chatMessages = try db.fetch( + Database.MessageFetchRequest(predicate: .chatID("chat-guid-2"), limit: 10) + ) + #expect(chatMessages.count == 2) + #expect(chatMessages.allSatisfy { $0.chatID?.rawValue == "chat-guid-2" }) + + // A message that is not linked to any chat has no chat identifier. + try db.execute( + """ + INSERT INTO message (ROWID, guid, text, attributedBody, handle_id, date, is_from_me, date_read, service) + VALUES (6, 'msg-guid-6', 'Orphan', NULL, 1, \(Date().nanosecondsSinceReferenceDate ?? 0), 0, 0, 'iMessage'); + """ + ) + let orphan = try db.fetch( + Database.MessageFetchRequest(predicate: .participantHandles(["+1234567890"]), limit: 10) + ).first { $0.id.rawValue == "msg-guid-6" } + #expect(orphan != nil) + #expect(orphan?.chatID == nil) + } + + @Test + func testParticipantPredicateIncludesOwnMessagesInChat() async throws { + // A message sent by the current user from another device: + // no handle at all (chat.db stores 0), only a chat membership. + try db.execute( + """ + INSERT INTO message (ROWID, guid, text, attributedBody, handle_id, date, is_from_me, date_read, service) + VALUES (6, 'msg-guid-6', 'Sent from my phone', NULL, 0, \(Date().nanosecondsSinceReferenceDate ?? 0), 1, 0, 'iMessage'); + INSERT INTO chat_message_join (chat_id, message_id) VALUES (2, 6); + """ + ) + + func ids(with handles: Set) throws -> Set { + let request = Database.MessageFetchRequest( + predicate: .participantHandles(handles), + limit: 10 + ) + return Set(try db.fetch(request).map(\.id.rawValue)) + } + + // third@example.com only takes part in chat 2: + // their own message, and mine in that chat — not my message in chat 1. + #expect(try ids(with: ["third@example.com"]) == ["msg-guid-5", "msg-guid-6"]) + + // +1234567890 takes part in both chats: their messages and mine in either chat, + // but not what person@example.com wrote in chat 1. + #expect( + try ids(with: ["+1234567890"]) == ["msg-guid-1", "msg-guid-2", "msg-guid-4", "msg-guid-6"] + ) + + // person@example.com only takes part in chat 1. + #expect(try ids(with: ["person@example.com"]) == ["msg-guid-2", "msg-guid-3"]) + + // Combined with a chat predicate, the join path reports the right chat. + let inChat2 = try db.fetch( + Database.MessageFetchRequest( + predicate: .and([ + .chatID("chat-guid-2"), + .participantHandles(["+1234567890"]), + ]), + limit: 10 + ) + ) + #expect(Set(inChat2.map(\.id.rawValue)) == ["msg-guid-4", "msg-guid-6"]) + #expect(inChat2.allSatisfy { $0.chatID?.rawValue == "chat-guid-2" }) + } + + @Test + func testFetchChatByID() async throws { + let chats = try db.fetch( + Database.ChatFetchRequest(predicate: .id("chat-guid-2"), limit: 10) + ) + #expect(chats.count == 1) + #expect(chats[0].id.rawValue == "chat-guid-2") + #expect(chats[0].displayName == "Another Group") + #expect(Set(chats[0].participants.map(\.rawValue)) == ["+1234567890", "third@example.com"]) + + let missing = try db.fetch( + Database.ChatFetchRequest(predicate: .id("no-such-chat"), limit: 10) + ) + #expect(missing.isEmpty) + + let several = try db.fetch( + Database.ChatFetchRequest( + predicate: .or([.id("chat-guid-1"), .id("chat-guid-2")]), + sortDescriptors: [.id(.ascending)], + limit: 10 + ) + ) + #expect(several.map(\.id.rawValue) == ["chat-guid-1", "chat-guid-2"]) } @Test From 2f6926254b9b2465433bcfd5e369d2f2e2b92921 Mon Sep 17 00:00:00 2001 From: mattt Date: Wed, 2 Sep 2026 10:24:49 -0700 Subject: [PATCH 2/2] Reflow comments with semantic line breaks --- Sources/iMessage/Database.swift | 5 ++--- Sources/iMessage/FetchRequest.swift | 3 +-- Sources/iMessage/Message.swift | 5 ++--- Tests/iMessageTests/DatabaseTests.swift | 3 ++- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/Sources/iMessage/Database.swift b/Sources/iMessage/Database.swift index 787ad5c..3346f57 100644 --- a/Sources/iMessage/Database.swift +++ b/Sources/iMessage/Database.swift @@ -414,9 +414,8 @@ public final class Database { let placeholders = placeholders(handles.count) // Messages from the current user only carry a handle // when they were sent from this Mac; - // those sent from another device (and synced over iCloud) - // have no handle and can only be matched - // through the chat they belong to. + // those sent from another device (and synced over iCloud) have no handle + // and can only be matched through the chat they belong to. let condition = """ ( m.ROWID IN ( diff --git a/Sources/iMessage/FetchRequest.swift b/Sources/iMessage/FetchRequest.swift index 8e46a84..6a77b0c 100644 --- a/Sources/iMessage/FetchRequest.swift +++ b/Sources/iMessage/FetchRequest.swift @@ -25,8 +25,7 @@ public indirect enum MessagePredicate: Sendable, Hashable { /// Match messages that belong to the specified chat. case chatID(Chat.ID) /// Match messages sent by any of the provided handles, - /// as well as messages the current user sent - /// in chats that include any of the provided handles. + /// as well as messages the current user sent in chats that include any of the provided handles. case participantHandles(Set) /// Match messages in the half-open date range. case dateRange(Range) diff --git a/Sources/iMessage/Message.swift b/Sources/iMessage/Message.swift index d2d1b29..a310039 100644 --- a/Sources/iMessage/Message.swift +++ b/Sources/iMessage/Message.swift @@ -22,9 +22,8 @@ public struct Message: Identifiable, Hashable, Codable, Sendable { /// The identifier of the chat this message belongs to, if any. /// - /// Messages sent by the current user from another device - /// carry no sender handle, so this is the only way to tell - /// which conversation they are part of. + /// Messages sent by the current user from another device carry no sender handle, + /// so this is the only way to tell which conversation they are part of. public let chatID: Chat.ID? /// A Boolean value that indicates whether this message has been read. diff --git a/Tests/iMessageTests/DatabaseTests.swift b/Tests/iMessageTests/DatabaseTests.swift index 89dae54..821249c 100644 --- a/Tests/iMessageTests/DatabaseTests.swift +++ b/Tests/iMessageTests/DatabaseTests.swift @@ -249,7 +249,8 @@ struct DatabaseTests { // person@example.com only takes part in chat 1. #expect(try ids(with: ["person@example.com"]) == ["msg-guid-2", "msg-guid-3"]) - // Combined with a chat predicate, the join path reports the right chat. + // Combined with a chat predicate, + // the join path reports the right chat. let inChat2 = try db.fetch( Database.MessageFetchRequest( predicate: .and([