feat: expose chat routing hints
This commit is contained in:
parent
df2d928ff0
commit
327829a819
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
111
Sources/IMsgCore/MessageStore+Chats.swift
Normal file
111
Sources/IMsgCore/MessageStore+Chats.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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')")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"])
|
||||
}
|
||||
|
||||
|
||||
@ -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')")
|
||||
|
||||
@ -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"])
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user