From 39b2c207e551acdccc3630c4c143c2a62bacd0b3 Mon Sep 17 00:00:00 2001 From: Seb Pretzer <24555985+sebpretzer@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:25:09 -0500 Subject: [PATCH] Add chats_fetch to find a conversation's chatId messages_fetch accepts a chatId, but the only way to learn one was the isPartOf of messages fetched through the sender-based participants filter, which mixes in unrelated chats. chats_fetch finds conversations by participants, name or activity window and returns the same Conversation object as isPartOf, plus dateModified. Each participant is resolved to its own set of handles, since one handle string can match several (with and without a country code). match defaults to all: the chat includes each participant and may include others. any needs at least one of them, and exact also requires that no one else is in the chat. A participant that resolves to no handle returns no chats under all and exact. exact and the name query are checked on the fetched chats, so chats are read in pages until the limit is filled or none are left. --- App/Services/Messages.swift | 158 +++++++++++++++++++++++++++++++++++- 1 file changed, 157 insertions(+), 1 deletion(-) diff --git a/App/Services/Messages.swift b/App/Services/Messages.swift index b31d4d0f..9afc372c 100644 --- a/App/Services/Messages.swift +++ b/App/Services/Messages.swift @@ -111,7 +111,7 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { ), "chatId": .string( description: - "Conversation @id, as reported in a message's isPartOf. Fetches only messages in that conversation" + "Conversation @id, as found with chats_fetch or reported in a message's isPartOf. Fetches only messages in that conversation" ), "start": .string( description: @@ -287,6 +287,162 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { "hasPart": Value.array(messages.map({ .object($0) })), ] } + + Tool( + name: "chats_fetch", + description: + "Find conversations in the Messages app by participants, name or activity, most recent first. Each result's @id is the chatId that messages_fetch accepts. dateModified is the latest message, within start/end when given.", + inputSchema: .object( + properties: [ + "participants": .array( + description: + "Participant handles (phone or email) of the other people in the conversation, not including yourself. Phone numbers should use E.164 format", + items: .string() + ), + "match": .string( + description: + "How participants must line up with a conversation: all = it includes every one of them and may include others, exact = it includes exactly those people, any = it includes at least one of them", + default: .string(ChatParticipantMatch.all.rawValue), + enum: ChatParticipantMatch.allCases.map { .string($0.rawValue) } + ), + "query": .string( + description: + "Search term to filter conversations by name. Unnamed conversations never match" + ), + "start": .string( + description: + "Start of the date range (inclusive) in which a conversation has messages. If timezone is omitted, local time is assumed. Date-only uses local midnight.", + format: .dateTime + ), + "end": .string( + description: + "End of the date range (exclusive) in which a conversation has messages. If timezone is omitted, local time is assumed. A date-only value includes that whole day.", + format: .dateTime + ), + "limit": .integer( + description: "Maximum conversations to return", + default: .int(defaultLimit) + ), + ], + additionalProperties: false + ), + annotations: .init( + title: "Fetch Chats", + readOnlyHint: true, + openWorldHint: false + ) + ) { arguments in + log.debug("Starting chat fetch with arguments: \(arguments)") + try await self.activate(offeringUpgrade: false) + + let participants = + arguments["participants"]?.arrayValue?.compactMap({ + $0.stringValue + }) ?? [] + + var match = ChatParticipantMatch.all + if let value = arguments["match"], !value.isNull { + guard let string = value.stringValue else { + throw ArgumentError.invalid("match has the wrong type") + } + guard let parsed = ChatParticipantMatch(rawValue: string) else { + throw ArgumentError.invalid("match must be one of all, exact or any") + } + match = parsed + } + + var searchTerm: String? + if let value = arguments["query"], !value.isNull { + guard let string = value.stringValue else { + throw ArgumentError.invalid("query has the wrong type") + } + searchTerm = string + } + + let dateRange = try Self.dateRange(from: arguments) + let limit = arguments["limit"]?.intValue ?? defaultLimit + + let access = try self.openDatabase() + defer { access.stop() } + let db = access.database + + // Each participant is resolved on its own: one handle string can match several + // handles (with and without a country code), all of them the same person. + log.debug("Fetching handles for participants: \(participants)") + var people = try participants.map { Set(try db.fetchParticipant(matching: [$0])) } + if match == .any { + people.removeAll { $0.isEmpty } + } else if people.contains(where: { $0.isEmpty }) { + return Value.array([]) + } + if !participants.isEmpty && people.isEmpty { + return Value.array([]) + } + + var predicates: [ChatPredicate] = [] + switch match { + case .any: + if !people.isEmpty { + predicates.append(.participantHandles(people.reduce(into: []) { $0.formUnion($1) }, match: .any)) + } + case .all, .exact: + predicates += people.map { .participantHandles($0, match: .any) } + } + if let dateRange { + predicates.append(.dateRange(dateRange)) + } + + let requiresExactParticipants = match == .exact && !people.isEmpty + func isIncluded(_ chat: Chat) -> Bool { + if requiresExactParticipants, + !chat.participants.allSatisfy({ handle in people.contains { $0.contains(handle) } }) + { + return false + } + if let searchTerm { + guard let name = chat.displayName, name.localizedCaseInsensitiveContains(searchTerm) + else { return false } + } + return true + } + + // exact and query are checked here rather than in SQL, so pages are read + // until enough chats pass or none are left. + let filtersFetchedChats = requiresExactParticipants || searchTerm != nil + var chats: [Chat] = [] + var offset = 0 + while chats.count < limit { + let pageSize = filtersFetchedChats ? 1024 : limit - chats.count + let page = try db.fetch( + FetchRequest(predicate: .and(predicates), limit: pageSize, offset: offset) + ) + for chat in page where chats.count < limit && isIncluded(chat) { + chats.append(chat) + } + guard page.count == pageSize else { break } + offset += pageSize + } + + log.debug("Successfully fetched \(chats.count) chats") + return Value.array( + chats.map { chat in + var object = Self.conversation(for: chat) + if let lastMessageDate = chat.lastMessageDate { + object["dateModified"] = .string(lastMessageDate.formatted(.iso8601)) + } + return .object(object) + } + ) + } + } + + /// How `chats_fetch` lines up the requested participants with a conversation's own. + /// `all` and `any` are matched in SQL; `exact` is `all` followed by a check + /// that no one else is in the chat. + private enum ChatParticipantMatch: String, CaseIterable { + case all + case exact + case any } /// Reads the optional `start` and `end` arguments into a date range.