From 327829a819fca6299a19d991969b367e4e47c954 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 5 May 2026 02:00:56 +0100 Subject: [PATCH] feat: expose chat routing hints --- CHANGELOG.md | 1 + README.md | 7 +- Sources/IMsgCore/MessageStore+Chats.swift | 111 +++++++++++++++++ .../IMsgCore/MessageStore+SentMessages.swift | 14 ++- Sources/IMsgCore/MessageStore.swift | 115 +++++------------- Sources/IMsgCore/Models.swift | 34 +++++- Sources/imsg/Commands/GroupCommand.swift | 9 ++ Sources/imsg/OutputModels.swift | 18 +++ .../MessageStoreListChatsTests.swift | 6 + .../MessageStoreTestHelpers.swift | 15 ++- Tests/IMsgCoreTests/MessageStoreTests.swift | 3 + .../ChatHistorySendCommandTests.swift | 6 + Tests/imsgTests/CommandTestDatabase.swift | 25 +++- Tests/imsgTests/GroupCommandTests.swift | 6 + docs/groups.md | 12 ++ docs/rpc.md | 7 ++ 16 files changed, 289 insertions(+), 100 deletions(-) create mode 100644 Sources/IMsgCore/MessageStore+Chats.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index a5abdcf..74df297 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - feat: add completion script and LLM reference generation (#21, thanks @bdmorin) - feat: optionally expose model-compatible converted attachment files for CAF/GIF metadata (#73, thanks @mfzeidan) - feat: add `imsg group` chat metadata lookup and group fields to `chats --json` (#88, thanks @mryanb) +- feat: expose read-only chat account routing hints for multi-number diagnostics (#18) - fix: return best-effort message `id` and `guid` from RPC `send` responses (#85) - fix: keep watch streams alive with a periodic poll fallback when filesystem events are missed (#78) - fix: detect Tahoe group-send ghost rows and fail instead of reporting false success (#90, thanks @loop) diff --git a/README.md b/README.md index eb1b568..eaea597 100644 --- a/README.md +++ b/README.md @@ -121,11 +121,14 @@ the calling terminal or parent app; Automation permission is only needed for send/read/typing/reaction commands that control Messages.app. ## JSON output -`imsg chats --json` emits one JSON object per chat with fields: `id`, `name`, `identifier`, `service`, `last_message_at`, `guid`, `display_name`, `contact_name`, `is_group`, `participants`. +`imsg chats --json` emits one JSON object per chat with fields: `id`, `name`, `identifier`, `service`, `last_message_at`, `guid`, `display_name`, `contact_name`, `is_group`, `participants`, `account_id`, `account_login`, `last_addressed_handle`. `imsg history --json` and `imsg watch --json` emit one JSON object per message with fields: `id`, `chat_id`, `chat_identifier`, `chat_guid`, `chat_name`, `participants`, `is_group`, `guid`, `reply_to_guid`, `destination_caller_id`, `sender`, `sender_name`, `is_from_me`, `text`, `created_at`, `attachments` (array of metadata with `filename`, `transfer_name`, `uti`, `mime_type`, `total_bytes`, `is_sticker`, `original_path`, `converted_path`, `converted_mime_type`, `missing`), `reactions`. When `watch --reactions --json` sees a tapback event, the message object also includes `is_reaction`, `reaction_type`, `reaction_emoji`, `is_reaction_add`, and `reacted_to_guid`. -Note: `reply_to_guid`, `destination_caller_id`, and `reactions` are read-only metadata. +Note: `reply_to_guid`, `destination_caller_id`, `account_id`, `account_login`, +`last_addressed_handle`, and `reactions` are read-only metadata. They help +inspect Messages routing state, but `imsg send` cannot force a specific +outgoing Apple ID phone number or inline reply target through AppleScript. ## Permissions troubleshooting If you see “unable to open database file” or empty output: diff --git a/Sources/IMsgCore/MessageStore+Chats.swift b/Sources/IMsgCore/MessageStore+Chats.swift new file mode 100644 index 0000000..52159d6 --- /dev/null +++ b/Sources/IMsgCore/MessageStore+Chats.swift @@ -0,0 +1,111 @@ +import Foundation +import SQLite + +extension MessageStore { + public func listChats(limit: Int) throws -> [Chat] { + let accountIDColumn = hasChatAccountIDColumn ? "IFNULL(c.account_id, '')" : "''" + let accountLoginColumn = hasChatAccountLoginColumn ? "IFNULL(c.account_login, '')" : "''" + let lastAddressedHandleColumn = + hasChatLastAddressedHandleColumn ? "IFNULL(c.last_addressed_handle, '')" : "''" + let sql: String + if hasChatMessageJoinMessageDateColumn { + sql = """ + SELECT c.ROWID, IFNULL(c.display_name, c.chat_identifier) AS name, c.chat_identifier, + c.service_name, MAX(cmj.message_date) AS last_date, + \(accountIDColumn) AS account_id, + \(accountLoginColumn) AS account_login, + \(lastAddressedHandleColumn) AS last_addressed_handle + FROM chat c + JOIN chat_message_join cmj ON c.ROWID = cmj.chat_id + GROUP BY c.ROWID + ORDER BY last_date DESC + LIMIT ? + """ + } else { + sql = """ + SELECT c.ROWID, IFNULL(c.display_name, c.chat_identifier) AS name, c.chat_identifier, + c.service_name, MAX(m.date) AS last_date, + \(accountIDColumn) AS account_id, + \(accountLoginColumn) AS account_login, + \(lastAddressedHandleColumn) AS last_addressed_handle + FROM chat c + JOIN chat_message_join cmj ON c.ROWID = cmj.chat_id + JOIN message m ON m.ROWID = cmj.message_id + GROUP BY c.ROWID + ORDER BY last_date DESC + LIMIT ? + """ + } + return try withConnection { db in + var chats: [Chat] = [] + for row in try db.prepare(sql, limit) { + chats.append( + Chat( + id: int64Value(row[0]) ?? 0, + identifier: stringValue(row[2]), + name: stringValue(row[1]), + service: stringValue(row[3]), + lastMessageAt: appleDate(from: int64Value(row[4])), + accountID: stringValue(row[5]).nilIfEmpty, + accountLogin: stringValue(row[6]).nilIfEmpty, + lastAddressedHandle: stringValue(row[7]).nilIfEmpty + )) + } + return chats + } + } + + public func chatInfo(chatID: Int64) throws -> ChatInfo? { + let accountIDColumn = hasChatAccountIDColumn ? "IFNULL(c.account_id, '')" : "''" + let accountLoginColumn = hasChatAccountLoginColumn ? "IFNULL(c.account_login, '')" : "''" + let lastAddressedHandleColumn = + hasChatLastAddressedHandleColumn ? "IFNULL(c.last_addressed_handle, '')" : "''" + let sql = """ + SELECT c.ROWID, IFNULL(c.chat_identifier, '') AS identifier, IFNULL(c.guid, '') AS guid, + IFNULL(c.display_name, c.chat_identifier) AS name, IFNULL(c.service_name, '') AS service, + \(accountIDColumn) AS account_id, + \(accountLoginColumn) AS account_login, + \(lastAddressedHandleColumn) AS last_addressed_handle + FROM chat c + WHERE c.ROWID = ? + LIMIT 1 + """ + return try withConnection { db in + for row in try db.prepare(sql, chatID) { + return ChatInfo( + id: int64Value(row[0]) ?? 0, + identifier: stringValue(row[1]), + guid: stringValue(row[2]), + name: stringValue(row[3]), + service: stringValue(row[4]), + accountID: stringValue(row[5]).nilIfEmpty, + accountLogin: stringValue(row[6]).nilIfEmpty, + lastAddressedHandle: stringValue(row[7]).nilIfEmpty + ) + } + return nil + } + } + + public func participants(chatID: Int64) throws -> [String] { + let sql = """ + SELECT h.id + FROM chat_handle_join chj + JOIN handle h ON h.ROWID = chj.handle_id + WHERE chj.chat_id = ? + ORDER BY h.id ASC + """ + return try withConnection { db in + var results: [String] = [] + var seen = Set() + for row in try db.prepare(sql, chatID) { + let handle = stringValue(row[0]) + if handle.isEmpty { continue } + if seen.insert(handle).inserted { + results.append(handle) + } + } + return results + } + } +} diff --git a/Sources/IMsgCore/MessageStore+SentMessages.swift b/Sources/IMsgCore/MessageStore+SentMessages.swift index 38c3349..9d53126 100644 --- a/Sources/IMsgCore/MessageStore+SentMessages.swift +++ b/Sources/IMsgCore/MessageStore+SentMessages.swift @@ -7,9 +7,16 @@ extension MessageStore { guard !candidates.isEmpty else { return nil } let placeholders = Array(repeating: "?", count: candidates.count).joined(separator: ",") + let accountIDColumn = hasChatAccountIDColumn ? "IFNULL(c.account_id, '')" : "''" + let accountLoginColumn = hasChatAccountLoginColumn ? "IFNULL(c.account_login, '')" : "''" + let lastAddressedHandleColumn = + hasChatLastAddressedHandleColumn ? "IFNULL(c.last_addressed_handle, '')" : "''" let sql = """ SELECT c.ROWID, IFNULL(c.chat_identifier, '') AS identifier, IFNULL(c.guid, '') AS guid, - IFNULL(c.display_name, c.chat_identifier) AS name, IFNULL(c.service_name, '') AS service + IFNULL(c.display_name, c.chat_identifier) AS name, IFNULL(c.service_name, '') AS service, + \(accountIDColumn) AS account_id, + \(accountLoginColumn) AS account_login, + \(lastAddressedHandleColumn) AS last_addressed_handle FROM chat c WHERE c.chat_identifier IN (\(placeholders)) OR c.guid IN (\(placeholders)) @@ -23,7 +30,10 @@ extension MessageStore { identifier: stringValue(row[1]), guid: stringValue(row[2]), name: stringValue(row[3]), - service: stringValue(row[4]) + service: stringValue(row[4]), + accountID: stringValue(row[5]).nilIfEmpty, + accountLogin: stringValue(row[6]).nilIfEmpty, + lastAddressedHandle: stringValue(row[7]).nilIfEmpty ) } } diff --git a/Sources/IMsgCore/MessageStore.swift b/Sources/IMsgCore/MessageStore.swift index 38c25e9..8ef9bd1 100644 --- a/Sources/IMsgCore/MessageStore.swift +++ b/Sources/IMsgCore/MessageStore.swift @@ -22,6 +22,9 @@ public final class MessageStore: @unchecked Sendable { let hasAttachmentUserInfo: Bool let hasBalloonBundleIDColumn: Bool let hasChatMessageJoinMessageDateColumn: Bool + let hasChatAccountIDColumn: Bool + let hasChatAccountLoginColumn: Bool + let hasChatLastAddressedHandleColumn: Bool private struct URLBalloonDedupeEntry: Sendable { let rowID: Int64 @@ -52,6 +55,7 @@ public final class MessageStore: @unchecked Sendable { connection: self.connection, table: "chat_message_join" ) + let chatColumns = MessageStore.tableColumns(connection: self.connection, table: "chat") self.hasAttributedBody = messageColumns.contains("attributedbody") self.hasReactionColumns = MessageStore.reactionColumnsPresent(in: messageColumns) self.hasThreadOriginatorGUIDColumn = messageColumns.contains("thread_originator_guid") @@ -60,6 +64,9 @@ public final class MessageStore: @unchecked Sendable { self.hasAttachmentUserInfo = attachmentColumns.contains("user_info") self.hasBalloonBundleIDColumn = messageColumns.contains("balloon_bundle_id") self.hasChatMessageJoinMessageDateColumn = chatMessageJoinColumns.contains("message_date") + self.hasChatAccountIDColumn = chatColumns.contains("account_id") + self.hasChatAccountLoginColumn = chatColumns.contains("account_login") + self.hasChatLastAddressedHandleColumn = chatColumns.contains("last_addressed_handle") } catch { throw MessageStore.enhance(error: error, path: normalized) } @@ -75,7 +82,10 @@ public final class MessageStore: @unchecked Sendable { hasAudioMessageColumn: Bool? = nil, hasAttachmentUserInfo: Bool? = nil, hasBalloonBundleIDColumn: Bool? = nil, - hasChatMessageJoinMessageDateColumn: Bool? = nil + hasChatMessageJoinMessageDateColumn: Bool? = nil, + hasChatAccountIDColumn: Bool? = nil, + hasChatAccountLoginColumn: Bool? = nil, + hasChatLastAddressedHandleColumn: Bool? = nil ) throws { self.path = path self.queue = DispatchQueue(label: "imsg.db.test", qos: .userInitiated) @@ -88,6 +98,7 @@ public final class MessageStore: @unchecked Sendable { connection: connection, table: "chat_message_join" ) + let chatColumns = MessageStore.tableColumns(connection: connection, table: "chat") if let hasAttributedBody { self.hasAttributedBody = hasAttributedBody } else { @@ -128,94 +139,20 @@ public final class MessageStore: @unchecked Sendable { } else { self.hasChatMessageJoinMessageDateColumn = chatMessageJoinColumns.contains("message_date") } - } - - public func listChats(limit: Int) throws -> [Chat] { - let sql: String - if hasChatMessageJoinMessageDateColumn { - sql = """ - SELECT c.ROWID, IFNULL(c.display_name, c.chat_identifier) AS name, c.chat_identifier, - c.service_name, MAX(cmj.message_date) AS last_date - FROM chat c - JOIN chat_message_join cmj ON c.ROWID = cmj.chat_id - GROUP BY c.ROWID - ORDER BY last_date DESC - LIMIT ? - """ + if let hasChatAccountIDColumn { + self.hasChatAccountIDColumn = hasChatAccountIDColumn } else { - sql = """ - SELECT c.ROWID, IFNULL(c.display_name, c.chat_identifier) AS name, c.chat_identifier, - c.service_name, MAX(m.date) AS last_date - FROM chat c - JOIN chat_message_join cmj ON c.ROWID = cmj.chat_id - JOIN message m ON m.ROWID = cmj.message_id - GROUP BY c.ROWID - ORDER BY last_date DESC - LIMIT ? - """ + self.hasChatAccountIDColumn = chatColumns.contains("account_id") } - return try withConnection { db in - var chats: [Chat] = [] - for row in try db.prepare(sql, limit) { - let id = int64Value(row[0]) ?? 0 - let name = stringValue(row[1]) - let identifier = stringValue(row[2]) - let service = stringValue(row[3]) - let lastDate = appleDate(from: int64Value(row[4])) - chats.append( - Chat( - id: id, identifier: identifier, name: name, service: service, lastMessageAt: lastDate)) - } - return chats + if let hasChatAccountLoginColumn { + self.hasChatAccountLoginColumn = hasChatAccountLoginColumn + } else { + self.hasChatAccountLoginColumn = chatColumns.contains("account_login") } - } - - public func chatInfo(chatID: Int64) throws -> ChatInfo? { - let sql = """ - SELECT c.ROWID, IFNULL(c.chat_identifier, '') AS identifier, IFNULL(c.guid, '') AS guid, - IFNULL(c.display_name, c.chat_identifier) AS name, IFNULL(c.service_name, '') AS service - FROM chat c - WHERE c.ROWID = ? - LIMIT 1 - """ - return try withConnection { db in - for row in try db.prepare(sql, chatID) { - let id = int64Value(row[0]) ?? 0 - let identifier = stringValue(row[1]) - let guid = stringValue(row[2]) - let name = stringValue(row[3]) - let service = stringValue(row[4]) - return ChatInfo( - id: id, - identifier: identifier, - guid: guid, - name: name, - service: service - ) - } - return nil - } - } - - public func participants(chatID: Int64) throws -> [String] { - let sql = """ - SELECT h.id - FROM chat_handle_join chj - JOIN handle h ON h.ROWID = chj.handle_id - WHERE chj.chat_id = ? - ORDER BY h.id ASC - """ - return try withConnection { db in - var results: [String] = [] - var seen = Set() - for row in try db.prepare(sql, chatID) { - let handle = stringValue(row[0]) - if handle.isEmpty { continue } - if seen.insert(handle).inserted { - results.append(handle) - } - } - return results + if let hasChatLastAddressedHandleColumn { + self.hasChatLastAddressedHandleColumn = hasChatLastAddressedHandleColumn + } else { + self.hasChatLastAddressedHandleColumn = chatColumns.contains("last_addressed_handle") } } @@ -261,6 +198,12 @@ public final class MessageStore: @unchecked Sendable { } } +extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self + } +} + extension MessageStore { public func attachments( for messageID: Int64, diff --git a/Sources/IMsgCore/Models.swift b/Sources/IMsgCore/Models.swift index 9655e80..902acd0 100644 --- a/Sources/IMsgCore/Models.swift +++ b/Sources/IMsgCore/Models.swift @@ -190,13 +190,28 @@ public struct Chat: Sendable, Equatable { public let name: String public let service: String public let lastMessageAt: Date + public let accountID: String? + public let accountLogin: String? + public let lastAddressedHandle: String? - public init(id: Int64, identifier: String, name: String, service: String, lastMessageAt: Date) { + public init( + id: Int64, + identifier: String, + name: String, + service: String, + lastMessageAt: Date, + accountID: String? = nil, + accountLogin: String? = nil, + lastAddressedHandle: String? = nil + ) { self.id = id self.identifier = identifier self.name = name self.service = service self.lastMessageAt = lastMessageAt + self.accountID = accountID + self.accountLogin = accountLogin + self.lastAddressedHandle = lastAddressedHandle } } @@ -206,13 +221,28 @@ public struct ChatInfo: Sendable, Equatable { public let guid: String public let name: String public let service: String + public let accountID: String? + public let accountLogin: String? + public let lastAddressedHandle: String? - public init(id: Int64, identifier: String, guid: String, name: String, service: String) { + public init( + id: Int64, + identifier: String, + guid: String, + name: String, + service: String, + accountID: String? = nil, + accountLogin: String? = nil, + lastAddressedHandle: String? = nil + ) { self.id = id self.identifier = identifier self.guid = guid self.name = name self.service = service + self.accountID = accountID + self.accountLogin = accountLogin + self.lastAddressedHandle = lastAddressedHandle } } diff --git a/Sources/imsg/Commands/GroupCommand.swift b/Sources/imsg/Commands/GroupCommand.swift index 646ca54..f18e5b6 100644 --- a/Sources/imsg/Commands/GroupCommand.swift +++ b/Sources/imsg/Commands/GroupCommand.swift @@ -40,6 +40,15 @@ enum GroupCommand { StdoutWriter.writeLine("guid: \(info.guid)") StdoutWriter.writeLine("name: \(info.name)") StdoutWriter.writeLine("service: \(info.service)") + if let accountID = info.accountID { + StdoutWriter.writeLine("account_id: \(accountID)") + } + if let accountLogin = info.accountLogin { + StdoutWriter.writeLine("account_login: \(accountLogin)") + } + if let lastAddressedHandle = info.lastAddressedHandle { + StdoutWriter.writeLine("last_addressed_handle: \(lastAddressedHandle)") + } let isGroup = isGroupHandle(identifier: info.identifier, guid: info.guid) StdoutWriter.writeLine("is_group: \(isGroup)") if participants.isEmpty { diff --git a/Sources/imsg/OutputModels.swift b/Sources/imsg/OutputModels.swift index 3cb7227..4e49aae 100644 --- a/Sources/imsg/OutputModels.swift +++ b/Sources/imsg/OutputModels.swift @@ -12,6 +12,9 @@ struct ChatPayload: Codable { let contactName: String? let isGroup: Bool let participants: [String]? + let accountID: String? + let accountLogin: String? + let lastAddressedHandle: String? init( chat: Chat, chatInfo: ChatInfo? = nil, participants: [String]? = nil, contactName: String? = nil @@ -28,6 +31,9 @@ struct ChatPayload: Codable { self.contactName = contactName self.isGroup = isGroupHandle(identifier: identifier, guid: guid) self.participants = participants + self.accountID = chatInfo?.accountID ?? chat.accountID + self.accountLogin = chatInfo?.accountLogin ?? chat.accountLogin + self.lastAddressedHandle = chatInfo?.lastAddressedHandle ?? chat.lastAddressedHandle } enum CodingKeys: String, CodingKey { @@ -41,6 +47,9 @@ struct ChatPayload: Codable { case contactName = "contact_name" case isGroup = "is_group" case participants + case accountID = "account_id" + case accountLogin = "account_login" + case lastAddressedHandle = "last_addressed_handle" } } @@ -180,6 +189,9 @@ struct GroupPayload: Codable { let service: String let isGroup: Bool let participants: [String] + let accountID: String? + let accountLogin: String? + let lastAddressedHandle: String? init(chatInfo: ChatInfo, participants: [String]) { self.id = chatInfo.id @@ -189,6 +201,9 @@ struct GroupPayload: Codable { self.service = chatInfo.service self.isGroup = isGroupHandle(identifier: chatInfo.identifier, guid: chatInfo.guid) self.participants = participants + self.accountID = chatInfo.accountID + self.accountLogin = chatInfo.accountLogin + self.lastAddressedHandle = chatInfo.lastAddressedHandle } enum CodingKeys: String, CodingKey { @@ -199,6 +214,9 @@ struct GroupPayload: Codable { case service case isGroup = "is_group" case participants + case accountID = "account_id" + case accountLogin = "account_login" + case lastAddressedHandle = "last_addressed_handle" } } diff --git a/Tests/IMsgCoreTests/MessageStoreListChatsTests.swift b/Tests/IMsgCoreTests/MessageStoreListChatsTests.swift index 64425fc..ff7821f 100644 --- a/Tests/IMsgCoreTests/MessageStoreListChatsTests.swift +++ b/Tests/IMsgCoreTests/MessageStoreListChatsTests.swift @@ -10,6 +10,9 @@ func listChatsReturnsChat() throws { let chats = try store.listChats(limit: 5) #expect(chats.count == 1) #expect(chats.first?.identifier == "+123") + #expect(chats.first?.accountID == "iMessage;+;me@icloud.com") + #expect(chats.first?.accountLogin == "me@icloud.com") + #expect(chats.first?.lastAddressedHandle == "+15551234567") } @Test @@ -58,4 +61,7 @@ func listChatsUsesChatMessageJoinDateWithoutMessageJoinWhenAvailable() throws { let chats = try store.listChats(limit: 1) #expect(chats.count == 1) #expect(chats.first?.identifier == "+222") + #expect(chats.first?.accountID == nil) + #expect(chats.first?.accountLogin == nil) + #expect(chats.first?.lastAddressedHandle == nil) } diff --git a/Tests/IMsgCoreTests/MessageStoreTestHelpers.swift b/Tests/IMsgCoreTests/MessageStoreTestHelpers.swift index 836cc78..8c1c37a 100644 --- a/Tests/IMsgCoreTests/MessageStoreTestHelpers.swift +++ b/Tests/IMsgCoreTests/MessageStoreTestHelpers.swift @@ -48,7 +48,10 @@ enum TestDatabase { chat_identifier TEXT, guid TEXT, display_name TEXT, - service_name TEXT + service_name TEXT, + account_id TEXT, + account_login TEXT, + last_addressed_handle TEXT ); """ ) @@ -80,8 +83,14 @@ enum TestDatabase { let now = Date() try db.run( """ - INSERT INTO chat(ROWID, chat_identifier, guid, display_name, service_name) - VALUES (1, '+123', 'iMessage;+;chat123', 'Test Chat', 'iMessage') + INSERT INTO chat( + ROWID, chat_identifier, guid, display_name, service_name, + account_id, account_login, last_addressed_handle + ) + VALUES ( + 1, '+123', 'iMessage;+;chat123', 'Test Chat', 'iMessage', + 'iMessage;+;me@icloud.com', 'me@icloud.com', '+15551234567' + ) """ ) try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123'), (2, 'Me')") diff --git a/Tests/IMsgCoreTests/MessageStoreTests.swift b/Tests/IMsgCoreTests/MessageStoreTests.swift index 9247899..8318ec9 100644 --- a/Tests/IMsgCoreTests/MessageStoreTests.swift +++ b/Tests/IMsgCoreTests/MessageStoreTests.swift @@ -43,6 +43,9 @@ func chatInfoReturnsMetadata() throws { #expect(info?.guid == "iMessage;+;chat123") #expect(info?.name == "Test Chat") #expect(info?.service == "iMessage") + #expect(info?.accountID == "iMessage;+;me@icloud.com") + #expect(info?.accountLogin == "me@icloud.com") + #expect(info?.lastAddressedHandle == "+15551234567") } @Test diff --git a/Tests/imsgTests/ChatHistorySendCommandTests.swift b/Tests/imsgTests/ChatHistorySendCommandTests.swift index 342a722..8bf8864 100644 --- a/Tests/imsgTests/ChatHistorySendCommandTests.swift +++ b/Tests/imsgTests/ChatHistorySendCommandTests.swift @@ -26,6 +26,9 @@ func chatsCommandRunsWithJsonOutput() async throws { #expect(payload["is_group"] as? Bool == true) #expect(payload["guid"] as? String == "iMessage;+;chat123") #expect(payload["display_name"] as? String == "Test Chat") + #expect(payload["account_id"] as? String == "iMessage;+;me@icloud.com") + #expect(payload["account_login"] as? String == "me@icloud.com") + #expect(payload["last_addressed_handle"] as? String == "+15551234567") #expect(payload["participants"] as? [String] == ["+123"]) } @@ -49,6 +52,9 @@ func chatsCommandJsonReportsDirectChatMetadata() async throws { #expect(payload["is_group"] as? Bool == false) #expect(payload["guid"] as? String == "iMessage;-;+123") #expect(payload["display_name"] as? String == "Direct Chat") + #expect(payload["account_id"] as? String == "iMessage;+;me@icloud.com") + #expect(payload["account_login"] as? String == "me@icloud.com") + #expect(payload["last_addressed_handle"] as? String == "+15551234567") #expect(payload["participants"] as? [String] == ["+123"]) } diff --git a/Tests/imsgTests/CommandTestDatabase.swift b/Tests/imsgTests/CommandTestDatabase.swift index e17c7c3..147664d 100644 --- a/Tests/imsgTests/CommandTestDatabase.swift +++ b/Tests/imsgTests/CommandTestDatabase.swift @@ -169,7 +169,10 @@ enum CommandTestDatabase { chat_identifier TEXT, guid TEXT, display_name TEXT, - service_name TEXT + service_name TEXT, + account_id TEXT, + account_login TEXT, + last_addressed_handle TEXT ); """ ) @@ -199,8 +202,14 @@ enum CommandTestDatabase { let now = Date() try db.run( """ - INSERT INTO chat(ROWID, chat_identifier, guid, display_name, service_name) - VALUES (1, '+123', 'iMessage;+;chat123', 'Test Chat', 'iMessage') + INSERT INTO chat( + ROWID, chat_identifier, guid, display_name, service_name, + account_id, account_login, last_addressed_handle + ) + VALUES ( + 1, '+123', 'iMessage;+;chat123', 'Test Chat', 'iMessage', + 'iMessage;+;me@icloud.com', 'me@icloud.com', '+15551234567' + ) """ ) try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')") @@ -219,8 +228,14 @@ enum CommandTestDatabase { let now = Date() try db.run( """ - INSERT INTO chat(ROWID, chat_identifier, guid, display_name, service_name) - VALUES (1, 'iMessage;+;chat123', 'iMessage;+;chat123', 'Group Chat', 'iMessage') + INSERT INTO chat( + ROWID, chat_identifier, guid, display_name, service_name, + account_id, account_login, last_addressed_handle + ) + VALUES ( + 1, 'iMessage;+;chat123', 'iMessage;+;chat123', 'Group Chat', 'iMessage', + 'iMessage;+;me@icloud.com', 'me@icloud.com', 'me@icloud.com' + ) """ ) try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123'), (2, 'me@icloud.com')") diff --git a/Tests/imsgTests/GroupCommandTests.swift b/Tests/imsgTests/GroupCommandTests.swift index f317dd0..7b987b8 100644 --- a/Tests/imsgTests/GroupCommandTests.swift +++ b/Tests/imsgTests/GroupCommandTests.swift @@ -59,6 +59,9 @@ func groupCommandPrintsPlainTextForGroup() async throws { #expect(output.contains("guid: iMessage;+;chat123")) #expect(output.contains("name: Test Chat")) #expect(output.contains("service: iMessage")) + #expect(output.contains("account_id: iMessage;+;me@icloud.com")) + #expect(output.contains("account_login: me@icloud.com")) + #expect(output.contains("last_addressed_handle: +15551234567")) #expect(output.contains("is_group: true")) #expect(output.contains("- +123")) } @@ -81,6 +84,9 @@ func groupCommandEmitsJsonPayload() async throws { #expect(payload["guid"] as? String == "iMessage;+;chat123") #expect(payload["name"] as? String == "Test Chat") #expect(payload["service"] as? String == "iMessage") + #expect(payload["account_id"] as? String == "iMessage;+;me@icloud.com") + #expect(payload["account_login"] as? String == "me@icloud.com") + #expect(payload["last_addressed_handle"] as? String == "+15551234567") #expect(payload["is_group"] as? Bool == true) #expect(payload["participants"] as? [String] == ["+123"]) } diff --git a/docs/groups.md b/docs/groups.md index f8fa92c..632b75b 100644 --- a/docs/groups.md +++ b/docs/groups.md @@ -12,6 +12,8 @@ - `chat.chat_identifier` -> group handle (used by Messages). - `chat.guid` -> group GUID (often same chat handle semantics). - `chat.display_name` -> group name (optional). +- `chat.account_id`, `chat.account_login`, `chat.last_addressed_handle` -> + read-only Messages routing hints for the local account/identity state. - Participants in `chat_handle_join` + `handle`. ## Sending to a group @@ -30,6 +32,9 @@ The direct CLI (`imsg chats`, `imsg history`, `imsg watch`) and JSON-RPC surface - `chat_identifier` - `chat_guid` - `chat_name` +- `account_id` +- `account_login` +- `last_addressed_handle` - `participants` (array of handles) - `is_group` @@ -41,6 +46,13 @@ stores external handles. The local user's handle is implicit and message-specifi use `is_from_me` plus `destination_caller_id` on sent messages when that distinction matters. +### Multiple local identities +Messages.app can store multiple local-account hints for a chat, but its +AppleScript `send` command does not expose a `from` or account selector. `imsg` +reports `account_id`, `account_login`, `last_addressed_handle`, and sent-message +`destination_caller_id` so callers can diagnose routing, but normal sends cannot +force a specific phone number when several numbers share one Apple ID. + ## Focused group lookup - `imsg group --chat-id ` prints id, identifier, guid, name, service, `is_group`, and participants for one chat. It works for direct chats too and diff --git a/docs/rpc.md b/docs/rpc.md index 717433c..8a6f1be 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -90,6 +90,9 @@ is not joined to the target chat. That case is reported as an error instead of - `guid` (string, optional) - `service` (string) - `last_message_at` (ISO8601) +- `account_id` (string, optional) +- `account_login` (string, optional) +- `last_addressed_handle` (string, optional) - `participants` (array, optional) - `is_group` (bool, optional) @@ -111,6 +114,10 @@ is not joined to the target chat. That case is reported as an error instead of - `participants` - `is_group` +`account_id`, `account_login`, `last_addressed_handle`, and sent-message +`destination_caller_id` are read-only routing diagnostics from Messages. The +AppleScript send API does not expose a `from` account or phone-number selector. + ## Examples Request: