@ardennguyen/zalo-agent-cli 2.5.5 → 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 +9 -0
- 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/mcp.js
CHANGED
|
@@ -12,7 +12,8 @@ import { getApi, clearSession } from "../core/zalo-client.js";
|
|
|
12
12
|
import { liveApi } from "../core/live-api.js";
|
|
13
13
|
import { parseAccountId, selectDaemonAccount, loginDaemonAccount } from "../core/daemon-account.js";
|
|
14
14
|
import { updateAccountName } from "../core/accounts.js";
|
|
15
|
-
import { getAccountDir, MEDIA_DIR } from "../core/credentials.js";
|
|
15
|
+
import { CONFIG_DIR, getAccountDir, MEDIA_DIR } from "../core/credentials.js";
|
|
16
|
+
import { createStartupGate, resolveStartupSettings } from "../core/startup-gate.js";
|
|
16
17
|
import { acquireLock, releaseLock } from "../core/lock.js";
|
|
17
18
|
import {
|
|
18
19
|
createStageLock,
|
|
@@ -22,6 +23,8 @@ import {
|
|
|
22
23
|
spawnDaemonProcess,
|
|
23
24
|
waitViaDaemon,
|
|
24
25
|
markReadViaDaemon,
|
|
26
|
+
statusViaDaemon,
|
|
27
|
+
configureLogging,
|
|
25
28
|
} from "../core/daemon-channel.js";
|
|
26
29
|
import { success, error, info, output } from "../utils/output.js";
|
|
27
30
|
import { resolveWebhookSecret } from "../utils/secrets.js";
|
|
@@ -50,13 +53,26 @@ import { classifyLiveMessage } from "../core/sync-v2/message-types.js";
|
|
|
50
53
|
import { createDeliveredReceipts } from "../core/receipts.js";
|
|
51
54
|
import { MessageBuffer } from "../mcp/message-buffer.js";
|
|
52
55
|
import { ThreadFilter } from "../mcp/thread-filter.js";
|
|
53
|
-
import {
|
|
56
|
+
import {
|
|
57
|
+
readMCPConfig,
|
|
58
|
+
resolveAccountMCPConfig,
|
|
59
|
+
envForAccount,
|
|
60
|
+
parseDuration,
|
|
61
|
+
resolveWebhookDelivery,
|
|
62
|
+
} from "../mcp/mcp-config.js";
|
|
63
|
+
import { startWebhookDelivery } from "../core/webhook-outbox.js";
|
|
64
|
+
import { batchParams, waitResult, MAX_BATCH, MAX_SETTLE_MS } from "../mcp/wait-result.js";
|
|
65
|
+
import { createDaemonHealth, describeHealth } from "../core/daemon-health.js";
|
|
66
|
+
import { DEBUG_MARK, LOG_LEVELS } from "../core/log-sink.js";
|
|
67
|
+
import { registerMCPWebhookCommands } from "./mcp-webhook.js";
|
|
54
68
|
import { createMCPServer } from "../mcp/mcp-server.js";
|
|
55
69
|
import { registerTools } from "../mcp/mcp-tools.js";
|
|
56
70
|
import { createHTTPServer } from "../mcp/mcp-http-transport.js";
|
|
57
71
|
import { ZaloNotifier } from "../mcp/notifier.js";
|
|
58
72
|
import { ThreadNameCache } from "../mcp/thread-name-cache.js";
|
|
59
73
|
|
|
74
|
+
const PKG_VERSION = JSON.parse(readFileSync(join(import.meta.dirname, "..", "..", "package.json"), "utf8")).version;
|
|
75
|
+
|
|
60
76
|
/** Zalo close code for duplicate web session — fatal, do not retry */
|
|
61
77
|
const CLOSE_DUPLICATE = 3000;
|
|
62
78
|
|
|
@@ -106,6 +122,17 @@ export function normalizeMessage(msg, info = null) {
|
|
|
106
122
|
};
|
|
107
123
|
}
|
|
108
124
|
|
|
125
|
+
/**
|
|
126
|
+
* The JSON a webhook receiver gets for one buffered message.
|
|
127
|
+
*
|
|
128
|
+
* @param {object} message - Normalized message object
|
|
129
|
+
* @param {object} [extra] - Additional top-level fields (e.g. `{ accountId }`)
|
|
130
|
+
* @returns {object}
|
|
131
|
+
*/
|
|
132
|
+
export function buildMCPWebhookPayload(message, extra = {}) {
|
|
133
|
+
return { event: "message", ...extra, ...message };
|
|
134
|
+
}
|
|
135
|
+
|
|
109
136
|
/**
|
|
110
137
|
* Fire-and-forget HTTP POST for an incoming buffered MCP message when a
|
|
111
138
|
* webhook URL is configured (`-w, --webhook <url>` or `webhook` in
|
|
@@ -120,12 +147,7 @@ export function normalizeMessage(msg, info = null) {
|
|
|
120
147
|
*/
|
|
121
148
|
export async function postMCPWebhook(webhookUrl, message, extra = {}, fetchFn = fetch, webhookSecret = null) {
|
|
122
149
|
if (!webhookUrl) return false;
|
|
123
|
-
const
|
|
124
|
-
event: "message",
|
|
125
|
-
...extra,
|
|
126
|
-
...message,
|
|
127
|
-
};
|
|
128
|
-
const body = JSON.stringify(payload);
|
|
150
|
+
const body = JSON.stringify(buildMCPWebhookPayload(message, extra));
|
|
129
151
|
const headers = { "Content-Type": "application/json" };
|
|
130
152
|
if (typeof webhookSecret === "string" && webhookSecret.trim()) {
|
|
131
153
|
const secret = webhookSecret.trim();
|
|
@@ -228,11 +250,19 @@ export function registerMCPCommands(program) {
|
|
|
228
250
|
"--no-self-heal",
|
|
229
251
|
"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)",
|
|
230
252
|
)
|
|
253
|
+
.option(
|
|
254
|
+
"--log-level <level>",
|
|
255
|
+
"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",
|
|
256
|
+
)
|
|
231
257
|
.option("-d, --daemon", "Run in the background (requires --http <port>; logs to <accountDir>/daemon.log)")
|
|
232
258
|
.option("--stop", "Stop the running background daemon for this account")
|
|
233
259
|
.option("--status", "Show background daemon status for this account")
|
|
234
260
|
.action(async (opts) => {
|
|
235
261
|
const jsonMode = program.opts().json;
|
|
262
|
+
if (opts.logLevel !== undefined && !LOG_LEVELS.includes(String(opts.logLevel).trim().toLowerCase())) {
|
|
263
|
+
error(`Invalid --log-level "${opts.logLevel}": use one of ${LOG_LEVELS.join(", ")}.`);
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
236
266
|
const isDaemonCli = Boolean(opts.status || opts.stop || (opts.daemon && !process.env.ZALO_DAEMON_CHILD));
|
|
237
267
|
const failStart = (msg) => {
|
|
238
268
|
if (isDaemonCli) error(msg);
|
|
@@ -295,12 +325,18 @@ export function registerMCPCommands(program) {
|
|
|
295
325
|
|
|
296
326
|
if (opts.status) {
|
|
297
327
|
const status = getDaemonStatus(accountDir);
|
|
298
|
-
|
|
328
|
+
// What the daemon says about itself; an older daemon has the channel but not the route.
|
|
329
|
+
const reported = status.running ? await statusViaDaemon(accountDir) : null;
|
|
330
|
+
const health = reported?.ok ? reported.health : null;
|
|
331
|
+
output({ accountId: activeAcc.ownId, ...status, health }, jsonMode, () => {
|
|
299
332
|
if (status.running) {
|
|
300
333
|
success(
|
|
301
334
|
`Daemon running for account ${activeAcc.ownId} (PID ${status.pid}${status.channelPort ? `, channel 127.0.0.1:${status.channelPort}` : ""})`,
|
|
302
335
|
);
|
|
303
336
|
info(`Log file: ${status.logPath}`);
|
|
337
|
+
if (health) for (const line of describeHealth(health)) info(line);
|
|
338
|
+
else
|
|
339
|
+
info("Health: not reported (this daemon predates the health route; restart it to see it).");
|
|
304
340
|
} else {
|
|
305
341
|
info(`No daemon running for account ${activeAcc.ownId}`);
|
|
306
342
|
info(`Log file: ${status.logPath}`);
|
|
@@ -323,6 +359,15 @@ export function registerMCPCommands(program) {
|
|
|
323
359
|
return;
|
|
324
360
|
}
|
|
325
361
|
|
|
362
|
+
// Levels and rotation of the log, now that the account is known (src/core/log-sink.js).
|
|
363
|
+
const logging = configureLogging({
|
|
364
|
+
ownId: activeAcc.ownId,
|
|
365
|
+
levelFlag: opts.logLevel?.trim().toLowerCase() || null,
|
|
366
|
+
// The process that launches a background daemon talks to the user; only the daemon's own output is a log.
|
|
367
|
+
filterStderr: !(opts.daemon && !process.env.ZALO_DAEMON_CHILD),
|
|
368
|
+
});
|
|
369
|
+
if (!process.env.ZALO_DAEMON_CHILD) for (const w of logging.warnings) console.error(`[mcp] [logging] ${w}`);
|
|
370
|
+
|
|
326
371
|
// Merge per-account config (`config.accounts[ownId]` and `<accountDir>/mcp-config.json`)
|
|
327
372
|
const { config: accountConfig, problem: accountProblem } = resolveAccountMCPConfig(config, {
|
|
328
373
|
accountId: activeAcc.ownId,
|
|
@@ -427,9 +472,22 @@ export function registerMCPCommands(program) {
|
|
|
427
472
|
}
|
|
428
473
|
};
|
|
429
474
|
|
|
475
|
+
// Spread a fleet's start-up: a random delay and a cap on daemons logging in at once
|
|
476
|
+
// (ZALO_START_JITTER, ZALO_LOGIN_SLOTS; src/core/startup-gate.js). Both off by default.
|
|
477
|
+
const startup = resolveStartupSettings({ env: process.env, ownId: activeAcc.ownId });
|
|
478
|
+
for (const w of startup.warnings) console.error(`[mcp] ${w}`);
|
|
479
|
+
const gate = createStartupGate({
|
|
480
|
+
configDir: CONFIG_DIR,
|
|
481
|
+
ownId: activeAcc.ownId,
|
|
482
|
+
settings: startup,
|
|
483
|
+
log: (m) => console.error(`[mcp] ${m}`),
|
|
484
|
+
});
|
|
485
|
+
await gate.enter();
|
|
486
|
+
|
|
430
487
|
try {
|
|
431
488
|
await loginDaemonAccount(activeAcc);
|
|
432
489
|
} catch (e) {
|
|
490
|
+
gate.leave();
|
|
433
491
|
dropLock();
|
|
434
492
|
console.error("[mcp] Pinned account login failed:", e.message);
|
|
435
493
|
process.exit(1);
|
|
@@ -438,6 +496,7 @@ export function registerMCPCommands(program) {
|
|
|
438
496
|
initDb(join(accountDir, "zalo.db"));
|
|
439
497
|
console.error(`[mcp] Local cache: ${join(accountDir, "zalo.db")}`);
|
|
440
498
|
} catch (e) {
|
|
499
|
+
gate.leave();
|
|
441
500
|
dropLock();
|
|
442
501
|
console.error(`[mcp] Failed to initialize local DB: ${e.message}`);
|
|
443
502
|
process.exit(1);
|
|
@@ -458,6 +517,8 @@ export function registerMCPCommands(program) {
|
|
|
458
517
|
} catch (e) {
|
|
459
518
|
console.error("[mcp] Thread name cache init failed (non-fatal):", e.message);
|
|
460
519
|
}
|
|
520
|
+
// Logged in and the friend and group lists are loaded: the heavy part of start-up is over.
|
|
521
|
+
gate.leave();
|
|
461
522
|
|
|
462
523
|
// One stage at a time on this socket, shared by the sync routes, the
|
|
463
524
|
// self-heal catch-up and the tools' own history fetch. Created before
|
|
@@ -503,6 +564,17 @@ export function registerMCPCommands(program) {
|
|
|
503
564
|
});
|
|
504
565
|
|
|
505
566
|
// Start MCP server — stdio (default) or HTTP/HTTPS
|
|
567
|
+
// What this daemon can say about itself: /health, /ready, /status and /metrics over HTTP, and
|
|
568
|
+
// `mcp start --status` over the loopback channel (src/core/daemon-health.js). The sources that do not
|
|
569
|
+
// exist yet (the webhook outbox, below) are plugged in as they are built.
|
|
570
|
+
const health = createDaemonHealth({ kind: "mcp", accountId: activeAcc.ownId, version: PKG_VERSION });
|
|
571
|
+
health.setSource("buffer", () => {
|
|
572
|
+
const stats = buffer.getStats();
|
|
573
|
+
return { threads: stats.length, messages: stats.reduce((n, t) => n + t.total, 0) };
|
|
574
|
+
});
|
|
575
|
+
health.setSource("gaps", () => getPendingSyncGaps().length);
|
|
576
|
+
health.setSource("stage", () => stageLock.current());
|
|
577
|
+
|
|
506
578
|
let httpServer = null;
|
|
507
579
|
try {
|
|
508
580
|
if (effectiveHttpPort) {
|
|
@@ -523,6 +595,7 @@ export function registerMCPCommands(program) {
|
|
|
523
595
|
accountDir,
|
|
524
596
|
stageLock,
|
|
525
597
|
syncJobs,
|
|
598
|
+
health,
|
|
526
599
|
};
|
|
527
600
|
const authToken =
|
|
528
601
|
opts.auth?.trim() ||
|
|
@@ -597,9 +670,33 @@ export function registerMCPCommands(program) {
|
|
|
597
670
|
if (webhookSecret && !webhookUrl) {
|
|
598
671
|
console.error("[mcp] A webhook secret is set but no webhook URL: nothing will be signed or sent.");
|
|
599
672
|
}
|
|
673
|
+
// Events are written to an on-disk outbox first and delivered from it, so a receiver
|
|
674
|
+
// that is down or slow costs a backlog (mcp webhook status), not messages.
|
|
675
|
+
let webhookDelivery = null;
|
|
600
676
|
if (webhookUrl) {
|
|
601
|
-
|
|
677
|
+
const delivery = resolveWebhookDelivery(activeAcc.ownId, config);
|
|
678
|
+
for (const w of delivery.warnings) console.error(`[mcp] ${w}`);
|
|
679
|
+
webhookDelivery = startWebhookDelivery({
|
|
680
|
+
accountDir,
|
|
681
|
+
accountId: activeAcc.ownId,
|
|
682
|
+
url: webhookUrl,
|
|
683
|
+
secret: webhookSecret,
|
|
684
|
+
batchSize: delivery.batchSize,
|
|
685
|
+
maxAgeMs: delivery.maxAgeMs,
|
|
686
|
+
log: (line) => console.error(`[mcp] ${line}`),
|
|
687
|
+
});
|
|
688
|
+
console.error(
|
|
689
|
+
`[mcp] Webhook enabled: ${webhookUrl}${webhookSecret ? " (signed)" : ""}, ` +
|
|
690
|
+
`durable outbox, ${delivery.batchSize === 1 ? "one event per POST" : `up to ${delivery.batchSize} events per POST`}`,
|
|
691
|
+
);
|
|
602
692
|
}
|
|
693
|
+
health.setSource("webhook", () => webhookDelivery?.status() ?? null);
|
|
694
|
+
/** Queue one buffered message for the webhook, in the shape `postMCPWebhook` always sent. */
|
|
695
|
+
const queueWebhook = (normalized) =>
|
|
696
|
+
webhookDelivery?.enqueue(
|
|
697
|
+
`message:${normalized.id}`,
|
|
698
|
+
buildMCPWebhookPayload(normalized, { accountId: activeAcc.ownId }),
|
|
699
|
+
);
|
|
603
700
|
|
|
604
701
|
// Delivered receipts: the same shared implementation `listen` uses
|
|
605
702
|
// (AGENTS.md §13 -- the two listeners must not differ). Diagnostics
|
|
@@ -685,7 +782,7 @@ export function registerMCPCommands(program) {
|
|
|
685
782
|
if (!filter.shouldKeep(normalized)) continue;
|
|
686
783
|
buffer.push(normalized.threadId, normalized);
|
|
687
784
|
notifier.onMessage(normalized);
|
|
688
|
-
|
|
785
|
+
queueWebhook(normalized);
|
|
689
786
|
}
|
|
690
787
|
},
|
|
691
788
|
});
|
|
@@ -717,6 +814,7 @@ export function registerMCPCommands(program) {
|
|
|
717
814
|
* @param {object} api - zca-js API instance
|
|
718
815
|
*/
|
|
719
816
|
function attachListenerHandlers(api) {
|
|
817
|
+
health.attach(api.listener);
|
|
720
818
|
api.listener.on("message", (msg) => {
|
|
721
819
|
// Persist FIRST and unconditionally. The buffer is what an
|
|
722
820
|
// agent polls; the cache is the durable record, and it must
|
|
@@ -759,8 +857,10 @@ export function registerMCPCommands(program) {
|
|
|
759
857
|
|
|
760
858
|
buffer.push(normalized.threadId, normalized);
|
|
761
859
|
notifier.onMessage(normalized);
|
|
762
|
-
|
|
763
|
-
console.error(
|
|
860
|
+
queueWebhook(normalized);
|
|
861
|
+
console.error(
|
|
862
|
+
`${DEBUG_MARK}[mcp] Buffered ${normalized.threadType} msg from ${normalized.threadId}`,
|
|
863
|
+
);
|
|
764
864
|
});
|
|
765
865
|
|
|
766
866
|
// After the storing handler, exactly as in `listen`: the write
|
|
@@ -868,6 +968,7 @@ export function registerMCPCommands(program) {
|
|
|
868
968
|
// the shutdown is indistinguishable from a drop and files a gap
|
|
869
969
|
// for a window nothing was missed in.
|
|
870
970
|
lifecycle.setStopping();
|
|
971
|
+
health.noteStopping();
|
|
871
972
|
clearInterval(mcpHeartbeatTimer);
|
|
872
973
|
deliveredReceipts.stop();
|
|
873
974
|
const receipts = deliveredReceipts.stats();
|
|
@@ -885,6 +986,7 @@ export function registerMCPCommands(program) {
|
|
|
885
986
|
/* already stopped */
|
|
886
987
|
}
|
|
887
988
|
notifier?.destroy();
|
|
989
|
+
webhookDelivery?.stop();
|
|
888
990
|
httpServer?.close();
|
|
889
991
|
dropLock();
|
|
890
992
|
process.exit(0);
|
|
@@ -913,6 +1015,7 @@ export function registerMCPCommands(program) {
|
|
|
913
1015
|
// The self-heal's lock: a stage and a catch-up never share the socket.
|
|
914
1016
|
lock: stageLock,
|
|
915
1017
|
onStop: shutdown,
|
|
1018
|
+
onStatus: () => health.snapshot(),
|
|
916
1019
|
onWait: async (params, signal) => {
|
|
917
1020
|
const cleanThreadId =
|
|
918
1021
|
typeof params?.threadId === "string" && params.threadId.trim()
|
|
@@ -933,33 +1036,26 @@ export function registerMCPCommands(program) {
|
|
|
933
1036
|
: consumer
|
|
934
1037
|
? buffer.readCursor(consumer)
|
|
935
1038
|
: undefined;
|
|
936
|
-
const
|
|
1039
|
+
const { messages, hasMore } = await buffer.waitForMessages({
|
|
937
1040
|
threadId: cleanThreadId,
|
|
938
1041
|
since: effectiveSince,
|
|
939
1042
|
timeoutMs: clampedSec * 1000,
|
|
940
1043
|
signal,
|
|
1044
|
+
...batchParams({ limit: params?.limit, settleMs: params?.settleMs }),
|
|
941
1045
|
});
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
status: "message",
|
|
947
|
-
message: {
|
|
948
|
-
...msg,
|
|
949
|
-
...(threadName ? { threadName } : {}),
|
|
950
|
-
},
|
|
951
|
-
cursor: msg._cursor,
|
|
952
|
-
};
|
|
953
|
-
}
|
|
954
|
-
return {
|
|
955
|
-
status: "timeout",
|
|
956
|
-
message: null,
|
|
957
|
-
cursor: Math.max(
|
|
1046
|
+
return waitResult({
|
|
1047
|
+
messages,
|
|
1048
|
+
hasMore,
|
|
1049
|
+
cursorWhenEmpty: Math.max(
|
|
958
1050
|
effectiveSince ?? 0,
|
|
959
1051
|
Number(buffer._globalCursor) || 0,
|
|
960
1052
|
buffer.readCursor(consumer),
|
|
961
1053
|
),
|
|
962
|
-
|
|
1054
|
+
decorate: (msg) => {
|
|
1055
|
+
const threadName = nameCache?.get(String(msg.threadId || ""))?.name || null;
|
|
1056
|
+
return { ...msg, ...(threadName ? { threadName } : {}) };
|
|
1057
|
+
},
|
|
1058
|
+
});
|
|
963
1059
|
},
|
|
964
1060
|
onMarkRead: async (params) => {
|
|
965
1061
|
const cursorNum = Number(params?.cursor);
|
|
@@ -1008,6 +1104,16 @@ export function registerMCPCommands(program) {
|
|
|
1008
1104
|
.option("-s, --since <cursor>", "Return immediately if a buffered message after this cursor already exists")
|
|
1009
1105
|
.option("-t, --thread <threadId>", "Only wake on messages from this thread ID")
|
|
1010
1106
|
.option("--timeout <seconds>", "Maximum seconds to wait before returning timeout (1-300, default: 55)", "55")
|
|
1107
|
+
.option(
|
|
1108
|
+
"-n, --limit <count>",
|
|
1109
|
+
`Return up to this many buffered messages in one call, in order (1-${MAX_BATCH}, default: 1)`,
|
|
1110
|
+
"1",
|
|
1111
|
+
)
|
|
1112
|
+
.option(
|
|
1113
|
+
"--settle <ms>",
|
|
1114
|
+
`After the first message, wait this long for the rest of a burst (0-${MAX_SETTLE_MS}, default: 0)`,
|
|
1115
|
+
"0",
|
|
1116
|
+
)
|
|
1011
1117
|
.option(
|
|
1012
1118
|
"-c, --consumer <name>",
|
|
1013
1119
|
"Consumer name whose read cursor is used when --since is omitted (matches zalo_mark_read)",
|
|
@@ -1034,6 +1140,16 @@ export function registerMCPCommands(program) {
|
|
|
1034
1140
|
error(`Invalid --consumer "${consumer}": must be 1-64 characters matching [A-Za-z0-9._:@-].`);
|
|
1035
1141
|
process.exit(1);
|
|
1036
1142
|
}
|
|
1143
|
+
const limit = Number(opts.limit);
|
|
1144
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_BATCH) {
|
|
1145
|
+
error(`Invalid --limit "${opts.limit}": must be an integer between 1 and ${MAX_BATCH}.`);
|
|
1146
|
+
process.exit(1);
|
|
1147
|
+
}
|
|
1148
|
+
const settleMs = Number(opts.settle);
|
|
1149
|
+
if (!Number.isInteger(settleMs) || settleMs < 0 || settleMs > MAX_SETTLE_MS) {
|
|
1150
|
+
error(`Invalid --settle "${opts.settle}": must be an integer between 0 and ${MAX_SETTLE_MS} (ms).`);
|
|
1151
|
+
process.exit(1);
|
|
1152
|
+
}
|
|
1037
1153
|
|
|
1038
1154
|
let activeAcc;
|
|
1039
1155
|
try {
|
|
@@ -1046,6 +1162,8 @@ export function registerMCPCommands(program) {
|
|
|
1046
1162
|
}
|
|
1047
1163
|
const accountDir = getAccountDir(activeAcc.ownId);
|
|
1048
1164
|
const res = await waitViaDaemon(accountDir, {
|
|
1165
|
+
limit,
|
|
1166
|
+
settleMs,
|
|
1049
1167
|
threadId: opts.thread?.trim() || null,
|
|
1050
1168
|
since: parsedSince,
|
|
1051
1169
|
timeoutSec: rawTimeout,
|
|
@@ -1065,16 +1183,21 @@ export function registerMCPCommands(program) {
|
|
|
1065
1183
|
{
|
|
1066
1184
|
status: res.status,
|
|
1067
1185
|
message: res.message,
|
|
1186
|
+
messages: res.messages,
|
|
1187
|
+
count: res.count,
|
|
1188
|
+
hasMore: res.hasMore,
|
|
1068
1189
|
cursor: res.cursor,
|
|
1069
1190
|
},
|
|
1070
1191
|
jsonMode,
|
|
1071
1192
|
() => {
|
|
1072
|
-
if (res.status === "message" && res.
|
|
1073
|
-
const m
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1193
|
+
if (res.status === "message" && res.messages?.length) {
|
|
1194
|
+
for (const m of res.messages) {
|
|
1195
|
+
const label = m.threadName ? `${m.threadName} (${m.threadId})` : m.threadId;
|
|
1196
|
+
success(
|
|
1197
|
+
`[cursor ${m._cursor}] ${m.senderName || m.senderId} in ${label}: ${m.text || `(${m.type})`}`,
|
|
1198
|
+
);
|
|
1199
|
+
}
|
|
1200
|
+
if (res.hasMore) info(`More are waiting: run again with since ${res.cursor}`);
|
|
1078
1201
|
} else {
|
|
1079
1202
|
info(`Timeout after ${rawTimeout}s (cursor: ${res.cursor})`);
|
|
1080
1203
|
}
|
|
@@ -1144,4 +1267,6 @@ export function registerMCPCommands(program) {
|
|
|
1144
1267
|
},
|
|
1145
1268
|
);
|
|
1146
1269
|
});
|
|
1270
|
+
|
|
1271
|
+
registerMCPWebhookCommands(mcp, program);
|
|
1147
1272
|
}
|
package/src/commands/msg.js
CHANGED
|
@@ -34,6 +34,7 @@ import { expandMentions, parseMentionSpecs, shiftStyles, ALL_MENTION_UID } from
|
|
|
34
34
|
import { applySelfThreadAlias } from "../utils/my-documents.js";
|
|
35
35
|
import { buildQuote, resolveQuoteSender } from "../utils/quote.js";
|
|
36
36
|
import { urgencyLevel } from "../utils/urgency.js";
|
|
37
|
+
import { markdownToZalo, resolveSendFormat } from "../utils/markdown.js";
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
40
|
* Open the active account's SQLite cache.
|
|
@@ -574,6 +575,10 @@ export function registerMsgCommands(program) {
|
|
|
574
575
|
)
|
|
575
576
|
.option("--style <specs...>", "Text styles. Format: start:len:style (e.g. 0:5:bold 6:5:italic)")
|
|
576
577
|
.option("--md", "Parse markdown-like formatting: **bold** *italic* __underline__ ~~strike~~ {red:text}")
|
|
578
|
+
.option(
|
|
579
|
+
"--markdown",
|
|
580
|
+
"Send the message as Markdown, converted to Zalo's own formatting: **bold**, *italic*, ~~strike~~, # headings, - bullet lists, 1. lists, > quotes, `code`, [text](url), {red:text}. (Zalo shows typed Markdown as typed.) ZALO_SEND_FORMAT=markdown makes this the default.",
|
|
581
|
+
)
|
|
577
582
|
.option(
|
|
578
583
|
"--react <icon>",
|
|
579
584
|
"Auto-react to sent message. Codes: :> (haha), /-heart (heart), /-strong (like), :o (wow), :-(( (cry), :-h (angry)",
|
|
@@ -594,7 +599,14 @@ export function registerMsgCommands(program) {
|
|
|
594
599
|
let styles = [];
|
|
595
600
|
let finalMsg = message;
|
|
596
601
|
|
|
597
|
-
if (
|
|
602
|
+
if (
|
|
603
|
+
resolveSendFormat({ explicit: opts.markdown ? "markdown" : null, fallback: "plain" }) === "markdown"
|
|
604
|
+
) {
|
|
605
|
+
// Real Markdown: bullets, headings, bold and italic as Markdown means them.
|
|
606
|
+
const converted = markdownToZalo(message);
|
|
607
|
+
finalMsg = converted.text;
|
|
608
|
+
styles = converted.styles;
|
|
609
|
+
} else if (opts.md) {
|
|
598
610
|
// Markdown-like parsing: **bold** *italic* __underline__ ~~strike~~
|
|
599
611
|
const parsed = parseMarkdownStyles(message);
|
|
600
612
|
finalMsg = parsed.plain;
|