@indigoai-us/hq-cli 5.69.0 → 5.70.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/dist/commands/channels.js +6 -3
- package/dist/commands/dm.d.ts +76 -0
- package/dist/commands/dm.js +317 -3
- package/package.json +1 -1
- package/src/commands/channels.ts +4 -1
- package/src/commands/dm.test.ts +265 -0
- package/src/commands/dm.ts +449 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.70.0]
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Read DMs from the CLI.** `hq dm` gained a receive side, wired to the notify
|
|
10
|
+
read endpoints that already back the HQ Sync menubar:
|
|
11
|
+
- `hq dm inbox` — list your incoming direct messages, with `--unread`,
|
|
12
|
+
`--limit`, `--json`, and `--mark-read`.
|
|
13
|
+
- `hq dm thread <person>` (alias `read`) — the two-way 1:1 conversation with a
|
|
14
|
+
person by email, personUid, or agentUid, read oldest-first. Marks their
|
|
15
|
+
messages read unless `--no-ack`. Reading by email uses the notify thread
|
|
16
|
+
endpoint's new `withEmail` resolution (requires the matching hq-pro deploy).
|
|
17
|
+
- `hq dm channel <name|#name|id>` (alias `history`) — channel and group-DM
|
|
18
|
+
message history, with `--mark-read` to advance your read cursor.
|
|
19
|
+
- **`hq channels` now shows each group DM's channel id**, so unnamed group DMs
|
|
20
|
+
are addressable for reading with `hq dm channel <id>`.
|
|
21
|
+
|
|
5
22
|
## [5.69.0]
|
|
6
23
|
|
|
7
24
|
### Added
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="fe48059a-56b5-5f50-8ebf-b5fbbe1aa432")}catch(e){}}();
|
|
3
3
|
import chalk from "chalk";
|
|
4
4
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
5
5
|
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
@@ -18,7 +18,10 @@ export function describeChannel(c) {
|
|
|
18
18
|
const who = names.length > 0
|
|
19
19
|
? names.join(", ")
|
|
20
20
|
: `${c.memberCount ?? "?"}-person group`;
|
|
21
|
-
|
|
21
|
+
// Group DMs are unnamed, so their channel id is the only way to address them
|
|
22
|
+
// for reading (`hq dm channel <id>`). Surface it so it can be copied.
|
|
23
|
+
const readHint = chalk.dim(`— hq dm channel ${c.channelId}`);
|
|
24
|
+
return `${who} ${chalk.dim("(group DM)")} ${readHint}`;
|
|
22
25
|
}
|
|
23
26
|
const slug = c.slug ?? (c.name ? channelSlug(c.name) : "");
|
|
24
27
|
const scopeTag = c.scope ? chalk.dim(`(${c.scope})`) : "";
|
|
@@ -75,4 +78,4 @@ export function registerChannelsCommand(program) {
|
|
|
75
78
|
});
|
|
76
79
|
}
|
|
77
80
|
//# sourceMappingURL=channels.js.map
|
|
78
|
-
//# debugId=
|
|
81
|
+
//# debugId=fe48059a-56b5-5f50-8ebf-b5fbbe1aa432
|
package/dist/commands/dm.d.ts
CHANGED
|
@@ -119,5 +119,81 @@ export declare function buildConnectionActionBody(identifier: string, matched: C
|
|
|
119
119
|
} | {
|
|
120
120
|
withEmail: string;
|
|
121
121
|
};
|
|
122
|
+
/** One incoming DM as returned by GET /v1/notify/inbox. */
|
|
123
|
+
export interface DmInboxEvent {
|
|
124
|
+
eventId: string;
|
|
125
|
+
fromPersonUid?: string;
|
|
126
|
+
fromEmail?: string;
|
|
127
|
+
fromDisplayName?: string;
|
|
128
|
+
body: string;
|
|
129
|
+
createdAt: string;
|
|
130
|
+
details?: string;
|
|
131
|
+
prompt?: string;
|
|
132
|
+
acknowledgedAt?: string;
|
|
133
|
+
}
|
|
134
|
+
/** One message in a 1:1 thread as returned by GET /v1/notify/thread. */
|
|
135
|
+
export interface DmThreadMessage {
|
|
136
|
+
eventId: string;
|
|
137
|
+
fromPersonUid?: string;
|
|
138
|
+
fromEmail?: string;
|
|
139
|
+
fromDisplayName?: string;
|
|
140
|
+
body: string;
|
|
141
|
+
createdAt: string;
|
|
142
|
+
direction: "in" | "out";
|
|
143
|
+
details?: string;
|
|
144
|
+
prompt?: string;
|
|
145
|
+
}
|
|
146
|
+
/** One channel/group message from GET /v1/notify/channels/{id}/messages. */
|
|
147
|
+
export interface ChannelMessageItem {
|
|
148
|
+
eventId?: string;
|
|
149
|
+
messageId?: string;
|
|
150
|
+
fromPersonUid?: string;
|
|
151
|
+
fromEmail?: string;
|
|
152
|
+
fromDisplayName?: string;
|
|
153
|
+
body: string;
|
|
154
|
+
createdAt: string;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Human label for a message sender — display name, else email, else uid. Pure →
|
|
158
|
+
* unit-testable.
|
|
159
|
+
*/
|
|
160
|
+
export declare function senderLabel(m: {
|
|
161
|
+
fromDisplayName?: string;
|
|
162
|
+
fromEmail?: string;
|
|
163
|
+
fromPersonUid?: string;
|
|
164
|
+
}): string;
|
|
165
|
+
/**
|
|
166
|
+
* Render an ISO timestamp as a compact relative age ("3m ago", "2h ago",
|
|
167
|
+
* "5d ago"), falling back to a YYYY-MM-DD date for anything older than a week or
|
|
168
|
+
* an unparseable input. `nowMs` is injected so the formatting is unit-testable.
|
|
169
|
+
* Pure.
|
|
170
|
+
*/
|
|
171
|
+
export declare function formatRelativeTime(iso: string, nowMs: number): string;
|
|
172
|
+
/** A DM is unread until the recipient acks it. Pure. */
|
|
173
|
+
export declare function isUnread(e: {
|
|
174
|
+
acknowledgedAt?: string;
|
|
175
|
+
}): boolean;
|
|
176
|
+
/** Event ids of the unread messages in a fetched inbox page. Pure. */
|
|
177
|
+
export declare function unreadEventIds(events: DmInboxEvent[]): string[];
|
|
178
|
+
/** Keep only the unread messages. Pure. */
|
|
179
|
+
export declare function filterUnread(events: DmInboxEvent[]): DmInboxEvent[];
|
|
180
|
+
/**
|
|
181
|
+
* Collapse a message body to a single trimmed line for list rendering, capped
|
|
182
|
+
* so one row stays readable. Pure.
|
|
183
|
+
*/
|
|
184
|
+
export declare function firstLine(body: string, max?: number): string;
|
|
185
|
+
/**
|
|
186
|
+
* Turn a person identifier into the query the thread endpoint expects. An email
|
|
187
|
+
* rides `withEmail` (server resolves it); a prs_/agt_ uid rides `withPersonUid`.
|
|
188
|
+
* A bare name is rejected — resolve it first with `hq people resolve`. Pure →
|
|
189
|
+
* unit-testable. Throws with a user-facing message on an invalid identifier.
|
|
190
|
+
*/
|
|
191
|
+
export declare function buildThreadQuery(identifier: string): Record<string, string>;
|
|
192
|
+
/** Render one inbox row (marker · age · sender · first line of body). */
|
|
193
|
+
export declare function formatInboxEvent(e: DmInboxEvent, nowMs: number): string;
|
|
194
|
+
/** Render one 1:1 thread line, tagged by direction. */
|
|
195
|
+
export declare function formatThreadMessage(m: DmThreadMessage, nowMs: number): string;
|
|
196
|
+
/** Render one channel/group message line. */
|
|
197
|
+
export declare function formatChannelMessage(m: ChannelMessageItem, nowMs: number): string;
|
|
122
198
|
export declare function registerDmCommand(program: Command): void;
|
|
123
199
|
//# sourceMappingURL=dm.d.ts.map
|
package/dist/commands/dm.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="8ba6c13e-f0d4-58c7-a62e-eede4fb7873f")}catch(e){}}();
|
|
3
3
|
import chalk from "chalk";
|
|
4
4
|
import { readFileSync } from "node:fs";
|
|
5
5
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
@@ -497,10 +497,294 @@ async function runDmRequests() {
|
|
|
497
497
|
process.exit(1);
|
|
498
498
|
}
|
|
499
499
|
}
|
|
500
|
+
/**
|
|
501
|
+
* Human label for a message sender — display name, else email, else uid. Pure →
|
|
502
|
+
* unit-testable.
|
|
503
|
+
*/
|
|
504
|
+
export function senderLabel(m) {
|
|
505
|
+
return (m.fromDisplayName?.trim() ||
|
|
506
|
+
m.fromEmail?.trim() ||
|
|
507
|
+
m.fromPersonUid?.trim() ||
|
|
508
|
+
"unknown");
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Render an ISO timestamp as a compact relative age ("3m ago", "2h ago",
|
|
512
|
+
* "5d ago"), falling back to a YYYY-MM-DD date for anything older than a week or
|
|
513
|
+
* an unparseable input. `nowMs` is injected so the formatting is unit-testable.
|
|
514
|
+
* Pure.
|
|
515
|
+
*/
|
|
516
|
+
export function formatRelativeTime(iso, nowMs) {
|
|
517
|
+
const t = new Date(iso).getTime();
|
|
518
|
+
if (isNaN(t))
|
|
519
|
+
return iso;
|
|
520
|
+
const diff = nowMs - t;
|
|
521
|
+
if (diff < 60_000)
|
|
522
|
+
return "just now";
|
|
523
|
+
const mins = Math.floor(diff / 60_000);
|
|
524
|
+
if (mins < 60)
|
|
525
|
+
return `${mins}m ago`;
|
|
526
|
+
const hours = Math.floor(mins / 60);
|
|
527
|
+
if (hours < 24)
|
|
528
|
+
return `${hours}h ago`;
|
|
529
|
+
const days = Math.floor(hours / 24);
|
|
530
|
+
if (days < 7)
|
|
531
|
+
return `${days}d ago`;
|
|
532
|
+
return new Date(t).toISOString().slice(0, 10);
|
|
533
|
+
}
|
|
534
|
+
/** A DM is unread until the recipient acks it. Pure. */
|
|
535
|
+
export function isUnread(e) {
|
|
536
|
+
return !e.acknowledgedAt;
|
|
537
|
+
}
|
|
538
|
+
/** Event ids of the unread messages in a fetched inbox page. Pure. */
|
|
539
|
+
export function unreadEventIds(events) {
|
|
540
|
+
return events
|
|
541
|
+
.filter(isUnread)
|
|
542
|
+
.map((e) => e.eventId)
|
|
543
|
+
.filter((id) => typeof id === "string" && id.length > 0);
|
|
544
|
+
}
|
|
545
|
+
/** Keep only the unread messages. Pure. */
|
|
546
|
+
export function filterUnread(events) {
|
|
547
|
+
return events.filter(isUnread);
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Collapse a message body to a single trimmed line for list rendering, capped
|
|
551
|
+
* so one row stays readable. Pure.
|
|
552
|
+
*/
|
|
553
|
+
export function firstLine(body, max = 240) {
|
|
554
|
+
const oneLine = (body ?? "").replace(/\s+/g, " ").trim();
|
|
555
|
+
if (oneLine.length <= max)
|
|
556
|
+
return oneLine;
|
|
557
|
+
return oneLine.slice(0, max - 1) + "…";
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Turn a person identifier into the query the thread endpoint expects. An email
|
|
561
|
+
* rides `withEmail` (server resolves it); a prs_/agt_ uid rides `withPersonUid`.
|
|
562
|
+
* A bare name is rejected — resolve it first with `hq people resolve`. Pure →
|
|
563
|
+
* unit-testable. Throws with a user-facing message on an invalid identifier.
|
|
564
|
+
*/
|
|
565
|
+
export function buildThreadQuery(identifier) {
|
|
566
|
+
const rcpt = detectRecipient(identifier);
|
|
567
|
+
if (!rcpt) {
|
|
568
|
+
throw new Error(`Invalid person '${identifier}': pass an email or a personUid/agentUid (prs_… / agt_…). Resolve a name first with \`hq people resolve\`.`);
|
|
569
|
+
}
|
|
570
|
+
if (rcpt.toEmail)
|
|
571
|
+
return { withEmail: rcpt.toEmail };
|
|
572
|
+
return { withPersonUid: rcpt.toPersonUid };
|
|
573
|
+
}
|
|
574
|
+
/** Render one inbox row (marker · age · sender · first line of body). */
|
|
575
|
+
export function formatInboxEvent(e, nowMs) {
|
|
576
|
+
const marker = isUnread(e) ? chalk.cyan("●") : " ";
|
|
577
|
+
const when = chalk.dim(formatRelativeTime(e.createdAt, nowMs));
|
|
578
|
+
const who = chalk.bold(senderLabel(e));
|
|
579
|
+
const email = e.fromEmail && e.fromDisplayName ? chalk.dim(` <${e.fromEmail}>`) : "";
|
|
580
|
+
return `${marker} ${when} ${who}${email}\n ${firstLine(e.body)}`;
|
|
581
|
+
}
|
|
582
|
+
/** Render one 1:1 thread line, tagged by direction. */
|
|
583
|
+
export function formatThreadMessage(m, nowMs) {
|
|
584
|
+
const arrow = m.direction === "out" ? chalk.dim("→") : chalk.cyan("←");
|
|
585
|
+
const who = m.direction === "out" ? "you" : senderLabel(m);
|
|
586
|
+
const when = chalk.dim(formatRelativeTime(m.createdAt, nowMs));
|
|
587
|
+
return `${arrow} ${chalk.bold(who)} ${when}\n ${firstLine(m.body)}`;
|
|
588
|
+
}
|
|
589
|
+
/** Render one channel/group message line. */
|
|
590
|
+
export function formatChannelMessage(m, nowMs) {
|
|
591
|
+
const who = chalk.bold(senderLabel(m));
|
|
592
|
+
const when = chalk.dim(formatRelativeTime(m.createdAt, nowMs));
|
|
593
|
+
return `${who} ${when}\n ${firstLine(m.body)}`;
|
|
594
|
+
}
|
|
595
|
+
/** POST /v1/notify/inbox/ack — idempotently mark messages read. */
|
|
596
|
+
async function ackEvents(token, eventIds) {
|
|
597
|
+
if (eventIds.length === 0)
|
|
598
|
+
return;
|
|
599
|
+
const res = await vaultApiFetch({
|
|
600
|
+
token,
|
|
601
|
+
path: "/v1/notify/inbox/ack",
|
|
602
|
+
method: "POST",
|
|
603
|
+
body: { eventIds },
|
|
604
|
+
});
|
|
605
|
+
if (!res.ok) {
|
|
606
|
+
const err = (await res.json().catch(() => ({})));
|
|
607
|
+
throw new Error(friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText));
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
async function runDmInbox(opts) {
|
|
611
|
+
try {
|
|
612
|
+
const token = await ensureCognitoToken();
|
|
613
|
+
const query = {};
|
|
614
|
+
if (opts.limit)
|
|
615
|
+
query.limit = opts.limit;
|
|
616
|
+
const res = await vaultApiFetch({ token, path: "/v1/notify/inbox", query });
|
|
617
|
+
if (!res.ok) {
|
|
618
|
+
const err = (await res.json().catch(() => ({})));
|
|
619
|
+
console.error(chalk.red(friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText)));
|
|
620
|
+
process.exit(1);
|
|
621
|
+
}
|
|
622
|
+
const data = (await res.json());
|
|
623
|
+
const all = data.events ?? [];
|
|
624
|
+
const shown = opts.unread ? filterUnread(all) : all;
|
|
625
|
+
if (opts.json) {
|
|
626
|
+
console.log(JSON.stringify(shown, null, 2));
|
|
627
|
+
}
|
|
628
|
+
else if (shown.length === 0) {
|
|
629
|
+
console.log(chalk.dim(opts.unread ? "No unread messages." : "No messages yet."));
|
|
630
|
+
}
|
|
631
|
+
else {
|
|
632
|
+
const unreadCount = filterUnread(all).length;
|
|
633
|
+
const suffix = unreadCount > 0 ? ` (${unreadCount} unread)` : "";
|
|
634
|
+
console.log(chalk.green(`${shown.length} message${shown.length === 1 ? "" : "s"}${suffix}:`));
|
|
635
|
+
const now = Date.now();
|
|
636
|
+
for (const e of shown)
|
|
637
|
+
console.log(`\n${formatInboxEvent(e, now)}`);
|
|
638
|
+
if (data.nextCursor) {
|
|
639
|
+
console.log(chalk.dim("\nMore messages available — raise --limit to see them."));
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
// Ack every unread message on the fetched page (not just the filtered view).
|
|
643
|
+
if (opts.markRead) {
|
|
644
|
+
const ids = unreadEventIds(all);
|
|
645
|
+
await ackEvents(token, ids);
|
|
646
|
+
if (!opts.json && ids.length > 0) {
|
|
647
|
+
console.log(chalk.dim(`\nMarked ${ids.length} read.`));
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
catch (err) {
|
|
652
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
653
|
+
process.exit(1);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
async function runDmThread(identifier, opts) {
|
|
657
|
+
try {
|
|
658
|
+
const query = buildThreadQuery(identifier);
|
|
659
|
+
if (opts.limit)
|
|
660
|
+
query.limit = opts.limit;
|
|
661
|
+
const token = await ensureCognitoToken();
|
|
662
|
+
const res = await vaultApiFetch({
|
|
663
|
+
token,
|
|
664
|
+
path: "/v1/notify/thread",
|
|
665
|
+
query,
|
|
666
|
+
});
|
|
667
|
+
if (!res.ok) {
|
|
668
|
+
const err = (await res.json().catch(() => ({})));
|
|
669
|
+
console.error(chalk.red(friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText)));
|
|
670
|
+
process.exit(1);
|
|
671
|
+
}
|
|
672
|
+
const data = (await res.json());
|
|
673
|
+
const messages = data.messages ?? [];
|
|
674
|
+
// The server returns newest-first; read a conversation oldest-first.
|
|
675
|
+
const ordered = [...messages].reverse();
|
|
676
|
+
if (opts.json) {
|
|
677
|
+
console.log(JSON.stringify(ordered, null, 2));
|
|
678
|
+
}
|
|
679
|
+
else if (ordered.length === 0) {
|
|
680
|
+
console.log(chalk.dim(`No messages with ${identifier} yet.`));
|
|
681
|
+
}
|
|
682
|
+
else {
|
|
683
|
+
console.log(chalk.green(`${ordered.length} message${ordered.length === 1 ? "" : "s"} with ${identifier}:`));
|
|
684
|
+
const now = Date.now();
|
|
685
|
+
for (const m of ordered)
|
|
686
|
+
console.log(`\n${formatThreadMessage(m, now)}`);
|
|
687
|
+
}
|
|
688
|
+
// Mark the incoming messages read unless the caller opted out. Best-effort:
|
|
689
|
+
// a read is a side effect, not the point of the command, so an ack failure
|
|
690
|
+
// is surfaced but does not fail the read.
|
|
691
|
+
if (opts.ack !== false) {
|
|
692
|
+
const inIds = messages
|
|
693
|
+
.filter((m) => m.direction === "in")
|
|
694
|
+
.map((m) => m.eventId)
|
|
695
|
+
.filter((id) => typeof id === "string" && id.length > 0);
|
|
696
|
+
try {
|
|
697
|
+
await ackEvents(token, inIds);
|
|
698
|
+
}
|
|
699
|
+
catch (ackErr) {
|
|
700
|
+
console.error(chalk.dim(`(could not mark read: ${ackErr instanceof Error ? ackErr.message : String(ackErr)})`));
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
catch (err) {
|
|
705
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
706
|
+
process.exit(1);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Resolve a `hq dm channel <target>` argument to a channelId. Accepts a channel
|
|
711
|
+
* name (bare or `#name`) resolved against the caller's channels, or a raw
|
|
712
|
+
* channelId (the only way to address an unnamed group DM — copy it from
|
|
713
|
+
* `hq channels`). Throws a user-facing message when nothing matches or a name is
|
|
714
|
+
* ambiguous.
|
|
715
|
+
*/
|
|
716
|
+
async function resolveChannelId(token, target) {
|
|
717
|
+
const raw = target.trim().replace(/^#/, "");
|
|
718
|
+
if (!raw)
|
|
719
|
+
throw new Error("A channel name or id is required.");
|
|
720
|
+
const channels = await fetchChannels(token);
|
|
721
|
+
const named = matchChannelsByName(channels, raw);
|
|
722
|
+
if (named.length === 1)
|
|
723
|
+
return named[0].channelId;
|
|
724
|
+
if (named.length > 1) {
|
|
725
|
+
throw new Error(`'${raw}' matches ${named.length} channels — pass the channel id instead (see \`hq channels\`).`);
|
|
726
|
+
}
|
|
727
|
+
const byId = channels.find((c) => c.channelId === raw);
|
|
728
|
+
if (byId)
|
|
729
|
+
return byId.channelId;
|
|
730
|
+
throw new Error(`No channel named or with id '${raw}' — run \`hq channels\` to see yours.`);
|
|
731
|
+
}
|
|
732
|
+
async function runDmChannel(target, opts) {
|
|
733
|
+
try {
|
|
734
|
+
const token = await ensureCognitoToken();
|
|
735
|
+
const channelId = await resolveChannelId(token, target);
|
|
736
|
+
const query = {};
|
|
737
|
+
if (opts.limit)
|
|
738
|
+
query.limit = opts.limit;
|
|
739
|
+
const res = await vaultApiFetch({
|
|
740
|
+
token,
|
|
741
|
+
path: `/v1/notify/channels/${encodeURIComponent(channelId)}/messages`,
|
|
742
|
+
query,
|
|
743
|
+
});
|
|
744
|
+
if (!res.ok) {
|
|
745
|
+
const err = (await res.json().catch(() => ({})));
|
|
746
|
+
console.error(chalk.red(friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText)));
|
|
747
|
+
process.exit(1);
|
|
748
|
+
}
|
|
749
|
+
const data = (await res.json());
|
|
750
|
+
const messages = data.messages ?? [];
|
|
751
|
+
const ordered = [...messages].reverse(); // oldest-first for reading
|
|
752
|
+
if (opts.json) {
|
|
753
|
+
console.log(JSON.stringify(ordered, null, 2));
|
|
754
|
+
}
|
|
755
|
+
else if (ordered.length === 0) {
|
|
756
|
+
console.log(chalk.dim("No messages in this channel yet."));
|
|
757
|
+
}
|
|
758
|
+
else {
|
|
759
|
+
console.log(chalk.green(`${ordered.length} message${ordered.length === 1 ? "" : "s"}:`));
|
|
760
|
+
const now = Date.now();
|
|
761
|
+
for (const m of ordered)
|
|
762
|
+
console.log(`\n${formatChannelMessage(m, now)}`);
|
|
763
|
+
}
|
|
764
|
+
if (opts.markRead) {
|
|
765
|
+
// messages are newest-first from the server; advance the read cursor to
|
|
766
|
+
// the newest one we saw.
|
|
767
|
+
const newest = messages[0]?.createdAt;
|
|
768
|
+
const readRes = await vaultApiFetch({
|
|
769
|
+
token,
|
|
770
|
+
path: `/v1/notify/channels/${encodeURIComponent(channelId)}/read`,
|
|
771
|
+
method: "POST",
|
|
772
|
+
body: newest ? { lastReadAt: newest } : {},
|
|
773
|
+
});
|
|
774
|
+
if (!opts.json && readRes.ok) {
|
|
775
|
+
console.log(chalk.dim("\nMarked read."));
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
catch (err) {
|
|
780
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
781
|
+
process.exit(1);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
500
784
|
export function registerDmCommand(program) {
|
|
501
785
|
const dm = program
|
|
502
786
|
.command("dm")
|
|
503
|
-
.description("Send
|
|
787
|
+
.description("Send and read direct messages, and manage connection requests.");
|
|
504
788
|
dm
|
|
505
789
|
.command("send [recipient] [message]", { isDefault: true, hidden: true })
|
|
506
790
|
.description('Send a direct message. RECIPIENT can be a person (email, personUid, or agentUid), a GROUP DM (comma-separated: "a@x.com,b@y.com"), or one of your DM CHANNELS by name — bare (hq dm vyg-dev "hi"), hash form (hq dm "#vyg-dev" "hi"), or via --channel (hq dm --channel vyg-dev "hi"). A person receives a DM as an HQ Sync notification; an agent receives it in its durable box inbox. If you aren\'t connected yet, it sends a connection request that holds your message. See your channels with `hq channels`.')
|
|
@@ -514,6 +798,36 @@ export function registerDmCommand(program) {
|
|
|
514
798
|
.action(async (recipient, message, opts) => {
|
|
515
799
|
await runDmSend(recipient, message, opts);
|
|
516
800
|
});
|
|
801
|
+
dm
|
|
802
|
+
.command("inbox")
|
|
803
|
+
.description("List your recent incoming direct messages.")
|
|
804
|
+
.option("--limit <n>", "Max messages to fetch (server-capped)")
|
|
805
|
+
.option("--unread", "Show only unread messages")
|
|
806
|
+
.option("--mark-read", "Mark the fetched messages as read after listing")
|
|
807
|
+
.option("--json", "Output raw JSON instead of a list")
|
|
808
|
+
.action(async (opts) => {
|
|
809
|
+
await runDmInbox(opts);
|
|
810
|
+
});
|
|
811
|
+
dm
|
|
812
|
+
.command("thread <person>")
|
|
813
|
+
.alias("read")
|
|
814
|
+
.description("Show your two-way conversation with a person (email, personUid, or agentUid). Reads oldest-first and marks their messages read unless --no-ack.")
|
|
815
|
+
.option("--limit <n>", "Max messages to fetch (server-capped)")
|
|
816
|
+
.option("--no-ack", "Do not mark the incoming messages as read")
|
|
817
|
+
.option("--json", "Output raw JSON instead of a transcript")
|
|
818
|
+
.action(async (person, opts) => {
|
|
819
|
+
await runDmThread(person, opts);
|
|
820
|
+
});
|
|
821
|
+
dm
|
|
822
|
+
.command("channel <target>")
|
|
823
|
+
.alias("history")
|
|
824
|
+
.description("Show recent messages in a DM channel or group DM — by name, #name, or a channel id from `hq channels`.")
|
|
825
|
+
.option("--limit <n>", "Max messages to fetch (server-capped)")
|
|
826
|
+
.option("--mark-read", "Advance your read marker to the newest message")
|
|
827
|
+
.option("--json", "Output raw JSON instead of a transcript")
|
|
828
|
+
.action(async (target, opts) => {
|
|
829
|
+
await runDmChannel(target, opts);
|
|
830
|
+
});
|
|
517
831
|
dm
|
|
518
832
|
.command("requests")
|
|
519
833
|
.description("List your pending incoming connection requests.")
|
|
@@ -540,4 +854,4 @@ export function registerDmCommand(program) {
|
|
|
540
854
|
});
|
|
541
855
|
}
|
|
542
856
|
//# sourceMappingURL=dm.js.map
|
|
543
|
-
//# debugId=
|
|
857
|
+
//# debugId=8ba6c13e-f0d4-58c7-a62e-eede4fb7873f
|
package/package.json
CHANGED
package/src/commands/channels.ts
CHANGED
|
@@ -19,7 +19,10 @@ export function describeChannel(c: ChannelSummary): string {
|
|
|
19
19
|
names.length > 0
|
|
20
20
|
? names.join(", ")
|
|
21
21
|
: `${c.memberCount ?? "?"}-person group`;
|
|
22
|
-
|
|
22
|
+
// Group DMs are unnamed, so their channel id is the only way to address them
|
|
23
|
+
// for reading (`hq dm channel <id>`). Surface it so it can be copied.
|
|
24
|
+
const readHint = chalk.dim(`— hq dm channel ${c.channelId}`);
|
|
25
|
+
return `${who} ${chalk.dim("(group DM)")} ${readHint}`;
|
|
23
26
|
}
|
|
24
27
|
const slug = c.slug ?? (c.name ? channelSlug(c.name) : "");
|
|
25
28
|
const scopeTag = c.scope ? chalk.dim(`(${c.scope})`) : "";
|
package/src/commands/dm.test.ts
CHANGED
|
@@ -18,6 +18,7 @@ vi.mock("../utils/cognito-session.js", async (importOriginal) => {
|
|
|
18
18
|
});
|
|
19
19
|
|
|
20
20
|
import { Command } from "commander";
|
|
21
|
+
import chalk from "chalk";
|
|
21
22
|
import {
|
|
22
23
|
detectRecipient,
|
|
23
24
|
parseDuration,
|
|
@@ -29,8 +30,18 @@ import {
|
|
|
29
30
|
channelSlug,
|
|
30
31
|
parseChannelName,
|
|
31
32
|
matchChannelsByName,
|
|
33
|
+
senderLabel,
|
|
34
|
+
formatRelativeTime,
|
|
35
|
+
isUnread,
|
|
36
|
+
unreadEventIds,
|
|
37
|
+
filterUnread,
|
|
38
|
+
firstLine,
|
|
39
|
+
buildThreadQuery,
|
|
40
|
+
formatInboxEvent,
|
|
41
|
+
formatThreadMessage,
|
|
32
42
|
type ConnectionRequest,
|
|
33
43
|
type ChannelSummary,
|
|
44
|
+
type DmInboxEvent,
|
|
34
45
|
} from "./dm.js";
|
|
35
46
|
|
|
36
47
|
describe("parseGroupRecipients", () => {
|
|
@@ -262,6 +273,127 @@ describe("buildConnectionActionBody", () => {
|
|
|
262
273
|
// Action handlers (HTTP/auth mocked)
|
|
263
274
|
// ---------------------------------------------------------------------------
|
|
264
275
|
|
|
276
|
+
describe("senderLabel", () => {
|
|
277
|
+
it("prefers display name, then email, then uid", () => {
|
|
278
|
+
expect(
|
|
279
|
+
senderLabel({ fromDisplayName: "Jonathan", fromEmail: "j@x.com", fromPersonUid: "prs_1" }),
|
|
280
|
+
).toBe("Jonathan");
|
|
281
|
+
expect(senderLabel({ fromEmail: "j@x.com", fromPersonUid: "prs_1" })).toBe("j@x.com");
|
|
282
|
+
expect(senderLabel({ fromPersonUid: "prs_1" })).toBe("prs_1");
|
|
283
|
+
expect(senderLabel({})).toBe("unknown");
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
describe("formatRelativeTime", () => {
|
|
288
|
+
const now = Date.parse("2026-07-16T12:00:00.000Z");
|
|
289
|
+
it("renders sub-minute as 'just now'", () => {
|
|
290
|
+
expect(formatRelativeTime("2026-07-16T11:59:30.000Z", now)).toBe("just now");
|
|
291
|
+
});
|
|
292
|
+
it("renders minutes, hours, and days", () => {
|
|
293
|
+
expect(formatRelativeTime("2026-07-16T11:57:00.000Z", now)).toBe("3m ago");
|
|
294
|
+
expect(formatRelativeTime("2026-07-16T10:00:00.000Z", now)).toBe("2h ago");
|
|
295
|
+
expect(formatRelativeTime("2026-07-11T12:00:00.000Z", now)).toBe("5d ago");
|
|
296
|
+
});
|
|
297
|
+
it("falls back to a date for anything older than a week", () => {
|
|
298
|
+
expect(formatRelativeTime("2026-06-01T09:00:00.000Z", now)).toBe("2026-06-01");
|
|
299
|
+
});
|
|
300
|
+
it("echoes an unparseable input", () => {
|
|
301
|
+
expect(formatRelativeTime("not-a-date", now)).toBe("not-a-date");
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
describe("isUnread / unreadEventIds / filterUnread", () => {
|
|
306
|
+
const events: DmInboxEvent[] = [
|
|
307
|
+
{ eventId: "a", body: "x", createdAt: "2026-07-16T11:00:00.000Z" },
|
|
308
|
+
{
|
|
309
|
+
eventId: "b",
|
|
310
|
+
body: "y",
|
|
311
|
+
createdAt: "2026-07-16T11:30:00.000Z",
|
|
312
|
+
acknowledgedAt: "2026-07-16T11:31:00.000Z",
|
|
313
|
+
},
|
|
314
|
+
];
|
|
315
|
+
it("treats a message with no acknowledgedAt as unread", () => {
|
|
316
|
+
expect(isUnread(events[0])).toBe(true);
|
|
317
|
+
expect(isUnread(events[1])).toBe(false);
|
|
318
|
+
});
|
|
319
|
+
it("collects unread event ids", () => {
|
|
320
|
+
expect(unreadEventIds(events)).toEqual(["a"]);
|
|
321
|
+
});
|
|
322
|
+
it("filters to unread only", () => {
|
|
323
|
+
expect(filterUnread(events).map((e) => e.eventId)).toEqual(["a"]);
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
describe("firstLine", () => {
|
|
328
|
+
it("collapses whitespace to a single trimmed line", () => {
|
|
329
|
+
expect(firstLine(" hello\n world \t there ")).toBe("hello world there");
|
|
330
|
+
});
|
|
331
|
+
it("truncates with an ellipsis past the cap", () => {
|
|
332
|
+
const out = firstLine("abcdefghij", 5);
|
|
333
|
+
expect(out).toBe("abcd…");
|
|
334
|
+
expect(out.length).toBe(5);
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
describe("buildThreadQuery", () => {
|
|
339
|
+
it("routes an email through withEmail (lowercased)", () => {
|
|
340
|
+
expect(buildThreadQuery("Jonathan@GetIndigo.ai")).toEqual({
|
|
341
|
+
withEmail: "jonathan@getindigo.ai",
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
it("routes a personUid / agentUid through withPersonUid", () => {
|
|
345
|
+
expect(buildThreadQuery("prs_123")).toEqual({ withPersonUid: "prs_123" });
|
|
346
|
+
expect(buildThreadQuery("agt_123")).toEqual({ withPersonUid: "agt_123" });
|
|
347
|
+
});
|
|
348
|
+
it("rejects a bare name", () => {
|
|
349
|
+
expect(() => buildThreadQuery("Jonathan")).toThrow(/Resolve a name first/);
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
describe("format helpers (rendering)", () => {
|
|
354
|
+
beforeEach(() => {
|
|
355
|
+
chalk.level = 0; // plain text so assertions read the rendered content
|
|
356
|
+
});
|
|
357
|
+
const now = Date.parse("2026-07-16T12:00:00.000Z");
|
|
358
|
+
it("marks an unread inbox row with a bullet and shows the sender + body", () => {
|
|
359
|
+
const row = formatInboxEvent(
|
|
360
|
+
{
|
|
361
|
+
eventId: "e1",
|
|
362
|
+
fromDisplayName: "Jonathan Bach",
|
|
363
|
+
fromEmail: "jonathan@getindigo.ai",
|
|
364
|
+
body: "grp_jb_agent is created",
|
|
365
|
+
createdAt: "2026-07-16T11:57:00.000Z",
|
|
366
|
+
},
|
|
367
|
+
now,
|
|
368
|
+
);
|
|
369
|
+
expect(row).toContain("●");
|
|
370
|
+
expect(row).toContain("Jonathan Bach");
|
|
371
|
+
expect(row).toContain("<jonathan@getindigo.ai>");
|
|
372
|
+
expect(row).toContain("3m ago");
|
|
373
|
+
expect(row).toContain("grp_jb_agent is created");
|
|
374
|
+
});
|
|
375
|
+
it("tags thread direction: outgoing as 'you', incoming by sender", () => {
|
|
376
|
+
const out = formatThreadMessage(
|
|
377
|
+
{ eventId: "o", body: "hey", createdAt: "2026-07-16T11:00:00.000Z", direction: "out" },
|
|
378
|
+
now,
|
|
379
|
+
);
|
|
380
|
+
expect(out).toContain("→");
|
|
381
|
+
expect(out).toContain("you");
|
|
382
|
+
const inbound = formatThreadMessage(
|
|
383
|
+
{
|
|
384
|
+
eventId: "i",
|
|
385
|
+
fromDisplayName: "Jonathan",
|
|
386
|
+
body: "thanks",
|
|
387
|
+
createdAt: "2026-07-16T11:30:00.000Z",
|
|
388
|
+
direction: "in",
|
|
389
|
+
},
|
|
390
|
+
now,
|
|
391
|
+
);
|
|
392
|
+
expect(inbound).toContain("←");
|
|
393
|
+
expect(inbound).toContain("Jonathan");
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
265
397
|
function jsonResponse(status: number, body: unknown): Response {
|
|
266
398
|
return new Response(JSON.stringify(body), {
|
|
267
399
|
status,
|
|
@@ -480,4 +612,137 @@ describe("dm command actions", () => {
|
|
|
480
612
|
expect(body).toEqual({ withEmail: "spammer@example.com" });
|
|
481
613
|
expect(logged()).toMatch(/Blocked — spammer@example\.com\./);
|
|
482
614
|
});
|
|
615
|
+
|
|
616
|
+
// ---- receive side: inbox / thread / channel ----
|
|
617
|
+
|
|
618
|
+
it("inbox: lists incoming messages from GET /v1/notify/inbox", async () => {
|
|
619
|
+
fetchSpy.mockResolvedValueOnce(
|
|
620
|
+
jsonResponse(200, {
|
|
621
|
+
events: [
|
|
622
|
+
{
|
|
623
|
+
eventId: "e1",
|
|
624
|
+
fromDisplayName: "Jonathan Bach",
|
|
625
|
+
fromEmail: "jonathan@getindigo.ai",
|
|
626
|
+
body: "grp_jb_agent is created",
|
|
627
|
+
createdAt: "2026-07-16T11:57:00.000Z",
|
|
628
|
+
},
|
|
629
|
+
],
|
|
630
|
+
}),
|
|
631
|
+
);
|
|
632
|
+
await program.parseAsync(["dm", "inbox"], { from: "user" });
|
|
633
|
+
expect(String(fetchSpy.mock.calls[0][0])).toContain("/v1/notify/inbox");
|
|
634
|
+
expect(logged()).toContain("Jonathan Bach");
|
|
635
|
+
expect(logged()).toContain("grp_jb_agent is created");
|
|
636
|
+
expect(errSpy).not.toHaveBeenCalled();
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
it("inbox --unread --mark-read: acks only the unread ids", async () => {
|
|
640
|
+
fetchSpy
|
|
641
|
+
.mockResolvedValueOnce(
|
|
642
|
+
jsonResponse(200, {
|
|
643
|
+
events: [
|
|
644
|
+
{ eventId: "u1", body: "new", createdAt: "2026-07-16T11:00:00.000Z" },
|
|
645
|
+
{
|
|
646
|
+
eventId: "r1",
|
|
647
|
+
body: "old",
|
|
648
|
+
createdAt: "2026-07-16T10:00:00.000Z",
|
|
649
|
+
acknowledgedAt: "2026-07-16T10:01:00.000Z",
|
|
650
|
+
},
|
|
651
|
+
],
|
|
652
|
+
}),
|
|
653
|
+
)
|
|
654
|
+
.mockResolvedValueOnce(jsonResponse(200, { acknowledged: 1 }));
|
|
655
|
+
await program.parseAsync(["dm", "inbox", "--unread", "--mark-read"], {
|
|
656
|
+
from: "user",
|
|
657
|
+
});
|
|
658
|
+
const ackCall = fetchSpy.mock.calls.find((c) =>
|
|
659
|
+
String(c[0]).includes("/v1/notify/inbox/ack"),
|
|
660
|
+
);
|
|
661
|
+
expect(ackCall).toBeTruthy();
|
|
662
|
+
expect(JSON.parse((ackCall![1]?.body as string) ?? "{}")).toEqual({
|
|
663
|
+
eventIds: ["u1"],
|
|
664
|
+
});
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it("thread <email>: reads via withEmail and acks incoming messages", async () => {
|
|
668
|
+
fetchSpy
|
|
669
|
+
.mockResolvedValueOnce(
|
|
670
|
+
jsonResponse(200, {
|
|
671
|
+
messages: [
|
|
672
|
+
{
|
|
673
|
+
eventId: "in1",
|
|
674
|
+
fromDisplayName: "Jonathan",
|
|
675
|
+
body: "thanks!",
|
|
676
|
+
createdAt: "2026-07-16T11:30:00.000Z",
|
|
677
|
+
direction: "in",
|
|
678
|
+
},
|
|
679
|
+
{
|
|
680
|
+
eventId: "out1",
|
|
681
|
+
body: "done",
|
|
682
|
+
createdAt: "2026-07-16T11:00:00.000Z",
|
|
683
|
+
direction: "out",
|
|
684
|
+
},
|
|
685
|
+
],
|
|
686
|
+
}),
|
|
687
|
+
)
|
|
688
|
+
.mockResolvedValueOnce(jsonResponse(200, { acknowledged: 1 }));
|
|
689
|
+
await program.parseAsync(["dm", "thread", "jonathan@getindigo.ai"], {
|
|
690
|
+
from: "user",
|
|
691
|
+
});
|
|
692
|
+
expect(String(fetchSpy.mock.calls[0][0])).toContain("/v1/notify/thread");
|
|
693
|
+
expect(String(fetchSpy.mock.calls[0][0])).toContain("withEmail=");
|
|
694
|
+
const ackCall = fetchSpy.mock.calls.find((c) =>
|
|
695
|
+
String(c[0]).includes("/v1/notify/inbox/ack"),
|
|
696
|
+
);
|
|
697
|
+
expect(ackCall).toBeTruthy();
|
|
698
|
+
expect(JSON.parse((ackCall![1]?.body as string) ?? "{}")).toEqual({
|
|
699
|
+
eventIds: ["in1"],
|
|
700
|
+
});
|
|
701
|
+
expect(logged()).toContain("thanks!");
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it("thread --no-ack: reads without marking anything read", async () => {
|
|
705
|
+
fetchSpy.mockResolvedValueOnce(
|
|
706
|
+
jsonResponse(200, {
|
|
707
|
+
messages: [
|
|
708
|
+
{
|
|
709
|
+
eventId: "in1",
|
|
710
|
+
body: "hi",
|
|
711
|
+
createdAt: "2026-07-16T11:30:00.000Z",
|
|
712
|
+
direction: "in",
|
|
713
|
+
},
|
|
714
|
+
],
|
|
715
|
+
}),
|
|
716
|
+
);
|
|
717
|
+
await program.parseAsync(["dm", "thread", "prs_x", "--no-ack"], {
|
|
718
|
+
from: "user",
|
|
719
|
+
});
|
|
720
|
+
expect(
|
|
721
|
+
fetchSpy.mock.calls.some((c) => String(c[0]).includes("/inbox/ack")),
|
|
722
|
+
).toBe(false);
|
|
723
|
+
expect(String(fetchSpy.mock.calls[0][0])).toContain("withPersonUid=prs_x");
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
it("channel <name>: resolves the channel then reads its messages", async () => {
|
|
727
|
+
fetchSpy
|
|
728
|
+
.mockResolvedValueOnce(jsonResponse(200, channelsPayload))
|
|
729
|
+
.mockResolvedValueOnce(
|
|
730
|
+
jsonResponse(200, {
|
|
731
|
+
messages: [
|
|
732
|
+
{
|
|
733
|
+
eventId: "m1",
|
|
734
|
+
fromDisplayName: "Stefan",
|
|
735
|
+
body: "ship it",
|
|
736
|
+
createdAt: "2026-07-16T11:00:00.000Z",
|
|
737
|
+
},
|
|
738
|
+
],
|
|
739
|
+
}),
|
|
740
|
+
);
|
|
741
|
+
await program.parseAsync(["dm", "channel", "vyg-dev"], { from: "user" });
|
|
742
|
+
expect(String(fetchSpy.mock.calls[0][0])).toContain("/v1/notify/channels");
|
|
743
|
+
expect(String(fetchSpy.mock.calls[1][0])).toContain(
|
|
744
|
+
"/v1/notify/channels/chn_v/messages",
|
|
745
|
+
);
|
|
746
|
+
expect(logged()).toContain("ship it");
|
|
747
|
+
});
|
|
483
748
|
});
|
package/src/commands/dm.ts
CHANGED
|
@@ -702,11 +702,422 @@ async function runDmRequests(): Promise<void> {
|
|
|
702
702
|
}
|
|
703
703
|
}
|
|
704
704
|
|
|
705
|
+
// ---------------------------------------------------------------------------
|
|
706
|
+
// Receive side — reading DMs (inbox, 1:1 thread, channel/group history).
|
|
707
|
+
// The backend read endpoints (GET /v1/notify/inbox, GET /v1/notify/thread,
|
|
708
|
+
// GET /v1/notify/channels/{id}/messages) already back the HQ Sync menubar; the
|
|
709
|
+
// commands below expose the same reads from the CLI.
|
|
710
|
+
// ---------------------------------------------------------------------------
|
|
711
|
+
|
|
712
|
+
/** One incoming DM as returned by GET /v1/notify/inbox. */
|
|
713
|
+
export interface DmInboxEvent {
|
|
714
|
+
eventId: string;
|
|
715
|
+
fromPersonUid?: string;
|
|
716
|
+
fromEmail?: string;
|
|
717
|
+
fromDisplayName?: string;
|
|
718
|
+
body: string;
|
|
719
|
+
createdAt: string;
|
|
720
|
+
details?: string;
|
|
721
|
+
prompt?: string;
|
|
722
|
+
acknowledgedAt?: string;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** One message in a 1:1 thread as returned by GET /v1/notify/thread. */
|
|
726
|
+
export interface DmThreadMessage {
|
|
727
|
+
eventId: string;
|
|
728
|
+
fromPersonUid?: string;
|
|
729
|
+
fromEmail?: string;
|
|
730
|
+
fromDisplayName?: string;
|
|
731
|
+
body: string;
|
|
732
|
+
createdAt: string;
|
|
733
|
+
direction: "in" | "out";
|
|
734
|
+
details?: string;
|
|
735
|
+
prompt?: string;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** One channel/group message from GET /v1/notify/channels/{id}/messages. */
|
|
739
|
+
export interface ChannelMessageItem {
|
|
740
|
+
eventId?: string;
|
|
741
|
+
messageId?: string;
|
|
742
|
+
fromPersonUid?: string;
|
|
743
|
+
fromEmail?: string;
|
|
744
|
+
fromDisplayName?: string;
|
|
745
|
+
body: string;
|
|
746
|
+
createdAt: string;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Human label for a message sender — display name, else email, else uid. Pure →
|
|
751
|
+
* unit-testable.
|
|
752
|
+
*/
|
|
753
|
+
export function senderLabel(m: {
|
|
754
|
+
fromDisplayName?: string;
|
|
755
|
+
fromEmail?: string;
|
|
756
|
+
fromPersonUid?: string;
|
|
757
|
+
}): string {
|
|
758
|
+
return (
|
|
759
|
+
m.fromDisplayName?.trim() ||
|
|
760
|
+
m.fromEmail?.trim() ||
|
|
761
|
+
m.fromPersonUid?.trim() ||
|
|
762
|
+
"unknown"
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Render an ISO timestamp as a compact relative age ("3m ago", "2h ago",
|
|
768
|
+
* "5d ago"), falling back to a YYYY-MM-DD date for anything older than a week or
|
|
769
|
+
* an unparseable input. `nowMs` is injected so the formatting is unit-testable.
|
|
770
|
+
* Pure.
|
|
771
|
+
*/
|
|
772
|
+
export function formatRelativeTime(iso: string, nowMs: number): string {
|
|
773
|
+
const t = new Date(iso).getTime();
|
|
774
|
+
if (isNaN(t)) return iso;
|
|
775
|
+
const diff = nowMs - t;
|
|
776
|
+
if (diff < 60_000) return "just now";
|
|
777
|
+
const mins = Math.floor(diff / 60_000);
|
|
778
|
+
if (mins < 60) return `${mins}m ago`;
|
|
779
|
+
const hours = Math.floor(mins / 60);
|
|
780
|
+
if (hours < 24) return `${hours}h ago`;
|
|
781
|
+
const days = Math.floor(hours / 24);
|
|
782
|
+
if (days < 7) return `${days}d ago`;
|
|
783
|
+
return new Date(t).toISOString().slice(0, 10);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** A DM is unread until the recipient acks it. Pure. */
|
|
787
|
+
export function isUnread(e: { acknowledgedAt?: string }): boolean {
|
|
788
|
+
return !e.acknowledgedAt;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** Event ids of the unread messages in a fetched inbox page. Pure. */
|
|
792
|
+
export function unreadEventIds(events: DmInboxEvent[]): string[] {
|
|
793
|
+
return events
|
|
794
|
+
.filter(isUnread)
|
|
795
|
+
.map((e) => e.eventId)
|
|
796
|
+
.filter((id): id is string => typeof id === "string" && id.length > 0);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/** Keep only the unread messages. Pure. */
|
|
800
|
+
export function filterUnread(events: DmInboxEvent[]): DmInboxEvent[] {
|
|
801
|
+
return events.filter(isUnread);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Collapse a message body to a single trimmed line for list rendering, capped
|
|
806
|
+
* so one row stays readable. Pure.
|
|
807
|
+
*/
|
|
808
|
+
export function firstLine(body: string, max = 240): string {
|
|
809
|
+
const oneLine = (body ?? "").replace(/\s+/g, " ").trim();
|
|
810
|
+
if (oneLine.length <= max) return oneLine;
|
|
811
|
+
return oneLine.slice(0, max - 1) + "…";
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Turn a person identifier into the query the thread endpoint expects. An email
|
|
816
|
+
* rides `withEmail` (server resolves it); a prs_/agt_ uid rides `withPersonUid`.
|
|
817
|
+
* A bare name is rejected — resolve it first with `hq people resolve`. Pure →
|
|
818
|
+
* unit-testable. Throws with a user-facing message on an invalid identifier.
|
|
819
|
+
*/
|
|
820
|
+
export function buildThreadQuery(identifier: string): Record<string, string> {
|
|
821
|
+
const rcpt = detectRecipient(identifier);
|
|
822
|
+
if (!rcpt) {
|
|
823
|
+
throw new Error(
|
|
824
|
+
`Invalid person '${identifier}': pass an email or a personUid/agentUid (prs_… / agt_…). Resolve a name first with \`hq people resolve\`.`,
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
if (rcpt.toEmail) return { withEmail: rcpt.toEmail };
|
|
828
|
+
return { withPersonUid: rcpt.toPersonUid! };
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/** Render one inbox row (marker · age · sender · first line of body). */
|
|
832
|
+
export function formatInboxEvent(e: DmInboxEvent, nowMs: number): string {
|
|
833
|
+
const marker = isUnread(e) ? chalk.cyan("●") : " ";
|
|
834
|
+
const when = chalk.dim(formatRelativeTime(e.createdAt, nowMs));
|
|
835
|
+
const who = chalk.bold(senderLabel(e));
|
|
836
|
+
const email =
|
|
837
|
+
e.fromEmail && e.fromDisplayName ? chalk.dim(` <${e.fromEmail}>`) : "";
|
|
838
|
+
return `${marker} ${when} ${who}${email}\n ${firstLine(e.body)}`;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/** Render one 1:1 thread line, tagged by direction. */
|
|
842
|
+
export function formatThreadMessage(m: DmThreadMessage, nowMs: number): string {
|
|
843
|
+
const arrow = m.direction === "out" ? chalk.dim("→") : chalk.cyan("←");
|
|
844
|
+
const who = m.direction === "out" ? "you" : senderLabel(m);
|
|
845
|
+
const when = chalk.dim(formatRelativeTime(m.createdAt, nowMs));
|
|
846
|
+
return `${arrow} ${chalk.bold(who)} ${when}\n ${firstLine(m.body)}`;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/** Render one channel/group message line. */
|
|
850
|
+
export function formatChannelMessage(
|
|
851
|
+
m: ChannelMessageItem,
|
|
852
|
+
nowMs: number,
|
|
853
|
+
): string {
|
|
854
|
+
const who = chalk.bold(senderLabel(m));
|
|
855
|
+
const when = chalk.dim(formatRelativeTime(m.createdAt, nowMs));
|
|
856
|
+
return `${who} ${when}\n ${firstLine(m.body)}`;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/** POST /v1/notify/inbox/ack — idempotently mark messages read. */
|
|
860
|
+
async function ackEvents(token: string, eventIds: string[]): Promise<void> {
|
|
861
|
+
if (eventIds.length === 0) return;
|
|
862
|
+
const res = await vaultApiFetch({
|
|
863
|
+
token,
|
|
864
|
+
path: "/v1/notify/inbox/ack",
|
|
865
|
+
method: "POST",
|
|
866
|
+
body: { eventIds },
|
|
867
|
+
});
|
|
868
|
+
if (!res.ok) {
|
|
869
|
+
const err = (await res.json().catch(() => ({}))) as Record<string, string>;
|
|
870
|
+
throw new Error(
|
|
871
|
+
friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText),
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
interface DmInboxOpts {
|
|
877
|
+
limit?: string;
|
|
878
|
+
unread?: boolean;
|
|
879
|
+
markRead?: boolean;
|
|
880
|
+
json?: boolean;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
async function runDmInbox(opts: DmInboxOpts): Promise<void> {
|
|
884
|
+
try {
|
|
885
|
+
const token = await ensureCognitoToken();
|
|
886
|
+
const query: Record<string, string> = {};
|
|
887
|
+
if (opts.limit) query.limit = opts.limit;
|
|
888
|
+
const res = await vaultApiFetch({ token, path: "/v1/notify/inbox", query });
|
|
889
|
+
if (!res.ok) {
|
|
890
|
+
const err = (await res.json().catch(() => ({}))) as Record<string, string>;
|
|
891
|
+
console.error(
|
|
892
|
+
chalk.red(
|
|
893
|
+
friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText),
|
|
894
|
+
),
|
|
895
|
+
);
|
|
896
|
+
process.exit(1);
|
|
897
|
+
}
|
|
898
|
+
const data = (await res.json()) as {
|
|
899
|
+
events?: DmInboxEvent[];
|
|
900
|
+
nextCursor?: string;
|
|
901
|
+
};
|
|
902
|
+
const all = data.events ?? [];
|
|
903
|
+
const shown = opts.unread ? filterUnread(all) : all;
|
|
904
|
+
|
|
905
|
+
if (opts.json) {
|
|
906
|
+
console.log(JSON.stringify(shown, null, 2));
|
|
907
|
+
} else if (shown.length === 0) {
|
|
908
|
+
console.log(
|
|
909
|
+
chalk.dim(opts.unread ? "No unread messages." : "No messages yet."),
|
|
910
|
+
);
|
|
911
|
+
} else {
|
|
912
|
+
const unreadCount = filterUnread(all).length;
|
|
913
|
+
const suffix = unreadCount > 0 ? ` (${unreadCount} unread)` : "";
|
|
914
|
+
console.log(
|
|
915
|
+
chalk.green(
|
|
916
|
+
`${shown.length} message${shown.length === 1 ? "" : "s"}${suffix}:`,
|
|
917
|
+
),
|
|
918
|
+
);
|
|
919
|
+
const now = Date.now();
|
|
920
|
+
for (const e of shown) console.log(`\n${formatInboxEvent(e, now)}`);
|
|
921
|
+
if (data.nextCursor) {
|
|
922
|
+
console.log(
|
|
923
|
+
chalk.dim("\nMore messages available — raise --limit to see them."),
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// Ack every unread message on the fetched page (not just the filtered view).
|
|
929
|
+
if (opts.markRead) {
|
|
930
|
+
const ids = unreadEventIds(all);
|
|
931
|
+
await ackEvents(token, ids);
|
|
932
|
+
if (!opts.json && ids.length > 0) {
|
|
933
|
+
console.log(chalk.dim(`\nMarked ${ids.length} read.`));
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
} catch (err) {
|
|
937
|
+
console.error(
|
|
938
|
+
chalk.red("Error:"),
|
|
939
|
+
err instanceof Error ? err.message : String(err),
|
|
940
|
+
);
|
|
941
|
+
process.exit(1);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
interface DmThreadOpts {
|
|
946
|
+
limit?: string;
|
|
947
|
+
ack?: boolean;
|
|
948
|
+
json?: boolean;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
async function runDmThread(
|
|
952
|
+
identifier: string,
|
|
953
|
+
opts: DmThreadOpts,
|
|
954
|
+
): Promise<void> {
|
|
955
|
+
try {
|
|
956
|
+
const query = buildThreadQuery(identifier);
|
|
957
|
+
if (opts.limit) query.limit = opts.limit;
|
|
958
|
+
const token = await ensureCognitoToken();
|
|
959
|
+
const res = await vaultApiFetch({
|
|
960
|
+
token,
|
|
961
|
+
path: "/v1/notify/thread",
|
|
962
|
+
query,
|
|
963
|
+
});
|
|
964
|
+
if (!res.ok) {
|
|
965
|
+
const err = (await res.json().catch(() => ({}))) as Record<string, string>;
|
|
966
|
+
console.error(
|
|
967
|
+
chalk.red(
|
|
968
|
+
friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText),
|
|
969
|
+
),
|
|
970
|
+
);
|
|
971
|
+
process.exit(1);
|
|
972
|
+
}
|
|
973
|
+
const data = (await res.json()) as { messages?: DmThreadMessage[] };
|
|
974
|
+
const messages = data.messages ?? [];
|
|
975
|
+
// The server returns newest-first; read a conversation oldest-first.
|
|
976
|
+
const ordered = [...messages].reverse();
|
|
977
|
+
|
|
978
|
+
if (opts.json) {
|
|
979
|
+
console.log(JSON.stringify(ordered, null, 2));
|
|
980
|
+
} else if (ordered.length === 0) {
|
|
981
|
+
console.log(chalk.dim(`No messages with ${identifier} yet.`));
|
|
982
|
+
} else {
|
|
983
|
+
console.log(
|
|
984
|
+
chalk.green(
|
|
985
|
+
`${ordered.length} message${ordered.length === 1 ? "" : "s"} with ${identifier}:`,
|
|
986
|
+
),
|
|
987
|
+
);
|
|
988
|
+
const now = Date.now();
|
|
989
|
+
for (const m of ordered) console.log(`\n${formatThreadMessage(m, now)}`);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// Mark the incoming messages read unless the caller opted out. Best-effort:
|
|
993
|
+
// a read is a side effect, not the point of the command, so an ack failure
|
|
994
|
+
// is surfaced but does not fail the read.
|
|
995
|
+
if (opts.ack !== false) {
|
|
996
|
+
const inIds = messages
|
|
997
|
+
.filter((m) => m.direction === "in")
|
|
998
|
+
.map((m) => m.eventId)
|
|
999
|
+
.filter((id): id is string => typeof id === "string" && id.length > 0);
|
|
1000
|
+
try {
|
|
1001
|
+
await ackEvents(token, inIds);
|
|
1002
|
+
} catch (ackErr) {
|
|
1003
|
+
console.error(
|
|
1004
|
+
chalk.dim(
|
|
1005
|
+
`(could not mark read: ${
|
|
1006
|
+
ackErr instanceof Error ? ackErr.message : String(ackErr)
|
|
1007
|
+
})`,
|
|
1008
|
+
),
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
} catch (err) {
|
|
1013
|
+
console.error(
|
|
1014
|
+
chalk.red("Error:"),
|
|
1015
|
+
err instanceof Error ? err.message : String(err),
|
|
1016
|
+
);
|
|
1017
|
+
process.exit(1);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* Resolve a `hq dm channel <target>` argument to a channelId. Accepts a channel
|
|
1023
|
+
* name (bare or `#name`) resolved against the caller's channels, or a raw
|
|
1024
|
+
* channelId (the only way to address an unnamed group DM — copy it from
|
|
1025
|
+
* `hq channels`). Throws a user-facing message when nothing matches or a name is
|
|
1026
|
+
* ambiguous.
|
|
1027
|
+
*/
|
|
1028
|
+
async function resolveChannelId(token: string, target: string): Promise<string> {
|
|
1029
|
+
const raw = target.trim().replace(/^#/, "");
|
|
1030
|
+
if (!raw) throw new Error("A channel name or id is required.");
|
|
1031
|
+
const channels = await fetchChannels(token);
|
|
1032
|
+
const named = matchChannelsByName(channels, raw);
|
|
1033
|
+
if (named.length === 1) return named[0].channelId;
|
|
1034
|
+
if (named.length > 1) {
|
|
1035
|
+
throw new Error(
|
|
1036
|
+
`'${raw}' matches ${named.length} channels — pass the channel id instead (see \`hq channels\`).`,
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
const byId = channels.find((c) => c.channelId === raw);
|
|
1040
|
+
if (byId) return byId.channelId;
|
|
1041
|
+
throw new Error(
|
|
1042
|
+
`No channel named or with id '${raw}' — run \`hq channels\` to see yours.`,
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
interface DmChannelOpts {
|
|
1047
|
+
limit?: string;
|
|
1048
|
+
markRead?: boolean;
|
|
1049
|
+
json?: boolean;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
async function runDmChannel(
|
|
1053
|
+
target: string,
|
|
1054
|
+
opts: DmChannelOpts,
|
|
1055
|
+
): Promise<void> {
|
|
1056
|
+
try {
|
|
1057
|
+
const token = await ensureCognitoToken();
|
|
1058
|
+
const channelId = await resolveChannelId(token, target);
|
|
1059
|
+
const query: Record<string, string> = {};
|
|
1060
|
+
if (opts.limit) query.limit = opts.limit;
|
|
1061
|
+
const res = await vaultApiFetch({
|
|
1062
|
+
token,
|
|
1063
|
+
path: `/v1/notify/channels/${encodeURIComponent(channelId)}/messages`,
|
|
1064
|
+
query,
|
|
1065
|
+
});
|
|
1066
|
+
if (!res.ok) {
|
|
1067
|
+
const err = (await res.json().catch(() => ({}))) as Record<string, string>;
|
|
1068
|
+
console.error(
|
|
1069
|
+
chalk.red(
|
|
1070
|
+
friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText),
|
|
1071
|
+
),
|
|
1072
|
+
);
|
|
1073
|
+
process.exit(1);
|
|
1074
|
+
}
|
|
1075
|
+
const data = (await res.json()) as { messages?: ChannelMessageItem[] };
|
|
1076
|
+
const messages = data.messages ?? [];
|
|
1077
|
+
const ordered = [...messages].reverse(); // oldest-first for reading
|
|
1078
|
+
|
|
1079
|
+
if (opts.json) {
|
|
1080
|
+
console.log(JSON.stringify(ordered, null, 2));
|
|
1081
|
+
} else if (ordered.length === 0) {
|
|
1082
|
+
console.log(chalk.dim("No messages in this channel yet."));
|
|
1083
|
+
} else {
|
|
1084
|
+
console.log(
|
|
1085
|
+
chalk.green(
|
|
1086
|
+
`${ordered.length} message${ordered.length === 1 ? "" : "s"}:`,
|
|
1087
|
+
),
|
|
1088
|
+
);
|
|
1089
|
+
const now = Date.now();
|
|
1090
|
+
for (const m of ordered) console.log(`\n${formatChannelMessage(m, now)}`);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
if (opts.markRead) {
|
|
1094
|
+
// messages are newest-first from the server; advance the read cursor to
|
|
1095
|
+
// the newest one we saw.
|
|
1096
|
+
const newest = messages[0]?.createdAt;
|
|
1097
|
+
const readRes = await vaultApiFetch({
|
|
1098
|
+
token,
|
|
1099
|
+
path: `/v1/notify/channels/${encodeURIComponent(channelId)}/read`,
|
|
1100
|
+
method: "POST",
|
|
1101
|
+
body: newest ? { lastReadAt: newest } : {},
|
|
1102
|
+
});
|
|
1103
|
+
if (!opts.json && readRes.ok) {
|
|
1104
|
+
console.log(chalk.dim("\nMarked read."));
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
} catch (err) {
|
|
1108
|
+
console.error(
|
|
1109
|
+
chalk.red("Error:"),
|
|
1110
|
+
err instanceof Error ? err.message : String(err),
|
|
1111
|
+
);
|
|
1112
|
+
process.exit(1);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
705
1116
|
export function registerDmCommand(program: Command): void {
|
|
706
1117
|
const dm = program
|
|
707
1118
|
.command("dm")
|
|
708
1119
|
.description(
|
|
709
|
-
"Send
|
|
1120
|
+
"Send and read direct messages, and manage connection requests.",
|
|
710
1121
|
);
|
|
711
1122
|
|
|
712
1123
|
dm
|
|
@@ -746,6 +1157,43 @@ export function registerDmCommand(program: Command): void {
|
|
|
746
1157
|
},
|
|
747
1158
|
);
|
|
748
1159
|
|
|
1160
|
+
dm
|
|
1161
|
+
.command("inbox")
|
|
1162
|
+
.description("List your recent incoming direct messages.")
|
|
1163
|
+
.option("--limit <n>", "Max messages to fetch (server-capped)")
|
|
1164
|
+
.option("--unread", "Show only unread messages")
|
|
1165
|
+
.option("--mark-read", "Mark the fetched messages as read after listing")
|
|
1166
|
+
.option("--json", "Output raw JSON instead of a list")
|
|
1167
|
+
.action(async (opts: DmInboxOpts) => {
|
|
1168
|
+
await runDmInbox(opts);
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
dm
|
|
1172
|
+
.command("thread <person>")
|
|
1173
|
+
.alias("read")
|
|
1174
|
+
.description(
|
|
1175
|
+
"Show your two-way conversation with a person (email, personUid, or agentUid). Reads oldest-first and marks their messages read unless --no-ack.",
|
|
1176
|
+
)
|
|
1177
|
+
.option("--limit <n>", "Max messages to fetch (server-capped)")
|
|
1178
|
+
.option("--no-ack", "Do not mark the incoming messages as read")
|
|
1179
|
+
.option("--json", "Output raw JSON instead of a transcript")
|
|
1180
|
+
.action(async (person: string, opts: DmThreadOpts) => {
|
|
1181
|
+
await runDmThread(person, opts);
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
dm
|
|
1185
|
+
.command("channel <target>")
|
|
1186
|
+
.alias("history")
|
|
1187
|
+
.description(
|
|
1188
|
+
"Show recent messages in a DM channel or group DM — by name, #name, or a channel id from `hq channels`.",
|
|
1189
|
+
)
|
|
1190
|
+
.option("--limit <n>", "Max messages to fetch (server-capped)")
|
|
1191
|
+
.option("--mark-read", "Advance your read marker to the newest message")
|
|
1192
|
+
.option("--json", "Output raw JSON instead of a transcript")
|
|
1193
|
+
.action(async (target: string, opts: DmChannelOpts) => {
|
|
1194
|
+
await runDmChannel(target, opts);
|
|
1195
|
+
});
|
|
1196
|
+
|
|
749
1197
|
dm
|
|
750
1198
|
.command("requests")
|
|
751
1199
|
.description("List your pending incoming connection requests.")
|