feat: add chat group lookup command

This commit is contained in:
Peter Steinberger 2026-05-04 06:37:27 +01:00
parent d825174537
commit 518144ee5b
No known key found for this signature in database
10 changed files with 252 additions and 9 deletions

View File

@ -1,6 +1,7 @@
# Changelog
## Unreleased
- feat: add `imsg group` chat metadata lookup and group fields to `chats --json` (#88, thanks @mryanb)
- fix: publish universal macOS release binaries for Homebrew installs (#68, #79)
- fix: include group metadata in CLI JSON history/watch output (#57, thanks @clawbunny)
- docs: document Homebrew install path in the README (#61, thanks @joshuayoes)

View File

@ -35,6 +35,7 @@ make build
## Commands
- `imsg chats [--limit 20] [--json]` — list recent conversations.
- `imsg group --chat-id <id> [--json]` — show identity and participants for one chat.
- `imsg history --chat-id <id> [--limit 50] [--attachments] [--participants +15551234567,...] [--start 2025-01-01T00:00:00Z] [--end 2025-02-01T00:00:00Z] [--json]`
- `imsg watch [--chat-id <id>] [--since-rowid <n>] [--debounce 250ms] [--attachments] [--participants …] [--start …] [--end …] [--json]`
- `imsg send --to <handle> [--text "hi"] [--file /path/img.jpg] [--service imessage|sms|auto] [--region US]`
@ -51,6 +52,9 @@ imsg chats --limit 5
# list chats as JSON
imsg chats --limit 5 --json
# show one chat's identity + participants
imsg group --chat-id 1 --json
# last 10 messages in chat 1 with attachments
imsg history --chat-id 1 --limit 10 --attachments
@ -80,7 +84,7 @@ imsg typing --to "+14155551212" --duration 5s
`--attachments` prints per-attachment lines with name, MIME, missing flag, and resolved path (tilde expanded). Only metadata is shown; files arent copied.
## JSON output
`imsg chats --json` emits one JSON object per chat with fields: `id`, `name`, `identifier`, `service`, `last_message_at`.
`imsg chats --json` emits one JSON object per chat with fields: `id`, `name`, `identifier`, `service`, `last_message_at`, `guid`, `display_name`, `is_group`, `participants`.
`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`, `is_from_me`, `text`, `created_at`, `attachments` (array of metadata with `filename`, `transfer_name`, `uti`, `mime_type`, `total_bytes`, `is_sticker`, `original_path`, `missing`), `reactions`.
Note: `reply_to_guid`, `destination_caller_id`, and `reactions` are read-only metadata.

View File

@ -11,6 +11,7 @@ struct CommandRouter {
self.version = CommandRouter.resolveVersion()
self.specs = [
ChatsCommand.spec,
GroupCommand.spec,
HistoryCommand.spec,
WatchCommand.spec,
SendCommand.spec,

View File

@ -26,7 +26,10 @@ enum ChatsCommand {
if runtime.jsonOutput {
for chat in chats {
try StdoutWriter.writeJSONLine(ChatPayload(chat: chat))
let chatInfo = try store.chatInfo(chatID: chat.id)
let participants = try store.participants(chatID: chat.id)
try StdoutWriter.writeJSONLine(
ChatPayload(chat: chat, chatInfo: chatInfo, participants: participants))
}
return
}

View File

@ -0,0 +1,54 @@
import Commander
import Foundation
import IMsgCore
enum GroupCommand {
static let spec = CommandSpec(
name: "group",
abstract: "Show chat identity and participants for a chat id",
discussion: "Prints chat identifier, guid, display name, service, group flag, "
+ "and participants for a given chat rowid. Works for direct chats too.",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chatID", names: [.long("chat-id")], help: "chat rowid from 'imsg chats'")
]
)
),
usageExamples: [
"imsg group --chat-id 1",
"imsg group --chat-id 1 --json",
]
) { values, runtime in
guard let chatID = values.optionInt64("chatID") else {
throw ParsedValuesError.missingOption("chat-id")
}
let dbPath = values.option("db") ?? MessageStore.defaultPath
let store = try MessageStore(path: dbPath)
guard let info = try store.chatInfo(chatID: chatID) else {
throw IMsgError.chatNotFound(chatID: chatID)
}
let participants = try store.participants(chatID: chatID)
if runtime.jsonOutput {
try StdoutWriter.writeJSONLine(GroupPayload(chatInfo: info, participants: participants))
return
}
StdoutWriter.writeLine("id: \(info.id)")
StdoutWriter.writeLine("identifier: \(info.identifier)")
StdoutWriter.writeLine("guid: \(info.guid)")
StdoutWriter.writeLine("name: \(info.name)")
StdoutWriter.writeLine("service: \(info.service)")
let isGroup = isGroupHandle(identifier: info.identifier, guid: info.guid)
StdoutWriter.writeLine("is_group: \(isGroup)")
if participants.isEmpty {
StdoutWriter.writeLine("participants: (none)")
} else {
StdoutWriter.writeLine("participants:")
for handle in participants {
StdoutWriter.writeLine(" - \(handle)")
}
}
}
}

View File

@ -7,13 +7,23 @@ struct ChatPayload: Codable {
let identifier: String
let service: String
let lastMessageAt: String
let guid: String?
let displayName: String?
let isGroup: Bool
let participants: [String]?
init(chat: Chat) {
init(chat: Chat, chatInfo: ChatInfo? = nil, participants: [String]? = nil) {
let identifier = chatInfo?.identifier ?? chat.identifier
let guid = chatInfo?.guid ?? ""
self.id = chat.id
self.name = chat.name
self.identifier = chat.identifier
self.service = chat.service
self.identifier = identifier
self.service = chatInfo?.service ?? chat.service
self.lastMessageAt = CLIISO8601.format(chat.lastMessageAt)
self.guid = guid.isEmpty ? nil : guid
self.displayName = chatInfo?.name
self.isGroup = isGroupHandle(identifier: identifier, guid: guid)
self.participants = participants
}
enum CodingKeys: String, CodingKey {
@ -22,6 +32,10 @@ struct ChatPayload: Codable {
case identifier
case service
case lastMessageAt = "last_message_at"
case guid
case displayName = "display_name"
case isGroup = "is_group"
case participants
}
}
@ -139,6 +153,36 @@ struct ReactionPayload: Codable {
}
}
struct GroupPayload: Codable {
let id: Int64
let identifier: String
let guid: String
let name: String
let service: String
let isGroup: Bool
let participants: [String]
init(chatInfo: ChatInfo, participants: [String]) {
self.id = chatInfo.id
self.identifier = chatInfo.identifier
self.guid = chatInfo.guid
self.name = chatInfo.name
self.service = chatInfo.service
self.isGroup = isGroupHandle(identifier: chatInfo.identifier, guid: chatInfo.guid)
self.participants = participants
}
enum CodingKeys: String, CodingKey {
case id
case identifier
case guid
case name
case service
case isGroup = "is_group"
case participants
}
}
struct AttachmentPayload: Codable {
let filename: String
let transferName: String

View File

@ -14,9 +14,33 @@ func chatsCommandRunsWithJsonOutput() async throws {
flags: ["jsonOutput"]
)
let runtime = RuntimeOptions(parsedValues: values)
_ = try await StdoutCapture.capture {
let (output, _) = try await StdoutCapture.capture {
try await ChatsCommand.spec.run(values, runtime)
}
let payload = try jsonObject(from: output)
#expect(payload["is_group"] as? Bool == true)
#expect(payload["guid"] as? String == "iMessage;+;chat123")
#expect(payload["display_name"] as? String == "Test Chat")
#expect(payload["participants"] as? [String] == ["+123"])
}
@Test
func chatsCommandJsonReportsDirectChatMetadata() async throws {
let path = try CommandTestDatabase.makePathDirectChat()
let values = ParsedValues(
positional: [],
options: ["db": [path], "limit": ["5"]],
flags: ["jsonOutput"]
)
let runtime = RuntimeOptions(parsedValues: values)
let (output, _) = try await StdoutCapture.capture {
try await ChatsCommand.spec.run(values, runtime)
}
let payload = try jsonObject(from: output)
#expect(payload["is_group"] as? Bool == false)
#expect(payload["guid"] as? String == "iMessage;-;+123")
#expect(payload["display_name"] as? String == "Direct Chat")
#expect(payload["participants"] as? [String] == ["+123"])
}
@Test

View File

@ -32,3 +32,9 @@ func commandRouterUnknownCommand() async {
}
#expect(status == 1)
}
@Test
func commandRouterIncludesGroupCommand() {
let router = CommandRouter()
#expect(router.specs.contains { $0.name == "group" })
}

View File

@ -0,0 +1,92 @@
import Commander
import Foundation
import Testing
@testable import IMsgCore
@testable import imsg
@Test
func groupCommandRequiresChatID() async {
let values = ParsedValues(
positional: [],
options: [:],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
do {
try await GroupCommand.spec.run(values, runtime)
#expect(Bool(false))
} catch let error as ParsedValuesError {
#expect(error.description.contains("Missing required option"))
} catch {
#expect(Bool(false))
}
}
@Test
func groupCommandThrowsOnUnknownChatID() async throws {
let path = try CommandTestDatabase.makePath()
let values = ParsedValues(
positional: [],
options: ["db": [path], "chatID": ["9999"]],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
do {
try await GroupCommand.spec.run(values, runtime)
#expect(Bool(false))
} catch let error as IMsgError {
#expect(error.errorDescription?.contains("9999") == true)
} catch {
#expect(Bool(false))
}
}
@Test
func groupCommandPrintsPlainTextForGroup() async throws {
let path = try CommandTestDatabase.makePath()
let values = ParsedValues(
positional: [],
options: ["db": [path], "chatID": ["1"]],
flags: []
)
let runtime = RuntimeOptions(parsedValues: values)
let (output, _) = try await StdoutCapture.capture {
try await GroupCommand.spec.run(values, runtime)
}
#expect(output.contains("id: 1"))
#expect(output.contains("identifier: +123"))
#expect(output.contains("guid: iMessage;+;chat123"))
#expect(output.contains("name: Test Chat"))
#expect(output.contains("service: iMessage"))
#expect(output.contains("is_group: true"))
#expect(output.contains("- +123"))
}
@Test
func groupCommandEmitsJsonPayload() async throws {
let path = try CommandTestDatabase.makePath()
let values = ParsedValues(
positional: [],
options: ["db": [path], "chatID": ["1"]],
flags: ["jsonOutput"]
)
let runtime = RuntimeOptions(parsedValues: values)
let (output, _) = try await StdoutCapture.capture {
try await GroupCommand.spec.run(values, runtime)
}
let payload = try jsonObject(from: output)
#expect(payload["id"] as? Int == 1)
#expect(payload["identifier"] as? String == "+123")
#expect(payload["guid"] as? String == "iMessage;+;chat123")
#expect(payload["name"] as? String == "Test Chat")
#expect(payload["service"] as? String == "iMessage")
#expect(payload["is_group"] as? Bool == true)
#expect(payload["participants"] as? [String] == ["+123"])
}
private func jsonObject(from output: String) throws -> [String: Any] {
let line = output.split(separator: "\n").first.map(String.init) ?? ""
let data = Data(line.utf8)
return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
}

View File

@ -1,9 +1,11 @@
# Groups
## What counts as a group
- `chat.chat_identifier` or `chat.guid` with `;+;` or `;-;` in the handle.
- Example: `iMessage;+;chat1234567890` (group handle).
- Direct chats typically use a single handle (phone/email) without `;+;` or `;-;`.
- `chat.chat_identifier` or `chat.guid` contains `;+;`, for example
`iMessage;+;chat1234567890`.
- `SERVICE;-;TARGET` is a direct 1:1 chat, for example `iMessage;-;+15551234567`,
and is deliberately not flagged as a group.
- Direct chats typically use a single handle (phone/email) with no `;+;`.
## Where the identifiers live
- `chat.ROWID` -> `chat_id` (stable within one DB).
@ -20,6 +22,7 @@
- Attachments supported same as direct sends.
## Inbound metadata (JSON)
The direct CLI (`imsg chats`, `imsg history`, `imsg watch`) and JSON-RPC surface include:
- `chat_id`
- `chat_identifier`
- `chat_guid`
@ -29,6 +32,17 @@
`chat_id` is preferred for routing within one machine/DB.
### Participants exclude the local user
`participants` is sourced from Messages.app's `chat_handle_join` table, which
stores external handles. The local user's handle is implicit and message-specific:
use `is_from_me` plus `destination_caller_id` on sent messages when that distinction
matters.
## 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
supports `--json`.
## Notes
- Group send uses chat handle, not `buddy`.
- Messages from self may have empty `sender`; prefer `SenderName` + chat metadata.