feat: expose chat routing hints

This commit is contained in:
Peter Steinberger 2026-05-05 02:00:56 +01:00
parent df2d928ff0
commit 327829a819
No known key found for this signature in database
16 changed files with 289 additions and 100 deletions

View File

@ -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)

View File

@ -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:

View File

@ -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<String>()
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
}
}
}

View File

@ -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
)
}
}

View File

@ -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<String>()
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,

View File

@ -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
}
}

View File

@ -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 {

View File

@ -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"
}
}

View File

@ -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)
}

View File

@ -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')")

View File

@ -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

View File

@ -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"])
}

View File

@ -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')")

View File

@ -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"])
}

View File

@ -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 <rowid>` prints id, identifier, guid, name, service,
`is_group`, and participants for one chat. It works for direct chats too and

View File

@ -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: