@ardennguyen/zalo-agent-cli 2.5.4 → 2.5.6
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/.env.example +23 -1
- package/README.md +10 -6
- package/package.json +1 -1
- package/src/commands/fleet.js +439 -0
- package/src/commands/listen.js +87 -24
- package/src/commands/mcp-webhook.js +197 -0
- package/src/commands/mcp.js +162 -37
- package/src/commands/msg.js +13 -1
- package/src/core/accounts.js +182 -40
- package/src/core/credentials.js +5 -3
- package/src/core/daemon-channel.js +145 -19
- package/src/core/daemon-health.js +403 -0
- package/src/core/fleet-templates.js +177 -0
- package/src/core/fleet.js +255 -0
- package/src/core/log-sink.js +311 -0
- package/src/core/oa-client.js +10 -6
- package/src/core/startup-gate.js +206 -0
- package/src/core/webhook-outbox.js +575 -0
- package/src/index.js +13 -3
- package/src/mcp/mcp-config.js +42 -2
- package/src/mcp/mcp-http-transport.js +37 -18
- package/src/mcp/mcp-tools.js +82 -36
- package/src/mcp/message-buffer.js +94 -4
- package/src/mcp/wait-result.js +52 -0
- package/src/utils/atomic-file.js +209 -0
- package/src/utils/markdown.js +339 -0
package/src/commands/listen.js
CHANGED
|
@@ -3,18 +3,20 @@
|
|
|
3
3
|
* Production-ready with auto-reconnect and re-login.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
import { appendFileSync, mkdirSync, existsSync } from "fs";
|
|
6
|
+
import { appendFileSync, mkdirSync, existsSync, readFileSync } from "fs";
|
|
8
7
|
import { resolve, join } from "path";
|
|
9
8
|
import { getApi, clearSession } from "../core/zalo-client.js";
|
|
10
9
|
import { success, error, info, warning, output } from "../utils/output.js";
|
|
11
10
|
import { resolveWebhookSecret } from "../utils/secrets.js";
|
|
12
11
|
import { parseAccountId, selectDaemonAccount, loginDaemonAccount } from "../core/daemon-account.js";
|
|
13
12
|
import { updateAccountName } from "../core/accounts.js";
|
|
14
|
-
import { getAccountDir } from "../core/credentials.js";
|
|
13
|
+
import { CONFIG_DIR, getAccountDir } from "../core/credentials.js";
|
|
14
|
+
import { createStartupGate, resolveStartupSettings } from "../core/startup-gate.js";
|
|
15
15
|
import { acquireLock, releaseLock } from "../core/lock.js";
|
|
16
16
|
import {
|
|
17
17
|
createStageLock,
|
|
18
|
+
statusViaDaemon,
|
|
19
|
+
configureLogging,
|
|
18
20
|
startDaemonChannel,
|
|
19
21
|
getDaemonStatus,
|
|
20
22
|
stopDaemon,
|
|
@@ -43,7 +45,12 @@ import { downloadSyncedMedia } from "../core/sync-v2/media.js";
|
|
|
43
45
|
import { SyncManager } from "../core/sync.js";
|
|
44
46
|
import { describeGap } from "../core/sync-v2/gap-advice.js";
|
|
45
47
|
import { createDeliveredReceipts } from "../core/receipts.js";
|
|
46
|
-
import { envForAccount } from "../mcp/mcp-config.js";
|
|
48
|
+
import { envForAccount, resolveWebhookDelivery } from "../mcp/mcp-config.js";
|
|
49
|
+
import { startWebhookDelivery } from "../core/webhook-outbox.js";
|
|
50
|
+
import { createDaemonHealth, describeHealth } from "../core/daemon-health.js";
|
|
51
|
+
import { DEBUG_MARK, LOG_LEVELS } from "../core/log-sink.js";
|
|
52
|
+
|
|
53
|
+
const PKG_VERSION = JSON.parse(readFileSync(join(import.meta.dirname, "..", "..", "package.json"), "utf8")).version;
|
|
47
54
|
|
|
48
55
|
/** Thread types matching zca-js ThreadType enum */
|
|
49
56
|
const THREAD_USER = 0;
|
|
@@ -98,11 +105,19 @@ export function registerListenCommand(program) {
|
|
|
98
105
|
"--no-self-heal",
|
|
99
106
|
"Do not pull what a dropped socket missed from Zalo's offline queue on reconnect (the default does, as Zalo Web does, on this socket and with no phone tap)",
|
|
100
107
|
)
|
|
108
|
+
.option(
|
|
109
|
+
"--log-level <level>",
|
|
110
|
+
"Log level of daemon.log (and of stderr in the foreground): error, warn, info (default) or debug, which adds a line per HTTP request and per message. Env: ZALO_LOG_LEVEL[_<ownId>]; also ZALO_LOG_FORMAT=json, ZALO_LOG_MAX_SIZE (default 50m) and ZALO_LOG_KEEP (default 3) for rotation",
|
|
111
|
+
)
|
|
101
112
|
.option("-d, --daemon", "Run in the background (logs to <accountDir>/daemon.log)")
|
|
102
113
|
.option("--stop", "Stop the running background daemon for this account")
|
|
103
114
|
.option("--status", "Show background daemon status for this account")
|
|
104
115
|
.action(async (opts) => {
|
|
105
116
|
const jsonMode = program.opts().json;
|
|
117
|
+
if (opts.logLevel !== undefined && !LOG_LEVELS.includes(String(opts.logLevel).trim().toLowerCase())) {
|
|
118
|
+
error(`Invalid --log-level "${opts.logLevel}": use one of ${LOG_LEVELS.join(", ")}.`);
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
106
121
|
// An empty secret is refused before anything else: see resolveWebhookSecret().
|
|
107
122
|
let cliWebhookSecret;
|
|
108
123
|
try {
|
|
@@ -125,12 +140,18 @@ export function registerListenCommand(program) {
|
|
|
125
140
|
|
|
126
141
|
if (opts.status) {
|
|
127
142
|
const status = getDaemonStatus(accountDir);
|
|
128
|
-
|
|
143
|
+
// What the daemon says about itself; an older daemon has the channel but not the route.
|
|
144
|
+
const reported = status.running ? await statusViaDaemon(accountDir) : null;
|
|
145
|
+
const health = reported?.ok ? reported.health : null;
|
|
146
|
+
output({ accountId: activeAcc.ownId, ...status, health }, jsonMode, () => {
|
|
129
147
|
if (status.running) {
|
|
130
148
|
success(
|
|
131
149
|
`Daemon running for account ${activeAcc.ownId} (PID ${status.pid}${status.channelPort ? `, channel 127.0.0.1:${status.channelPort}` : ""})`,
|
|
132
150
|
);
|
|
133
151
|
info(`Log file: ${status.logPath}`);
|
|
152
|
+
if (health) for (const line of describeHealth(health)) info(line);
|
|
153
|
+
else
|
|
154
|
+
info("Health: not reported (this daemon predates the health route; restart it to see it).");
|
|
134
155
|
} else {
|
|
135
156
|
info(`No daemon running for account ${activeAcc.ownId}`);
|
|
136
157
|
info(`Log file: ${status.logPath}`);
|
|
@@ -153,6 +174,16 @@ export function registerListenCommand(program) {
|
|
|
153
174
|
return;
|
|
154
175
|
}
|
|
155
176
|
|
|
177
|
+
// Levels and rotation of the log, now that the account is known (src/core/log-sink.js).
|
|
178
|
+
const logging = configureLogging({
|
|
179
|
+
ownId: activeAcc.ownId,
|
|
180
|
+
levelFlag: opts.logLevel?.trim().toLowerCase() || null,
|
|
181
|
+
// The process that launches a background daemon talks to the user; only the daemon's own output is a log.
|
|
182
|
+
filterStderr: !(opts.daemon && !process.env.ZALO_DAEMON_CHILD),
|
|
183
|
+
});
|
|
184
|
+
if (!process.env.ZALO_DAEMON_CHILD)
|
|
185
|
+
for (const w of logging.warnings) console.error(`[listen] [logging] ${w}`);
|
|
186
|
+
|
|
156
187
|
if (opts.daemon && !process.env.ZALO_DAEMON_CHILD) {
|
|
157
188
|
const spawned = await spawnDaemonProcess({ accountDir, args: process.argv.slice(1) });
|
|
158
189
|
if (!spawned.ok) {
|
|
@@ -205,9 +236,21 @@ export function registerListenCommand(program) {
|
|
|
205
236
|
console.error(`[listen] Failed to release lock: ${e.message}`);
|
|
206
237
|
}
|
|
207
238
|
};
|
|
239
|
+
// Spread a fleet's start-up: a random delay and a cap on daemons logging in at once
|
|
240
|
+
// (ZALO_START_JITTER, ZALO_LOGIN_SLOTS; src/core/startup-gate.js). Both off by default.
|
|
241
|
+
const startup = resolveStartupSettings({ env: process.env, ownId: activeAcc.ownId });
|
|
242
|
+
for (const w of startup.warnings) console.error(`[listen] ${w}`);
|
|
243
|
+
const gate = createStartupGate({
|
|
244
|
+
configDir: CONFIG_DIR,
|
|
245
|
+
ownId: activeAcc.ownId,
|
|
246
|
+
settings: startup,
|
|
247
|
+
log: (m) => console.error(`[listen] ${m}`),
|
|
248
|
+
});
|
|
249
|
+
await gate.enter();
|
|
208
250
|
try {
|
|
209
251
|
await loginDaemonAccount(activeAcc);
|
|
210
252
|
} catch (e) {
|
|
253
|
+
gate.leave();
|
|
211
254
|
releaseLock(accountDir);
|
|
212
255
|
error(`Pinned account login failed: ${e.message}`);
|
|
213
256
|
process.exit(1);
|
|
@@ -215,7 +258,10 @@ export function registerListenCommand(program) {
|
|
|
215
258
|
try {
|
|
216
259
|
initDb(join(accountDir, "zalo.db"));
|
|
217
260
|
info(`Local database initialized at ${accountDir}/zalo.db`);
|
|
261
|
+
// Logged in and the cache open: the heavy part of start-up is over.
|
|
262
|
+
gate.leave();
|
|
218
263
|
} catch (err) {
|
|
264
|
+
gate.leave();
|
|
219
265
|
releaseLock(accountDir);
|
|
220
266
|
error(`Failed to initialize local DB: ${err.message}`);
|
|
221
267
|
process.exit(1);
|
|
@@ -408,27 +454,32 @@ export function registerListenCommand(program) {
|
|
|
408
454
|
warning("A webhook secret is set but no webhook URL: nothing will be signed or sent. Pass -w <url>.");
|
|
409
455
|
}
|
|
410
456
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
const
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
signal: AbortSignal.timeout(5000),
|
|
427
|
-
}).catch((e) => {
|
|
428
|
-
console.error(`[listen] Webhook failed: ${e.message}`);
|
|
457
|
+
// The same durable outbox `mcp start` uses (src/core/webhook-outbox.js): an event is
|
|
458
|
+
// written to disk first and delivered from there, so a receiver that is down costs a
|
|
459
|
+
// backlog (zalo-agent mcp webhook status), not events.
|
|
460
|
+
let webhookDelivery = null;
|
|
461
|
+
if (webhookUrl) {
|
|
462
|
+
const delivery = resolveWebhookDelivery(activeAcc.ownId);
|
|
463
|
+
for (const w of delivery.warnings) warning(w);
|
|
464
|
+
webhookDelivery = startWebhookDelivery({
|
|
465
|
+
accountDir,
|
|
466
|
+
accountId: activeAcc.ownId,
|
|
467
|
+
url: webhookUrl,
|
|
468
|
+
secret: webhookSecret,
|
|
469
|
+
batchSize: delivery.batchSize,
|
|
470
|
+
maxAgeMs: delivery.maxAgeMs,
|
|
471
|
+
log: (line) => console.error(`[listen] ${line}`),
|
|
429
472
|
});
|
|
430
473
|
}
|
|
431
474
|
|
|
475
|
+
/** Queue an event for the webhook — never blocks event processing */
|
|
476
|
+
function postWebhook(data) {
|
|
477
|
+
// Only a message has an id that stays the same on a repeat (live, then catch-up);
|
|
478
|
+
// two reactions to one message must both be delivered.
|
|
479
|
+
const key = data.event === "message" && data.msgId ? `message:${data.msgId}` : null;
|
|
480
|
+
webhookDelivery?.enqueue(key, { accountId: activeAcc.ownId, ...data });
|
|
481
|
+
}
|
|
482
|
+
|
|
432
483
|
// Setup save directory if --save flag provided
|
|
433
484
|
let saveDir = null;
|
|
434
485
|
if (opts.save) {
|
|
@@ -453,7 +504,10 @@ export function registerListenCommand(program) {
|
|
|
453
504
|
/** Output event as JSON or human-readable, save locally, then post to webhook */
|
|
454
505
|
function emitEvent(data, humanMsg) {
|
|
455
506
|
eventCount++;
|
|
456
|
-
if (
|
|
507
|
+
if (process.env.ZALO_DAEMON_CHILD === "1") {
|
|
508
|
+
// In daemon.log an event line is a per-message line: debug (it carries the message text).
|
|
509
|
+
console.error(`${DEBUG_MARK}${jsonMode ? JSON.stringify(data) : humanMsg}`);
|
|
510
|
+
} else if (jsonMode) {
|
|
457
511
|
console.log(JSON.stringify(data));
|
|
458
512
|
} else {
|
|
459
513
|
info(humanMsg);
|
|
@@ -534,6 +588,11 @@ export function registerListenCommand(program) {
|
|
|
534
588
|
});
|
|
535
589
|
const socketContext = createSocketContext({ lock: stageLock, monitor: loopMonitor });
|
|
536
590
|
setUserInfoLogger(daemonLog);
|
|
591
|
+
// Same health object `mcp start` builds (src/core/daemon-health.js): `listen --status` prints it.
|
|
592
|
+
const health = createDaemonHealth({ kind: "listen", accountId: activeAcc.ownId, version: PKG_VERSION });
|
|
593
|
+
health.setSource("gaps", () => getPendingSyncGaps().length);
|
|
594
|
+
health.setSource("stage", () => stageLock.current());
|
|
595
|
+
health.setSource("webhook", () => webhookDelivery?.status() ?? null);
|
|
537
596
|
const socketTap = createSocketTap({ log: (line) => console.error(`[listen] ${line}`) });
|
|
538
597
|
// Read state other devices report -- a conversation read on the
|
|
539
598
|
// phone, an unread mark set or cleared there -- which zca-js
|
|
@@ -567,6 +626,7 @@ export function registerListenCommand(program) {
|
|
|
567
626
|
|
|
568
627
|
/** Attach ALL handlers (data + lifecycle) to current API listener */
|
|
569
628
|
function attachAllHandlers(api) {
|
|
629
|
+
health.attach(api.listener);
|
|
570
630
|
// --- Message events ---
|
|
571
631
|
// Registered unconditionally. --events, --filter and --no-self
|
|
572
632
|
// decide what is PRINTED, forwarded and saved as JSONL -- never
|
|
@@ -952,7 +1012,9 @@ export function registerListenCommand(program) {
|
|
|
952
1012
|
const shutdown = () => {
|
|
953
1013
|
// First, before anything can emit a close event.
|
|
954
1014
|
stopping = true;
|
|
1015
|
+
health.noteStopping();
|
|
955
1016
|
channel?.stop();
|
|
1017
|
+
webhookDelivery?.stop();
|
|
956
1018
|
deliveredReceipts.stop();
|
|
957
1019
|
try {
|
|
958
1020
|
getApi().listener.stop();
|
|
@@ -998,6 +1060,7 @@ export function registerListenCommand(program) {
|
|
|
998
1060
|
// The self-heal's lock: a stage and a catch-up never share the socket.
|
|
999
1061
|
lock: stageLock,
|
|
1000
1062
|
onStop: shutdown,
|
|
1063
|
+
onStatus: () => health.snapshot(),
|
|
1001
1064
|
});
|
|
1002
1065
|
info(`Sync & upload channel ready on 127.0.0.1:${channel.port} — this socket is reused for both.`);
|
|
1003
1066
|
info("`zalo-agent sync` will run its socket stages here; it still needs your phone tap.");
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `zalo-agent mcp webhook ...` — look at, and act on, the durable webhook outbox
|
|
3
|
+
* (src/core/webhook-outbox.js) of an account: how many events wait for the receiver, which were
|
|
4
|
+
* dead-lettered and why. These read and write `accounts/<ownId>/webhook-outbox.db` directly; SQLite
|
|
5
|
+
* lets them run beside the daemon that is delivering from it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { parseAccountId, selectDaemonAccount } from "../core/daemon-account.js";
|
|
10
|
+
import { getAccountDir } from "../core/credentials.js";
|
|
11
|
+
import { getDaemonStatus } from "../core/daemon-channel.js";
|
|
12
|
+
import { openOutbox, outboxPath } from "../core/webhook-outbox.js";
|
|
13
|
+
import { error, info, output, success } from "../utils/output.js";
|
|
14
|
+
|
|
15
|
+
const iso = (ms) => (ms ? new Date(ms).toISOString() : null);
|
|
16
|
+
|
|
17
|
+
/** @param {number} ms */
|
|
18
|
+
function age(ms) {
|
|
19
|
+
const s = Math.round(ms / 1000);
|
|
20
|
+
if (s < 90) return `${s}s`;
|
|
21
|
+
if (s < 5400) return `${Math.round(s / 60)}m`;
|
|
22
|
+
return `${(s / 3600).toFixed(1)}h`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {object} program - Commander root
|
|
27
|
+
* @param {object} opts - The subcommand's options
|
|
28
|
+
* @returns {{ ownId: string, accountDir: string }}
|
|
29
|
+
*/
|
|
30
|
+
function pickAccount(program, opts) {
|
|
31
|
+
try {
|
|
32
|
+
const requested = opts.account ?? program.opts().account ?? (process.env.ZALO_ACCOUNT?.trim() || undefined);
|
|
33
|
+
const acc = selectDaemonAccount(requested);
|
|
34
|
+
return { ownId: acc.ownId, accountDir: getAccountDir(acc.ownId) };
|
|
35
|
+
} catch (e) {
|
|
36
|
+
error(`Account selection failed: ${e.message}`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Open the outbox, or say plainly that this account has none yet.
|
|
43
|
+
* @param {string} accountDir
|
|
44
|
+
* @param {boolean} readonly
|
|
45
|
+
* @returns {object|null}
|
|
46
|
+
*/
|
|
47
|
+
function openOrNone(accountDir, readonly) {
|
|
48
|
+
if (!existsSync(outboxPath(accountDir))) return null;
|
|
49
|
+
try {
|
|
50
|
+
return openOutbox(accountDir, { readonly });
|
|
51
|
+
} catch (e) {
|
|
52
|
+
error(`Could not open the webhook outbox: ${e.message}`);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const NO_OUTBOX =
|
|
58
|
+
"This account has no webhook outbox: no event has been queued for a webhook yet (-w / ZALO_WEBHOOK_URL).";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {object} mcp - The `mcp` Commander command
|
|
62
|
+
* @param {object} program - Commander root, for the global --json / --account
|
|
63
|
+
*/
|
|
64
|
+
export function registerMCPWebhookCommands(mcp, program) {
|
|
65
|
+
const webhook = mcp
|
|
66
|
+
.command("webhook")
|
|
67
|
+
.description("Inspect and manage the durable webhook outbox: events waiting for the receiver and dead letters");
|
|
68
|
+
|
|
69
|
+
webhook
|
|
70
|
+
.command("status")
|
|
71
|
+
.description("Backlog of the webhook outbox: queued, dead-lettered, oldest wait, last success and error")
|
|
72
|
+
.option("--account <id>", "Target a specific registered account ID (default: active account)", parseAccountId)
|
|
73
|
+
.action((opts) => {
|
|
74
|
+
const { ownId, accountDir } = pickAccount(program, opts);
|
|
75
|
+
const daemon = getDaemonStatus(accountDir);
|
|
76
|
+
const outbox = openOrNone(accountDir, true);
|
|
77
|
+
const stats = outbox?.stats() ?? null;
|
|
78
|
+
outbox?.close();
|
|
79
|
+
const result = {
|
|
80
|
+
accountId: ownId,
|
|
81
|
+
daemon: { running: daemon.running, pid: daemon.pid },
|
|
82
|
+
outbox: stats && {
|
|
83
|
+
path: outboxPath(accountDir),
|
|
84
|
+
pending: stats.pending,
|
|
85
|
+
dead: stats.dead,
|
|
86
|
+
oldestPendingAt: iso(stats.oldestPendingAt),
|
|
87
|
+
oldestPendingAgeMs: stats.oldestPendingAt ? Math.max(0, Date.now() - stats.oldestPendingAt) : 0,
|
|
88
|
+
deliveredTotal: stats.deliveredTotal,
|
|
89
|
+
lastSuccessAt: iso(stats.lastSuccessAt),
|
|
90
|
+
lastError: stats.lastError,
|
|
91
|
+
lastErrorAt: iso(stats.lastErrorAt),
|
|
92
|
+
lastStatus: stats.lastStatus,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
output(result, program.opts().json, () => {
|
|
96
|
+
if (!stats) return info(NO_OUTBOX);
|
|
97
|
+
const o = result.outbox;
|
|
98
|
+
info(`Account ${ownId} daemon: ${daemon.running ? `running (pid ${daemon.pid})` : "not running"}`);
|
|
99
|
+
info(`Queued: ${o.pending}${o.pending ? ` (oldest waiting ${age(o.oldestPendingAgeMs)})` : ""}`);
|
|
100
|
+
info(`Dead-lettered: ${o.dead}${o.dead ? " (zalo-agent mcp webhook list --dead)" : ""}`);
|
|
101
|
+
info(`Delivered since the outbox was created: ${o.deliveredTotal}`);
|
|
102
|
+
info(`Last delivery: ${o.lastSuccessAt ?? "never"}`);
|
|
103
|
+
if (o.lastError) info(`Last error: ${o.lastError}${o.lastErrorAt ? ` at ${o.lastErrorAt}` : ""}`);
|
|
104
|
+
if (o.pending && !daemon.running)
|
|
105
|
+
info("Queued events are sent when `mcp start` / `listen` runs again.");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
webhook
|
|
110
|
+
.command("list")
|
|
111
|
+
.description("List queued events, or the dead letters, oldest first")
|
|
112
|
+
.option("--account <id>", "Target a specific registered account ID (default: active account)", parseAccountId)
|
|
113
|
+
.option("--dead", "Only dead-lettered events")
|
|
114
|
+
.option("--pending", "Only events still queued")
|
|
115
|
+
.option("-n, --limit <n>", "Maximum rows (default: 50)", "50")
|
|
116
|
+
.action((opts) => {
|
|
117
|
+
const limit = Number(opts.limit);
|
|
118
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 1000) {
|
|
119
|
+
error(`Invalid --limit "${opts.limit}": must be an integer between 1 and 1000.`);
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
if (opts.dead && opts.pending) {
|
|
123
|
+
error("Pass --dead or --pending, not both.");
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
const { ownId, accountDir } = pickAccount(program, opts);
|
|
127
|
+
const outbox = openOrNone(accountDir, true);
|
|
128
|
+
const rows = (
|
|
129
|
+
outbox?.list({ status: opts.dead ? "dead" : opts.pending ? "pending" : undefined, limit }) ?? []
|
|
130
|
+
).map((r) => {
|
|
131
|
+
let event = {};
|
|
132
|
+
try {
|
|
133
|
+
event = JSON.parse(r.body);
|
|
134
|
+
} catch {
|
|
135
|
+
/* a row the receiver gets as-is; shown without fields */
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
seq: r.seq,
|
|
139
|
+
status: r.status,
|
|
140
|
+
attempts: r.attempts,
|
|
141
|
+
queuedAt: iso(r.created_at),
|
|
142
|
+
lastAttemptAt: iso(r.last_attempt_at),
|
|
143
|
+
lastError: r.last_error,
|
|
144
|
+
event: event.event ?? null,
|
|
145
|
+
id: event.id ?? event.msgId ?? null,
|
|
146
|
+
threadId: event.threadId ?? null,
|
|
147
|
+
};
|
|
148
|
+
});
|
|
149
|
+
outbox?.close();
|
|
150
|
+
output({ accountId: ownId, count: rows.length, events: rows }, program.opts().json, () => {
|
|
151
|
+
if (!outbox) return info(NO_OUTBOX);
|
|
152
|
+
if (!rows.length) return info("Nothing queued.");
|
|
153
|
+
for (const r of rows) {
|
|
154
|
+
info(
|
|
155
|
+
`#${r.seq} ${r.status} attempts=${r.attempts} ${r.event ?? "?"} id=${r.id ?? "-"} thread=${r.threadId ?? "-"} queued ${r.queuedAt}${r.lastError ? ` — ${r.lastError}` : ""}`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
for (const [name, verb, description] of [
|
|
162
|
+
[
|
|
163
|
+
"retry",
|
|
164
|
+
"requeued",
|
|
165
|
+
"Put dead-lettered events back in the queue (all, or one by number); the daemon sends them within 30 seconds",
|
|
166
|
+
],
|
|
167
|
+
["purge", "deleted", "Delete dead-lettered events for good (all, or one by number)"],
|
|
168
|
+
]) {
|
|
169
|
+
webhook
|
|
170
|
+
.command(`${name} [seq]`)
|
|
171
|
+
.description(description)
|
|
172
|
+
.option(
|
|
173
|
+
"--account <id>",
|
|
174
|
+
"Target a specific registered account ID (default: active account)",
|
|
175
|
+
parseAccountId,
|
|
176
|
+
)
|
|
177
|
+
.action((seqArg, opts) => {
|
|
178
|
+
let seq;
|
|
179
|
+
if (seqArg !== undefined) {
|
|
180
|
+
seq = Number(seqArg);
|
|
181
|
+
if (!Number.isInteger(seq) || seq < 1) {
|
|
182
|
+
error(`Invalid event number "${seqArg}": use the # from \`mcp webhook list --dead\`.`);
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const { ownId, accountDir } = pickAccount(program, opts);
|
|
187
|
+
const outbox = openOrNone(accountDir, false);
|
|
188
|
+
const n = !outbox ? 0 : name === "retry" ? outbox.retryDead({ seq }) : outbox.purgeDead({ seq });
|
|
189
|
+
outbox?.close();
|
|
190
|
+
output({ accountId: ownId, [verb]: n }, program.opts().json, () => {
|
|
191
|
+
if (!outbox) info(NO_OUTBOX);
|
|
192
|
+
else if (n) success(`${n} dead-lettered event(s) ${verb}.`);
|
|
193
|
+
else info(seq ? `No dead-lettered event #${seq}.` : "No dead-lettered events.");
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|