@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
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import express from "express";
|
|
7
|
+
import { renderPrometheus } from "../core/daemon-health.js";
|
|
8
|
+
import { batchParams, waitResult } from "./wait-result.js";
|
|
9
|
+
import { DEBUG_MARK } from "../core/log-sink.js";
|
|
7
10
|
import https from "https";
|
|
8
11
|
import { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto";
|
|
9
12
|
import { readFileSync } from "fs";
|
|
@@ -143,7 +146,8 @@ function resolveIssuerOrigin(req, configuredIssuer, isTls) {
|
|
|
143
146
|
/**
|
|
144
147
|
* Create HTTP or HTTPS MCP server with optional bearer token and OAuth 2.0 auth.
|
|
145
148
|
* @param {Function} registerToolsFn - Registers tools on a McpServer instance
|
|
146
|
-
* @param {object} deps - { api, buffer, filter, config, nameCache, accountDir, stageLock, syncJobs }
|
|
149
|
+
* @param {object} deps - { api, buffer, filter, config, nameCache, accountDir, stageLock, syncJobs, health }
|
|
150
|
+
* (`health` is the daemon's ./core/daemon-health.js object: behind /health, /ready, /status and /metrics)
|
|
147
151
|
* @param {number} port
|
|
148
152
|
* @param {string|null} authToken - Bearer token (null = no auth unless oauth.clientSecret or oauth.clients is set)
|
|
149
153
|
* @param {string} [host="127.0.0.1"] - Bind address
|
|
@@ -192,7 +196,7 @@ export function createHTTPServer(registerToolsFn, deps, port, authToken, host =
|
|
|
192
196
|
const ua = String(req.headers["user-agent"] || "-").slice(0, 80);
|
|
193
197
|
const h = String(req.headers.host || "-");
|
|
194
198
|
console.error(
|
|
195
|
-
|
|
199
|
+
`${DEBUG_MARK}[mcp-http] ${req.method} ${req.originalUrl} -> ${res.statusCode} (host=${h} ip=${remoteIp} ua="${ua}")`,
|
|
196
200
|
);
|
|
197
201
|
});
|
|
198
202
|
}
|
|
@@ -503,7 +507,7 @@ export function createHTTPServer(registerToolsFn, deps, port, authToken, host =
|
|
|
503
507
|
// ── Bearer / OAuth2 Auth Middleware — skips /health ─────────────────
|
|
504
508
|
if (signingSecret) {
|
|
505
509
|
app.use((req, res, next) => {
|
|
506
|
-
if (req.path === "/health") return next();
|
|
510
|
+
if (req.path === "/health" || req.path === "/ready") return next();
|
|
507
511
|
const authHeader = req.headers.authorization || "";
|
|
508
512
|
const token = /^bearer\s+/i.test(authHeader) ? authHeader.slice(7).trim() : "";
|
|
509
513
|
|
|
@@ -583,27 +587,19 @@ export function createHTTPServer(registerToolsFn, deps, port, authToken, host =
|
|
|
583
587
|
const ac = new AbortController();
|
|
584
588
|
res.on("close", () => ac.abort());
|
|
585
589
|
|
|
590
|
+
const { limit, settleMs } = batchParams({ limit: req.query.limit, settleMs: req.query.settle });
|
|
591
|
+
|
|
586
592
|
try {
|
|
587
|
-
const
|
|
593
|
+
const { messages, hasMore } = await deps.buffer.waitForMessages({
|
|
588
594
|
threadId,
|
|
589
595
|
since,
|
|
590
596
|
timeoutMs: timeoutSec * 1000,
|
|
591
597
|
signal: ac.signal,
|
|
598
|
+
limit,
|
|
599
|
+
settleMs,
|
|
592
600
|
});
|
|
593
601
|
if (res.writableEnded) return;
|
|
594
|
-
|
|
595
|
-
res.json({
|
|
596
|
-
status: "message",
|
|
597
|
-
message: msg,
|
|
598
|
-
cursor: msg._cursor,
|
|
599
|
-
});
|
|
600
|
-
} else {
|
|
601
|
-
res.json({
|
|
602
|
-
status: "timeout",
|
|
603
|
-
message: null,
|
|
604
|
-
cursor: deps.buffer.readCursor(),
|
|
605
|
-
});
|
|
606
|
-
}
|
|
602
|
+
res.json(waitResult({ messages, hasMore, cursorWhenEmpty: deps.buffer.readCursor() }));
|
|
607
603
|
} catch (err) {
|
|
608
604
|
if (!res.headersSent) {
|
|
609
605
|
res.status(500).json({ error: err.message });
|
|
@@ -611,15 +607,38 @@ export function createHTTPServer(registerToolsFn, deps, port, authToken, host =
|
|
|
611
607
|
}
|
|
612
608
|
});
|
|
613
609
|
|
|
614
|
-
//
|
|
610
|
+
// Liveness — no auth required, and deliberately thin: that the process answers, and whether its socket is
|
|
611
|
+
// up. Everything else is behind the token (/status, /metrics).
|
|
615
612
|
app.get("/health", (req, res) => {
|
|
616
613
|
res.json({
|
|
617
614
|
status: "ok",
|
|
618
615
|
uptime: Math.floor(process.uptime()),
|
|
619
616
|
threads: deps.buffer.getStats().length,
|
|
617
|
+
...(deps.health ? { socket: deps.health.ready().socket, ready: deps.health.ready().ready } : {}),
|
|
620
618
|
});
|
|
621
619
|
});
|
|
622
620
|
|
|
621
|
+
// Readiness — no auth required: 200 only while the Zalo socket is connected, so a load balancer or a
|
|
622
|
+
// supervisor can stop sending work to a daemon whose account is offline.
|
|
623
|
+
app.get("/ready", (req, res) => {
|
|
624
|
+
const r = deps.health ? deps.health.ready() : { ready: true, socket: "unknown" };
|
|
625
|
+
res.status(r.ready ? 200 : 503).json(r);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
// Everything the daemon knows about itself (protected by the auth middleware above).
|
|
629
|
+
app.get("/status", (req, res) => {
|
|
630
|
+
if (!deps.health) return res.status(503).json({ error: "this server does not report health" });
|
|
631
|
+
res.json(deps.health.snapshot());
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
// The same, as Prometheus text (protected by the auth middleware above; Prometheus sends a bearer token).
|
|
635
|
+
app.get("/metrics", (req, res) => {
|
|
636
|
+
if (!deps.health) return res.status(503).type("text/plain").send("# this server does not report health\n");
|
|
637
|
+
res.set("Content-Type", "text/plain; version=0.0.4; charset=utf-8").send(
|
|
638
|
+
renderPrometheus(deps.health.snapshot()),
|
|
639
|
+
);
|
|
640
|
+
});
|
|
641
|
+
|
|
623
642
|
if (isTls) {
|
|
624
643
|
const primary = https.createServer({ cert: tls.cert, key: tls.key }, app).listen(port, host, () => {
|
|
625
644
|
console.error(`MCP HTTPS server listening on ${host}:${port}`);
|
package/src/mcp/mcp-tools.js
CHANGED
|
@@ -43,10 +43,12 @@ import { groupMemberUids, fetchMemberNames } from "../core/group-members.js";
|
|
|
43
43
|
import { recentConversations } from "../core/recent-conversations.js";
|
|
44
44
|
import { describeReadState, readOnZalo } from "../core/read-state.js";
|
|
45
45
|
import { extractMessageText } from "../utils/extract-message-text.js";
|
|
46
|
-
import { expandMentions, ALL_MENTION_UID } from "../utils/mentions.js";
|
|
46
|
+
import { expandMentions, shiftStyles, ALL_MENTION_UID } from "../utils/mentions.js";
|
|
47
47
|
import { resolveSelfThread } from "../utils/my-documents.js";
|
|
48
48
|
import { buildQuote, resolveQuoteSender } from "../utils/quote.js";
|
|
49
49
|
import { urgencyLevel } from "../utils/urgency.js";
|
|
50
|
+
import { markdownToZalo, resolveSendFormat } from "../utils/markdown.js";
|
|
51
|
+
import { MAX_BATCH, MAX_SETTLE_MS, batchParams, waitResult } from "./wait-result.js";
|
|
50
52
|
import {
|
|
51
53
|
describeFriendAddError,
|
|
52
54
|
extractErrorCode,
|
|
@@ -542,6 +544,16 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
|
|
|
542
544
|
"msgId of a cached TEXT message in this thread to quote-reply to. " +
|
|
543
545
|
"Get it from zalo_get_messages or zalo_get_history.",
|
|
544
546
|
),
|
|
547
|
+
format: z
|
|
548
|
+
.enum(["markdown", "plain"])
|
|
549
|
+
.optional()
|
|
550
|
+
.describe(
|
|
551
|
+
"How `text` is sent. `markdown` (the default) turns Markdown into Zalo's own formatting, because Zalo " +
|
|
552
|
+
"shows `**bold**` and `- item` as typed: **bold**, *italic*, ~~strike~~, # headings (bold), " +
|
|
553
|
+
"- bullets (real bullets), 1. lists (numbers kept), > quotes, `code`, [text](url) (the URL stays in " +
|
|
554
|
+
"the text), and {red:text}/{green:text}/{big:text} colors. `plain` sends the text exactly as written. " +
|
|
555
|
+
"Text that is not Markdown (snake_case, 2 * 3 * 4) is left alone either way.",
|
|
556
|
+
),
|
|
545
557
|
urgency: z
|
|
546
558
|
.enum(["normal", "important", "urgent"])
|
|
547
559
|
.optional()
|
|
@@ -577,6 +589,7 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
|
|
|
577
589
|
threadType: threadTypeIn,
|
|
578
590
|
quoteMsgId,
|
|
579
591
|
urgency: urgencyIn,
|
|
592
|
+
format: formatIn,
|
|
580
593
|
}) => {
|
|
581
594
|
// `me` (or the own uid) is My Documents, always a 1-1 -- resolved
|
|
582
595
|
// before anything else reads the thread id, as `msg send me` is.
|
|
@@ -625,8 +638,17 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
|
|
|
625
638
|
if (missing.length > 0) {
|
|
626
639
|
return err(`File not found: ${missing.join(", ")}`);
|
|
627
640
|
}
|
|
641
|
+
// A caption is text too: Markdown in it is converted the same way.
|
|
642
|
+
const caption =
|
|
643
|
+
text && resolveSendFormat({ explicit: formatIn, fallback: "markdown" }) === "markdown"
|
|
644
|
+
? markdownToZalo(text)
|
|
645
|
+
: { text: text || "", styles: [] };
|
|
628
646
|
const result = await api.sendMessage(
|
|
629
|
-
{
|
|
647
|
+
{
|
|
648
|
+
msg: caption.text,
|
|
649
|
+
attachments: absPaths,
|
|
650
|
+
...(caption.styles.length > 0 && { styles: caption.styles }),
|
|
651
|
+
},
|
|
630
652
|
threadId,
|
|
631
653
|
Number(threadType),
|
|
632
654
|
);
|
|
@@ -667,13 +689,19 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
|
|
|
667
689
|
// order against and nothing to move — if styles are ever added to
|
|
668
690
|
// this tool, they must be run through `shiftStyles(styles,
|
|
669
691
|
// expanded.edits)` the way `msg send` does.
|
|
692
|
+
// Markdown first, then mentions: a display name may itself contain `*` or `_`, whereas an
|
|
693
|
+
// `@[uid]` token contains no Markdown (the converter passes it through). The style ranges
|
|
694
|
+
// are counted before the names go in, so they move across the substitutions (`shiftStyles`).
|
|
695
|
+
const format = resolveSendFormat({ explicit: formatIn, fallback: "markdown" });
|
|
696
|
+
const converted = format === "markdown" ? markdownToZalo(text) : { text, styles: [] };
|
|
670
697
|
const unresolved = [];
|
|
671
|
-
const expanded = expandMentions(text, (uid) => {
|
|
698
|
+
const expanded = expandMentions(converted.text, (uid) => {
|
|
672
699
|
const name = cachedDisplayName(uid);
|
|
673
700
|
if (!name && uid !== ALL_MENTION_UID) unresolved.push(uid);
|
|
674
701
|
return name;
|
|
675
702
|
});
|
|
676
703
|
const mentions = expanded.mentions;
|
|
704
|
+
const styles = shiftStyles(converted.styles, expanded.edits);
|
|
677
705
|
|
|
678
706
|
// zca-js drops mentions outside a group, so a DM send would
|
|
679
707
|
// quietly arrive with the names as plain text and nobody tagged.
|
|
@@ -726,11 +754,12 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
|
|
|
726
754
|
|
|
727
755
|
// A plain send stays a plain string: the object form is only
|
|
728
756
|
// built when there is something to put in it.
|
|
729
|
-
const hasExtras = mentions.length > 0 || Boolean(quote) || Boolean(urgency);
|
|
757
|
+
const hasExtras = mentions.length > 0 || styles.length > 0 || Boolean(quote) || Boolean(urgency);
|
|
730
758
|
const content = hasExtras
|
|
731
759
|
? {
|
|
732
760
|
msg: expanded.text,
|
|
733
761
|
...(mentions.length > 0 && { mentions }),
|
|
762
|
+
...(styles.length > 0 && { styles }),
|
|
734
763
|
...(quote && { quote }),
|
|
735
764
|
...(urgency && { urgency }),
|
|
736
765
|
}
|
|
@@ -755,6 +784,7 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
|
|
|
755
784
|
// Only when it differs, so the common send keeps its shape:
|
|
756
785
|
// the agent wrote `@[123]` and needs to know what was sent.
|
|
757
786
|
...(expanded.text !== text && { text: expanded.text }),
|
|
787
|
+
...(styles.length > 0 && { formatted: styles.length }),
|
|
758
788
|
...(unresolved.length > 0 && { unresolvedMentions: unresolved }),
|
|
759
789
|
...(warnings.length > 0 && { warnings }),
|
|
760
790
|
});
|
|
@@ -2347,7 +2377,10 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
|
|
|
2347
2377
|
description:
|
|
2348
2378
|
"Long-poll the live MCP message buffer for the next incoming watched message without manual polling. " +
|
|
2349
2379
|
"Returns immediately if a buffered message newer than `since` is already waiting; otherwise waits up to " +
|
|
2350
|
-
"`timeoutSec` seconds (default 55, max 300) for a new message to arrive.
|
|
2380
|
+
"`timeoutSec` seconds (default 55, max 300) for a new message to arrive. `limit` (default 1, max 100) returns " +
|
|
2381
|
+
"up to that many buffered messages in one call, in order, in `messages` (`message` is the first and `cursor` " +
|
|
2382
|
+
"the last, so `since: cursor` continues where the batch ended; `hasMore` says more are already waiting); " +
|
|
2383
|
+
"`settleMs` waits that long after the first for the rest of a burst. Mirrors GET /events/wait for " +
|
|
2351
2384
|
"remote HTTPS/OAuth2 MCP clients.",
|
|
2352
2385
|
inputSchema: z.object({
|
|
2353
2386
|
threadId: z
|
|
@@ -2371,48 +2404,61 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
|
|
|
2371
2404
|
.max(300)
|
|
2372
2405
|
.default(55)
|
|
2373
2406
|
.describe("Maximum seconds to wait before returning `status: 'timeout'` (default 55, max 300)"),
|
|
2407
|
+
limit: z
|
|
2408
|
+
.number()
|
|
2409
|
+
.int()
|
|
2410
|
+
.min(1)
|
|
2411
|
+
.max(MAX_BATCH)
|
|
2412
|
+
.default(1)
|
|
2413
|
+
.describe(`Most messages to return in one call (default 1, max ${MAX_BATCH})`),
|
|
2414
|
+
settleMs: z
|
|
2415
|
+
.number()
|
|
2416
|
+
.int()
|
|
2417
|
+
.min(0)
|
|
2418
|
+
.max(MAX_SETTLE_MS)
|
|
2419
|
+
.default(0)
|
|
2420
|
+
.describe(
|
|
2421
|
+
`After the first message arrives, wait this many ms for the rest of a burst (default 0, max ${MAX_SETTLE_MS})`,
|
|
2422
|
+
),
|
|
2374
2423
|
}),
|
|
2375
2424
|
},
|
|
2376
|
-
async ({ threadId, since, timeoutSec = 55 }) => {
|
|
2425
|
+
async ({ threadId, since, timeoutSec = 55, limit = 1, settleMs = 0 }) => {
|
|
2377
2426
|
try {
|
|
2378
|
-
if (typeof buffer?.
|
|
2379
|
-
return err("Live message buffer does not support
|
|
2427
|
+
if (typeof buffer?.waitForMessages !== "function") {
|
|
2428
|
+
return err("Live message buffer does not support waitForMessages in this context.");
|
|
2380
2429
|
}
|
|
2381
2430
|
const cleanThreadId = typeof threadId === "string" && threadId.trim() ? threadId.trim() : null;
|
|
2382
2431
|
const clampedSec = Math.max(1, Math.min(300, Number(timeoutSec) || 55));
|
|
2383
|
-
const
|
|
2432
|
+
const batch = batchParams({ limit, settleMs });
|
|
2433
|
+
const { messages, hasMore } = await buffer.waitForMessages({
|
|
2384
2434
|
threadId: cleanThreadId,
|
|
2385
2435
|
since: typeof since === "number" && Number.isFinite(since) ? since : undefined,
|
|
2386
2436
|
timeoutMs: clampedSec * 1000,
|
|
2437
|
+
...batch,
|
|
2387
2438
|
});
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2439
|
+
return ok(
|
|
2440
|
+
waitResult({
|
|
2441
|
+
messages,
|
|
2442
|
+
hasMore,
|
|
2443
|
+
cursorWhenEmpty: Math.max(
|
|
2444
|
+
typeof since === "number" && Number.isFinite(since) ? since : 0,
|
|
2445
|
+
Number(buffer._globalCursor) || 0,
|
|
2446
|
+
typeof buffer.readCursor === "function" ? buffer.readCursor() : 0,
|
|
2447
|
+
),
|
|
2448
|
+
decorate: (msg) => {
|
|
2449
|
+
const tid = String(msg.threadId || "");
|
|
2450
|
+
let threadName = nameCache?.get(tid)?.name || null;
|
|
2451
|
+
if (!threadName && tid) {
|
|
2452
|
+
try {
|
|
2453
|
+
threadName = getDisplayName(tid) || null;
|
|
2454
|
+
} catch {
|
|
2455
|
+
threadName = null;
|
|
2456
|
+
}
|
|
2457
|
+
}
|
|
2458
|
+
return { ...msg, ...(threadName ? { threadName } : {}) };
|
|
2403
2459
|
},
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
}
|
|
2407
|
-
return ok({
|
|
2408
|
-
status: "timeout",
|
|
2409
|
-
message: null,
|
|
2410
|
-
cursor: Math.max(
|
|
2411
|
-
typeof since === "number" && Number.isFinite(since) ? since : 0,
|
|
2412
|
-
Number(buffer._globalCursor) || 0,
|
|
2413
|
-
typeof buffer.readCursor === "function" ? buffer.readCursor() : 0,
|
|
2414
|
-
),
|
|
2415
|
-
});
|
|
2460
|
+
}),
|
|
2461
|
+
);
|
|
2416
2462
|
} catch (e) {
|
|
2417
2463
|
console.error("[mcp-tools] zalo_wait_for_message error:", e.message);
|
|
2418
2464
|
return err(e.message);
|
|
@@ -9,6 +9,45 @@
|
|
|
9
9
|
* (maxSize per thread, maxAge), which bounds memory as before.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { batchParams } from "./wait-result.js";
|
|
13
|
+
|
|
14
|
+
/** Sleep `ms`, or less if `signal` aborts. */
|
|
15
|
+
function sleep(ms, signal) {
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
if (signal?.aborted) return resolve();
|
|
18
|
+
const timer = setTimeout(done, ms);
|
|
19
|
+
function done() {
|
|
20
|
+
clearTimeout(timer);
|
|
21
|
+
signal?.removeEventListener("abort", done);
|
|
22
|
+
resolve();
|
|
23
|
+
}
|
|
24
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** How often a settling wait looks at the buffer to see whether the batch is already full. */
|
|
29
|
+
const SETTLE_POLL_MS = 20;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Let a burst land: read, and keep reading for up to `settleMs`, but stop the moment `limit` messages
|
|
33
|
+
* are in hand (a full batch has nothing to wait for) or `signal` aborts.
|
|
34
|
+
*
|
|
35
|
+
* @param {() => { messages: object[], hasMore: boolean }} read
|
|
36
|
+
* @param {number} limit
|
|
37
|
+
* @param {number} settleMs
|
|
38
|
+
* @param {AbortSignal} [signal]
|
|
39
|
+
* @returns {Promise<{ messages: object[], hasMore: boolean }>}
|
|
40
|
+
*/
|
|
41
|
+
async function settleUntilFull(read, limit, settleMs, signal) {
|
|
42
|
+
const deadline = Date.now() + settleMs;
|
|
43
|
+
let r = read();
|
|
44
|
+
while (r.messages.length < limit && Date.now() < deadline && !signal?.aborted) {
|
|
45
|
+
await sleep(Math.min(SETTLE_POLL_MS, Math.max(1, deadline - Date.now())), signal);
|
|
46
|
+
r = read();
|
|
47
|
+
}
|
|
48
|
+
return r;
|
|
49
|
+
}
|
|
50
|
+
|
|
12
51
|
/** The consumer a caller that names none reads as. */
|
|
13
52
|
export const DEFAULT_CONSUMER = "default";
|
|
14
53
|
|
|
@@ -78,13 +117,64 @@ export class MessageBuffer {
|
|
|
78
117
|
* @param {AbortSignal} [opts.signal] - Optional abort signal (e.g. when HTTP client disconnects)
|
|
79
118
|
* @returns {Promise<object|null>} The message object, or null on timeout/abort
|
|
80
119
|
*/
|
|
81
|
-
waitForMessage(
|
|
120
|
+
async waitForMessage(opts = {}) {
|
|
121
|
+
const { messages } = await this.waitForMessages({ ...opts, limit: 1, settleMs: 0 });
|
|
122
|
+
return messages[0] ?? null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Wait for buffered messages and return up to `limit` of them at once, in cursor order.
|
|
127
|
+
*
|
|
128
|
+
* With `since`, messages already buffered after it are returned at once; otherwise the call waits for
|
|
129
|
+
* the next message to arrive. `settleMs` then gives a burst that long to land, so the reply holds it
|
|
130
|
+
* whole instead of the first message alone. Nothing is waited for once `limit` is reached.
|
|
131
|
+
*
|
|
132
|
+
* @param {object} [opts]
|
|
133
|
+
* @param {string} [opts.threadId] - Optional thread filter
|
|
134
|
+
* @param {number} [opts.since] - Optional cursor (exclusive)
|
|
135
|
+
* @param {number} [opts.timeoutMs=60000] - Max wait for the FIRST message
|
|
136
|
+
* @param {AbortSignal} [opts.signal]
|
|
137
|
+
* @param {number} [opts.limit=1] - Most messages to return (1-100)
|
|
138
|
+
* @param {number} [opts.settleMs=0] - After the first message, wait this long for more (0-5000)
|
|
139
|
+
* @returns {Promise<{ messages: object[], hasMore: boolean }>} `messages` is empty on timeout or abort
|
|
140
|
+
*/
|
|
141
|
+
async waitForMessages({
|
|
142
|
+
threadId = null,
|
|
143
|
+
since = undefined,
|
|
144
|
+
timeoutMs = 60000,
|
|
145
|
+
signal = undefined,
|
|
146
|
+
limit = 1,
|
|
147
|
+
settleMs = 0,
|
|
148
|
+
} = {}) {
|
|
149
|
+
const { limit: max, settleMs: settle } = batchParams({ limit, settleMs });
|
|
150
|
+
const from = (cursor) => this.read(threadId || undefined, cursor, max);
|
|
151
|
+
|
|
82
152
|
if (since !== undefined && since !== null && Number.isFinite(Number(since))) {
|
|
83
|
-
|
|
84
|
-
if (
|
|
85
|
-
|
|
153
|
+
let r = from(Number(since));
|
|
154
|
+
if (r.messages.length > 0) {
|
|
155
|
+
if (settle > 0 && r.messages.length < max) {
|
|
156
|
+
r = await settleUntilFull(() => from(Number(since)), max, settle, signal);
|
|
157
|
+
}
|
|
158
|
+
return { messages: r.messages, hasMore: r.hasMore };
|
|
86
159
|
}
|
|
87
160
|
}
|
|
161
|
+
if (signal?.aborted) return { messages: [], hasMore: false };
|
|
162
|
+
|
|
163
|
+
const first = await this._waitNext({ threadId, timeoutMs, signal });
|
|
164
|
+
if (!first) return { messages: [], hasMore: false };
|
|
165
|
+
// From the first message on: it, and whatever arrived with it or while settling.
|
|
166
|
+
const r =
|
|
167
|
+
settle > 0 && max > 1
|
|
168
|
+
? await settleUntilFull(() => from(first._cursor - 1), max, settle, signal)
|
|
169
|
+
: from(first._cursor - 1);
|
|
170
|
+
return r.messages.length ? { messages: r.messages, hasMore: r.hasMore } : { messages: [first], hasMore: false };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Resolve with the next message pushed (any thread, or `threadId`), or null on timeout or abort.
|
|
175
|
+
* @private
|
|
176
|
+
*/
|
|
177
|
+
_waitNext({ threadId = null, timeoutMs = 60000, signal = undefined } = {}) {
|
|
88
178
|
if (signal?.aborted) {
|
|
89
179
|
return Promise.resolve(null);
|
|
90
180
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a "wait for messages" call returns, in one place: the HTTP route `GET /events/wait`, the MCP tool
|
|
3
|
+
* `zalo_wait_for_message` and `mcp wait` over the daemon channel all build it here, so they cannot drift.
|
|
4
|
+
*
|
|
5
|
+
* It used to hand back one message per call, so a bot behind a busy account made one round trip per
|
|
6
|
+
* message. `limit` (1-100, default 1) returns up to that many buffered messages at once, in cursor order,
|
|
7
|
+
* and `settleMs` waits that long after the first arrives so a burst lands in one reply.
|
|
8
|
+
*
|
|
9
|
+
* The single-message fields are unchanged and still filled (`message` is the first, `cursor` the last
|
|
10
|
+
* one returned, so `since=cursor` continues exactly where the batch ended); `messages`, `count` and
|
|
11
|
+
* `hasMore` are added. With `limit` 1 the reply is what it always was, plus those three fields.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Most messages one call returns. */
|
|
15
|
+
export const MAX_BATCH = 100;
|
|
16
|
+
/** Longest a call waits for company after the first message. */
|
|
17
|
+
export const MAX_SETTLE_MS = 5000;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Clamp the caller's batch settings; anything unusable is the default (one message, no settling).
|
|
21
|
+
*
|
|
22
|
+
* @param {{ limit?: unknown, settleMs?: unknown }} [raw]
|
|
23
|
+
* @returns {{ limit: number, settleMs: number }}
|
|
24
|
+
*/
|
|
25
|
+
export function batchParams({ limit, settleMs } = {}) {
|
|
26
|
+
const l = Number(limit);
|
|
27
|
+
const s = Number(settleMs);
|
|
28
|
+
return {
|
|
29
|
+
limit: Number.isFinite(l) && l >= 1 ? Math.min(MAX_BATCH, Math.floor(l)) : 1,
|
|
30
|
+
settleMs: Number.isFinite(s) && s > 0 ? Math.min(MAX_SETTLE_MS, Math.floor(s)) : 0,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {object} args
|
|
36
|
+
* @param {object[]} args.messages - Buffered messages, in cursor order (empty on timeout)
|
|
37
|
+
* @param {boolean} [args.hasMore] - More messages are already buffered after these
|
|
38
|
+
* @param {number} args.cursorWhenEmpty - The cursor to report on a timeout, so the caller can carry on from it
|
|
39
|
+
* @param {(m: object) => object} [args.decorate] - Adds what only this daemon knows, e.g. the thread name
|
|
40
|
+
* @returns {{ status: "message"|"timeout", message: object|null, messages: object[], count: number, hasMore: boolean, cursor: number }}
|
|
41
|
+
*/
|
|
42
|
+
export function waitResult({ messages, hasMore = false, cursorWhenEmpty, decorate = (m) => m }) {
|
|
43
|
+
const out = messages.map(decorate);
|
|
44
|
+
return {
|
|
45
|
+
status: out.length ? "message" : "timeout",
|
|
46
|
+
message: out[0] ?? null,
|
|
47
|
+
messages: out,
|
|
48
|
+
count: out.length,
|
|
49
|
+
hasMore: Boolean(hasMore) && out.length > 0,
|
|
50
|
+
cursor: out.length ? messages[messages.length - 1]._cursor : cursorWhenEmpty,
|
|
51
|
+
};
|
|
52
|
+
}
|