feat: add chat group lookup command
This commit is contained in:
parent
d825174537
commit
518144ee5b
@ -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)
|
||||
|
||||
@ -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 aren’t 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.
|
||||
|
||||
@ -11,6 +11,7 @@ struct CommandRouter {
|
||||
self.version = CommandRouter.resolveVersion()
|
||||
self.specs = [
|
||||
ChatsCommand.spec,
|
||||
GroupCommand.spec,
|
||||
HistoryCommand.spec,
|
||||
WatchCommand.spec,
|
||||
SendCommand.spec,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
54
Sources/imsg/Commands/GroupCommand.swift
Normal file
54
Sources/imsg/Commands/GroupCommand.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -32,3 +32,9 @@ func commandRouterUnknownCommand() async {
|
||||
}
|
||||
#expect(status == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func commandRouterIncludesGroupCommand() {
|
||||
let router = CommandRouter()
|
||||
#expect(router.specs.contains { $0.name == "group" })
|
||||
}
|
||||
|
||||
92
Tests/imsgTests/GroupCommandTests.swift
Normal file
92
Tests/imsgTests/GroupCommandTests.swift
Normal 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])
|
||||
}
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user