From e8276e05b31cc1f217915a48cc4b60969262c994 Mon Sep 17 00:00:00 2001 From: patp Date: Tue, 1 Sep 2026 14:00:21 -0400 Subject: [PATCH] messages_fetch: report the conversation each message belongs to Messages the user sends from another device are synced to chat.db without a sender handle, so until now most of the user's own messages came back with sender "me" and nothing to tie them to a conversation; a client could not thread them or tell who they were sent to. Each message now carries an "isPartOf" Conversation with the chat identifier, its display name (group chats) and its participants. Chats are looked up once per call and shared by their messages. Relies on Message.chatID and ChatPredicate.id from Madrid 0.5.0 (mattt/Madrid#18), which the project already depends on. --- App/Services/Messages.swift | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/App/Services/Messages.swift b/App/Services/Messages.swift index ab41daf1..01260d5d 100644 --- a/App/Services/Messages.swift +++ b/App/Services/Messages.swift @@ -101,7 +101,8 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { var tools: [Tool] { Tool( name: "messages_fetch", - description: "Fetch messages from the Messages app", + description: + "Fetch messages from the Messages app. Each message names the conversation it belongs to (isPartOf), with its participants.", inputSchema: .object( properties: [ "participants": .array( @@ -212,6 +213,9 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { ? try self.attachments(ofMessages: fetched.map { $0.id.rawValue }, in: access) : [:] + // Each chat is looked up once and shared by all of its messages. + var conversations: [Chat.ID: [String: Value]] = [:] + for message in fetched { guard messages.count < (limit ?? defaultLimit) else { break } let attachments = attachmentsByMessage[message.id.rawValue] ?? [] @@ -273,6 +277,13 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { object["attachment"] = .array(attachments.map { .object($0) }) } + if let chatID = message.chatID { + if conversations[chatID] == nil { + conversations[chatID] = try self.conversation(for: chatID, in: db) + } + object["isPartOf"] = conversations[chatID].map { .object($0) } + } + messages.append(object) } @@ -483,6 +494,30 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { } } + /// Describes the chat a message belongs to: + /// its identifier, its display name (group chats) and its participants. + private func conversation( + for chatID: Chat.ID, + in db: iMessage.Database + ) throws -> [String: Value] { + var conversation: [String: Value] = [ + "@type": "Conversation", + "@id": .string(chatID.rawValue), + ] + + let request = FetchRequest(predicate: .id(chatID), limit: 1) + if let chat = try db.fetch(request).first { + if let name = chat.displayName, !name.isEmpty { + conversation["name"] = .string(name) + } + conversation["participant"] = .array( + chat.participants.map { .object(["@id": .string($0.rawValue)]) } + ) + } + + return conversation + } + private var canAccessDatabaseAtDefaultPath: Bool { return FileManager.default.isReadableFile(atPath: messagesDatabasePath) }