@ardennguyen/zalo-agent-cli 2.5.7 → 2.5.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ardennguyen/zalo-agent-cli",
3
- "version": "2.5.7",
3
+ "version": "2.5.9",
4
4
  "description": "CLI tool for Zalo automation — multi-account, proxy, bank transfers, QR payments, Official Account API v3.0",
5
5
  "type": "module",
6
6
  "bin": {
@@ -99,6 +99,9 @@ export function normalizeMessage(msg, info = null) {
99
99
  const cls = info || classifyLiveMessage(data);
100
100
  const media = cls.attachments.find((a) => a.url || a.thumbUrl) || null;
101
101
  return {
102
+ // `msgId` is the name every tool takes and every other reply uses; `id` is the same value, kept for clients
103
+ // that already read it (the webhook and /events/wait payloads carry both).
104
+ msgId: data.msgId,
102
105
  id: data.msgId,
103
106
  threadId: msg.threadId,
104
107
  threadType: msg.type === 0 ? "dm" : "group",
@@ -35,6 +35,7 @@ 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
37
  import { markdownToZalo, resolveSendFormat } from "../utils/markdown.js";
38
+ import { splitMessage, sendParts } from "../utils/split-message.js";
38
39
 
39
40
  /**
40
41
  * Open the active account's SQLite cache.
@@ -678,19 +679,38 @@ export function registerMsgCommands(program) {
678
679
  // Build message content. `urgency` is 1 (important) or 2
679
680
  // (urgent); zca-js turns it into the `metaData: {urgency}`
680
681
  // object Zalo Web sends, on every text endpoint.
682
+ //
683
+ // Zalo refuses one oversized message (114 plain, 112 styled), so
684
+ // a long text goes out as Web sends it: split at 3000 bytes of
685
+ // text plus styles, in order, 300 ms apart. The quote and the
686
+ // urgency belong to the first part only.
681
687
  const urgency = opts.urgency;
682
- const hasExtras = mentions.length > 0 || styles.length > 0 || Boolean(quote) || Boolean(urgency);
683
- const msgContent = hasExtras
684
- ? {
685
- msg: finalMsg,
686
- ...(mentions.length > 0 && { mentions }),
687
- ...(styles.length > 0 && { styles }),
688
- ...(quote && { quote }),
689
- ...(urgency && { urgency }),
690
- }
691
- : finalMsg;
692
-
693
- const result = await getApi().sendMessage(msgContent, threadId, Number(opts.type));
688
+ const parts = splitMessage({ text: finalMsg, styles, mentions });
689
+ const contentOf = (part, index) => {
690
+ const first = index === 0;
691
+ const extras = part.mentions.length > 0 || part.styles.length > 0 || (first && (quote || urgency));
692
+ return extras
693
+ ? {
694
+ msg: part.text,
695
+ ...(part.mentions.length > 0 && { mentions: part.mentions }),
696
+ ...(part.styles.length > 0 && { styles: part.styles }),
697
+ ...(first && quote && { quote }),
698
+ ...(first && urgency && { urgency }),
699
+ }
700
+ : part.text;
701
+ };
702
+
703
+ const answers = await sendParts(parts, (part, index) =>
704
+ getApi().sendMessage(contentOf(part, index), threadId, Number(opts.type)),
705
+ );
706
+ const result = answers[0];
707
+ if (parts.length > 1) {
708
+ result.splitInto = parts.length;
709
+ result.parts = answers.map((r) => ({
710
+ msgId: r.message?.msgId ? String(r.message.msgId) : null,
711
+ cliMsgId: r.message?.cliMsgId ? String(r.message.cliMsgId) : null,
712
+ }));
713
+ }
694
714
 
695
715
  // The cliMsgId comes back from zca-js, which hands over the
696
716
  // clientId it actually put on the wire (patches/zca-js+2.2.0.patch
@@ -709,7 +729,11 @@ export function registerMsgCommands(program) {
709
729
  // so — a fabricated one is worse than an absent one.
710
730
  const cliMsgId = result.message?.cliMsgId ? String(result.message.cliMsgId) : null;
711
731
  if (cliMsgId) result.cliMsgId = cliMsgId;
712
- output(result, program.opts().json, () => success("Message sent"));
732
+ output(result, program.opts().json, () =>
733
+ success(
734
+ parts.length > 1 ? `Message sent in ${parts.length} parts (too long for one)` : "Message sent",
735
+ ),
736
+ );
713
737
 
714
738
  if (result.message && !cliMsgId) {
715
739
  warning(
@@ -0,0 +1,32 @@
1
+ /**
2
+ * What every agent connecting to this MCP server is told once, on connect (the MCP `instructions` field), before it
3
+ * reads a single tool. The tool descriptions say what each tool does; this says what they share: the ids, the way to
4
+ * read and to send, and the few rules a model gets wrong without being told. Keep it short: clients put it in the
5
+ * model's context on every session.
6
+ */
7
+ export const SERVER_INSTRUCTIONS = [
8
+ "Zalo MCP server: read and send messages on one Zalo account.",
9
+ "",
10
+ "IDS",
11
+ "- threadId is the conversation: the other person's user id for a DM, the group id for a group. Find one by name with zalo_search_threads, among recent ones with zalo_list_conversations, or from a phone number or username with zalo_lookup_user. `me` is the owner's My Documents.",
12
+ "- msgId is one message, and it is called `msgId` in every input and reply. Live messages also carry it as `id`, and a send reply as `messageId`: the same value, kept for older clients.",
13
+ "- userId is a Zalo user. A message's author is its `senderId` (a userId); group members have a `userId`. `@[userId]` mentions someone.",
14
+ "- cliMsgId is the sender's own id for a message. zalo_send_message returns it, and zalo_react and zalo_undo need it (they read it from the cache when you leave it out).",
15
+ '- threadType, as an input, is the number 0 (DM) or 1 (group); leave it out and the type stored for the thread is used. Messages show `threadType` as the text "dm" or "group": never pass that text back.',
16
+ "",
17
+ "READING",
18
+ "- To receive messages, wait with zalo_wait_for_message or poll with zalo_get_messages, then call zalo_mark_read with the returned `cursor`. If several bots share the server, each passes its own `consumer` name.",
19
+ "- For older messages use zalo_get_history (one conversation) or zalo_search_messages (text, across all conversations).",
20
+ "",
21
+ "SENDING TEXT",
22
+ "- Put real line breaks in `text`. Never type the two characters backslash and n: Zalo shows them as typed.",
23
+ '- Markdown is converted to Zalo formatting by default; `format: "plain"` sends the text as written.',
24
+ "- Mention someone in a group with `@[uid]` (`@[-1]` is everyone). Reply to a message with `quoteMsgId`.",
25
+ "- A text over 3000 bytes goes out as several messages; the reply lists each part.",
26
+ "- A sent message cannot be edited. To correct one, recall it with zalo_undo (msgId and cliMsgId from the send reply) and send it again.",
27
+ "",
28
+ "RULES",
29
+ "- Call zalo_sync only when the owner asked: it makes the owner's phone ask for a confirmation.",
30
+ "- zalo_group `leave` and `remove_member` need `confirm: true`.",
31
+ "- A refused call says why in its error text. Fix the argument it names; do not repeat the same call.",
32
+ ].join("\n");
@@ -12,7 +12,7 @@ import { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto";
12
12
  import { readFileSync } from "fs";
13
13
  import { fileURLToPath } from "url";
14
14
  import { dirname, join } from "path";
15
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15
+ import { newMcpServer } from "./new-server.js";
16
16
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
17
17
 
18
18
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -539,7 +539,7 @@ export function createHTTPServer(registerToolsFn, deps, port, authToken, host =
539
539
  // MCP endpoint — stateless, fresh server+transport per request (POST, GET, DELETE per Streamable HTTP spec)
540
540
  app.all("/mcp", async (req, res) => {
541
541
  try {
542
- const server = new McpServer({ name: "zalo-agent", version: pkg.version });
542
+ const server = newMcpServer(pkg.version);
543
543
  registerToolsFn(
544
544
  server,
545
545
  deps.api,
@@ -6,9 +6,9 @@
6
6
  import { readFileSync } from "fs";
7
7
  import { fileURLToPath } from "url";
8
8
  import { dirname, join } from "path";
9
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
9
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
10
  import { registerTools } from "./mcp-tools.js";
11
+ import { newMcpServer } from "./new-server.js";
12
12
 
13
13
  const __dirname = dirname(fileURLToPath(import.meta.url));
14
14
  const pkg = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf8"));
@@ -24,13 +24,10 @@ const pkg = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf8
24
24
  * @param {string} [accountDir] - account data dir, for cache reads and media paths
25
25
  * @param {object} [stageLock] - the daemon's one-stage-at-a-time lock (daemon-channel.js)
26
26
  * @param {object} [syncJobs] - account-local sync tracker shared by all MCP requests
27
- * @returns {Promise<McpServer>}
27
+ * @returns {Promise<import("@modelcontextprotocol/sdk/server/mcp.js").McpServer>}
28
28
  */
29
29
  export async function createMCPServer(api, buffer, filter, config, nameCache, accountDir, stageLock, syncJobs) {
30
- const server = new McpServer({
31
- name: "zalo-agent",
32
- version: pkg.version,
33
- });
30
+ const server = newMcpServer(pkg.version);
34
31
 
35
32
  registerTools(server, api, buffer, filter, config, nameCache, accountDir, stageLock, syncJobs);
36
33
 
@@ -47,6 +47,8 @@ import { expandMentions, shiftStyles, ALL_MENTION_UID } from "../utils/mentions.
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 { splitMessage, sendParts } from "../utils/split-message.js";
51
+ import { hasLiteralNewline, LITERAL_NEWLINE_WARNING } from "../utils/literal-newline.js";
50
52
  import { markdownToZalo, resolveSendFormat } from "../utils/markdown.js";
51
53
  import { MAX_BATCH, MAX_SETTLE_MS, batchParams, waitResult } from "./wait-result.js";
52
54
  import {
@@ -412,21 +414,31 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
412
414
  {
413
415
  title: "Get Zalo Messages",
414
416
  description:
415
- "Get messages from Zalo threads (DMs and groups). Returns buffered messages this consumer has " +
416
- "not marked read (see zalo_mark_read). Use 'since' cursor from previous response for incremental polling. " +
417
- "Each message's `readOnZalo` says whether the account itself has already read it on Zalo -- the " +
418
- "human, on the phone or Zalo Web -- and is null when the listener has not heard; zalo_mark_read " +
419
- "does not change it. Use senderDisplayName for the current label (your alias, then Zalo profile name, " +
420
- "then UID); senderDisplayNameSource reports alias/zalo/uid. senderName remains the historical snapshot.",
417
+ "Read the new incoming Zalo messages (DMs and groups) from the live buffer, in order. It returns the messages this consumer has not yet marked read; when you have handled them, call zalo_mark_read with the reply's `cursor`. " +
418
+ "To continue exactly after the last reply, pass its `cursor` as `since`. To wait for the next message instead of polling, use zalo_wait_for_message. " +
419
+ "Each message has `msgId` (pass it as `msgId` or `quoteMsgId` to other tools; `id` is the same value, kept for older clients), `threadId`, `threadType` (dm or group), `senderId`, `senderDisplayName` (the current name: your alias, else the Zalo profile name, else the uid; `senderDisplayNameSource` says which), `senderName` (the name when the message was first seen), `text`, `timestamp` (epoch ms), `type`, `threadName` and `readOnZalo`. " +
420
+ "`readOnZalo` is true or false when the account owner has or has not read the message on Zalo (phone or Zalo Web), and null when that is unknown; zalo_mark_read does not change it. " +
421
+ "Returns `{ messages, cursor, hasMore }`.",
421
422
  inputSchema: z.object({
422
- threadId: z.string().optional().describe("Thread ID to read from. Omit for all watched threads."),
423
+ threadId: z
424
+ .string()
425
+ .optional()
426
+ .describe("Only this conversation (its threadId). Omit for all conversations the server watches."),
423
427
  since: z
424
428
  .number()
425
429
  .int()
426
430
  .min(0)
427
431
  .default(0)
428
- .describe("Cursor from previous read for incremental polling; 0 means from your read cursor"),
429
- limit: z.number().int().min(1).max(100).default(maxPerPoll).describe("Max messages to return"),
432
+ .describe(
433
+ "The `cursor` of your last reply, to continue after it. 0 (default) continues after what you marked read with zalo_mark_read.",
434
+ ),
435
+ limit: z
436
+ .number()
437
+ .int()
438
+ .min(1)
439
+ .max(100)
440
+ .default(maxPerPoll)
441
+ .describe("Most messages to return in one call (1 to 100)."),
430
442
  consumer: consumerSchema,
431
443
  }),
432
444
  },
@@ -479,54 +491,50 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
479
491
  {
480
492
  title: "Send Zalo Message",
481
493
  description:
482
- "Send a text message, file/image attachment (`filePath` or `files`), link preview card (`link`), or " +
483
- "contact card (`cardUserId`) to a Zalo thread (DM or group). threadType: 0=DM(User), 1=Group. " +
484
- "To @-mention someone in a group, put `@[uid]` in the text where the tag belongs — " +
485
- "`@[123456]` becomes `@Their Name` and notifies them, and `@[-1]` is @All. " +
486
- "Use the `senderId` from zalo_get_messages or zalo_get_history as the uid; the display name " +
487
- "is read from the local cache, and a uid it cannot name is tagged as the bare uid " +
488
- "(reported back as `unresolvedMentions`). Mentions only deliver in groups (threadType 1). " +
489
- "To reply to a specific message, pass its msgId as `quoteMsgId` — Zalo supports quote-replies " +
490
- "to text messages only, and the quoted message must already be in the local cache. " +
491
- '`urgency` marks the message Important or Urgent, as the apps do. `threadId: "me"` sends to ' +
492
- "My Documents (your own cloud chat); the reply carries its real thread id.",
494
+ "Send a message to a Zalo conversation: text, files or images, a link preview card, or a contact card. Give `threadId` and one kind of content: `text`, `filePath` or `files`, `link`, or `cardUserId`. With files or a link, `text` is an optional caption. " +
495
+ 'Text: use real line breaks, never the two characters backslash and n (Zalo shows those as typed). Markdown is converted to Zalo formatting by default; `format: "plain"` sends the text exactly as written. A text over 3000 bytes (a Chinese, Japanese, Korean, Thai or Hindi character is 3, a Cyrillic or Arabic one 2) is split into several messages at paragraph or sentence ends, as Zalo Web does, and the reply then lists every part (`splitInto`, `parts`). ' +
496
+ "Mention in a group: put `@[userId]` in the text where the tag belongs (`@[-1]` is @All). Take the user id from a message's `senderId` (zalo_get_messages, zalo_get_history) or from zalo_get_group_members. It is sent as `@Their Name` and notifies that person; a user id the cache cannot name is tagged as the bare id and listed in `unresolvedMentions`. Mentions only work in groups. " +
497
+ "Reply to a message: pass its msgId as `quoteMsgId` (text messages only, and the message must be in the local cache). " +
498
+ '`urgency` marks the message Important or Urgent, as the apps do. `threadId: "me"` sends to the owner\'s My Documents. ' +
499
+ "Returns `{ success, msgId, cliMsgId, threadType }` (`messageId` is the same value as `msgId`, kept for older clients) and, when they apply, `parts` and `warnings`. Keep `msgId` and `cliMsgId` if you may need to react to or recall this message (zalo_react, zalo_undo).",
493
500
  inputSchema: z
494
501
  .object({
495
502
  threadId: z
496
503
  .string()
497
- .describe("Thread ID to send message to. `me` (or your own uid) is My Documents, a 1-1 thread"),
504
+ .describe(
505
+ "The conversation: the other person's user id for a DM, the group id for a group (find one with zalo_search_threads, zalo_list_conversations or zalo_lookup_user). `me` (or your own uid) is My Documents.",
506
+ ),
498
507
  text: z
499
508
  .string()
500
509
  .min(1)
501
510
  .optional()
502
511
  .describe(
503
- "Message text to send (required unless `filePath`, `files`, `link`, or `cardUserId` is set; " +
504
- "optional caption when `filePath`, `files`, or `link` is set). `@[uid]` tokens expand to @-mentions; `@[-1]` is @All.",
512
+ "The message. Real line breaks only: never the two characters backslash and n. Markdown is converted unless `format` is 'plain'. Required unless `filePath`, `files`, `link` or `cardUserId` is given; with those it is the caption. `@[userId]` tokens become @-mentions (`@[-1]` is @All).",
505
513
  ),
506
514
  filePath: z
507
515
  .string()
508
516
  .min(1)
509
517
  .optional()
510
518
  .describe(
511
- "Optional local file or image path to send as an attachment (uses `text` as optional caption)",
519
+ "Local path of ONE file or image to send, on the machine this server runs on. `text` becomes its caption.",
512
520
  ),
513
521
  files: z
514
522
  .array(z.string().min(1))
515
523
  .min(1)
516
524
  .optional()
517
525
  .describe(
518
- "Optional list of local file or image paths to send as attachments in one call (uses `text` as optional caption)",
526
+ "Local paths of several files or images to send in one call, on the machine this server runs on. `text` is the caption.",
519
527
  ),
520
528
  link: z
521
529
  .string()
522
530
  .url()
523
531
  .optional()
524
- .describe("Optional URL to send as a rich link preview card (uses `text` as optional caption)"),
532
+ .describe("A URL to send as a link preview card. `text` is the caption."),
525
533
  cardUserId: z
526
534
  .string()
527
535
  .min(1)
528
536
  .optional()
529
- .describe("Optional Zalo user ID to share as a contact card in this thread"),
537
+ .describe("The user id whose contact card to share in this conversation."),
530
538
  threadType: z
531
539
  .number()
532
540
  .int()
@@ -534,15 +542,13 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
534
542
  .max(1)
535
543
  .optional()
536
544
  .describe(
537
- "Thread type: 0=DM(User), 1=Group. Omit it and the cached thread type is " +
538
- "used, which is what the CLI does — pass a value only to override that.",
545
+ "0 = DM, 1 = group. Leave it out: the type stored for this thread is used. Pass it only to override that.",
539
546
  ),
540
547
  quoteMsgId: z
541
548
  .string()
542
549
  .optional()
543
550
  .describe(
544
- "msgId of a cached TEXT message in this thread to quote-reply to. " +
545
- "Get it from zalo_get_messages or zalo_get_history.",
551
+ "msgId of a TEXT message in this conversation to quote in your reply (from zalo_get_messages or zalo_get_history; it must be in the local cache). Photos, stickers and other non-text messages cannot be quoted.",
546
552
  ),
547
553
  format: z
548
554
  .enum(["markdown", "plain"])
@@ -609,6 +615,7 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
609
615
  kind: "card",
610
616
  cardUserId,
611
617
  messageId,
618
+ msgId: messageId,
612
619
  ...(cliMsgId !== null && { cliMsgId: String(cliMsgId) }),
613
620
  threadType,
614
621
  ...(thread.notice && { threadId, notice: thread.notice }),
@@ -625,6 +632,7 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
625
632
  kind: "link",
626
633
  link,
627
634
  messageId,
635
+ msgId: messageId,
628
636
  ...(cliMsgId !== null && { cliMsgId: String(cliMsgId) }),
629
637
  threadType,
630
638
  ...(thread.notice && { threadId, notice: thread.notice }),
@@ -670,6 +678,7 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
670
678
  files: absPaths,
671
679
  sent,
672
680
  messageId,
681
+ msgId: messageId,
673
682
  ...(cliMsgId !== null && { cliMsgId: String(cliMsgId) }),
674
683
  threadType,
675
684
  ...(thread.notice && { threadId, notice: thread.notice }),
@@ -747,27 +756,38 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
747
756
  }
748
757
  }
749
758
 
759
+ // Never changes the text: it only tells the agent, in this reply, what it sent.
760
+ if (hasLiteralNewline(text)) warnings.push(LITERAL_NEWLINE_WARNING);
761
+
750
762
  // 1 (important) or 2 (urgent), mapped exactly as `msg send
751
763
  // --urgency` maps it; zca-js turns it into the `metaData:
752
764
  // {urgency}` Zalo Web sends. "normal" is no urgency at all.
753
765
  const urgency = urgencyIn ? urgencyLevel(urgencyIn) : null;
754
766
 
755
- // A plain send stays a plain string: the object form is only
756
- // built when there is something to put in it.
757
- const hasExtras = mentions.length > 0 || styles.length > 0 || Boolean(quote) || Boolean(urgency);
758
- const content = hasExtras
759
- ? {
760
- msg: expanded.text,
761
- ...(mentions.length > 0 && { mentions }),
762
- ...(styles.length > 0 && { styles }),
763
- ...(quote && { quote }),
764
- ...(urgency && { urgency }),
765
- }
766
- : expanded.text;
767
+ // Zalo refuses one oversized message (114 plain, 112 styled), so a long text goes out as Web sends
768
+ // it: split at 3000 bytes of text plus styles, in order, 300 ms apart. The quote and the urgency
769
+ // belong to the first part only.
770
+ const parts = splitMessage({ text: expanded.text, styles, mentions });
771
+ const contentOf = (part, index) => {
772
+ const first = index === 0;
773
+ const extras = part.mentions.length > 0 || part.styles.length > 0 || (first && (quote || urgency));
774
+ return extras
775
+ ? {
776
+ msg: part.text,
777
+ ...(part.mentions.length > 0 && { mentions: part.mentions }),
778
+ ...(part.styles.length > 0 && { styles: part.styles }),
779
+ ...(first && quote && { quote }),
780
+ ...(first && urgency && { urgency }),
781
+ }
782
+ : part.text;
783
+ };
767
784
 
768
785
  for (const w of warnings) console.error("[mcp-tools] zalo_send_message warning:", w);
769
786
 
770
- const result = await api.sendMessage(content, threadId, Number(threadType));
787
+ const results = await sendParts(parts, (part, index) =>
788
+ api.sendMessage(contentOf(part, index), threadId, Number(threadType)),
789
+ );
790
+ const result = results[0];
771
791
  const messageId = result?.message?.msgId ?? result?.msgId ?? null;
772
792
  // `msg react`, `msg undo` and a later quote all key off cliMsgId,
773
793
  // and it appears in no other output. Without it an agent cannot
@@ -776,6 +796,7 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
776
796
  return ok({
777
797
  success: true,
778
798
  messageId,
799
+ msgId: messageId,
779
800
  ...(cliMsgId !== null && { cliMsgId: String(cliMsgId) }),
780
801
  threadType,
781
802
  // Only for `me`: the thread it really went to, which is
@@ -786,6 +807,19 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
786
807
  ...(expanded.text !== text && { text: expanded.text }),
787
808
  ...(styles.length > 0 && { formatted: styles.length }),
788
809
  ...(unresolved.length > 0 && { unresolvedMentions: unresolved }),
810
+ // Only when it was split: every part's ids, in order (msgId/cliMsgId above are the first).
811
+ ...(parts.length > 1 && {
812
+ splitInto: parts.length,
813
+ parts: results.map((r) => {
814
+ const partId = r?.message?.msgId ?? r?.msgId ?? null;
815
+ const partCli = r?.message?.cliMsgId ?? r?.cliMsgId ?? null;
816
+ return {
817
+ msgId: partId,
818
+ messageId: partId,
819
+ ...(partCli !== null && { cliMsgId: String(partCli) }),
820
+ };
821
+ }),
822
+ }),
789
823
  ...(warnings.length > 0 && { warnings }),
790
824
  });
791
825
  } catch (e) {
@@ -801,9 +835,8 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
801
835
  {
802
836
  title: "List Zalo Threads",
803
837
  description:
804
- "List all Zalo threads currently buffered with unread message counts. Useful for discovering active " +
805
- "conversations. `unread` is this consumer's own count; `readState` is how far the account itself " +
806
- "has read the conversation on Zalo (the human, on any device), null when the listener has not heard.",
838
+ "List the conversations that have received a message since this server started (the live buffer), each with this consumer's unread count. Use it to see what is active right now; use zalo_list_conversations for recent conversations whenever they last had a message, and zalo_search_threads to find one by name. " +
839
+ "Returns `{ threads, total }`; each thread has `threadId`, `name`, `threadType`, `unread` (this consumer's own count), `total`, `lastActivity` and `readState` (how far the account owner has read it on Zalo, on any device; null when unknown).",
807
840
  inputSchema: z.object({
808
841
  type: z
809
842
  .enum(["group", "dm", "all"])
@@ -851,7 +884,8 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
851
884
  {
852
885
  title: "Search Zalo Threads",
853
886
  description:
854
- "Search threads (groups/DMs) by name. Uses fuzzy Vietnamese-aware matching. Useful for finding a thread ID by name.",
887
+ "Find a conversation by name, to get its `threadId`. It is a fuzzy search that ignores case and Vietnamese accents, over the names this server knows (cached contacts and groups). To find a person by phone number or username instead, use zalo_lookup_user. " +
888
+ "Returns `{ results, total }`; use a result's `threadId` in zalo_send_message, zalo_get_history and the other tools.",
855
889
  inputSchema: z.object({
856
890
  query: z.string().min(1).describe("Search keyword (fuzzy match, case-insensitive, accent-insensitive)"),
857
891
  type: z
@@ -882,9 +916,9 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
882
916
  {
883
917
  title: "Mark Zalo Messages Read",
884
918
  description:
885
- "Mark buffered messages up to and including the given cursor as read for this consumer. Use the " +
886
- "cursor returned by zalo_get_messages. Other consumers keep their own cursor, and nothing is " +
887
- "deleted: messages leave the buffer only by age or size.",
919
+ "Mark messages as read for this consumer, up to and including `cursor` (the `cursor` of the last zalo_get_messages or zalo_wait_for_message reply). Call it once a batch is handled, or the same messages come back. " +
920
+ "It only moves this consumer's read cursor: other bots keep their own, nothing is deleted (messages leave the buffer by age or size), and nothing is marked read on Zalo, so the owner's own unread state is unchanged. " +
921
+ "Returns `{ success, marked, readCursor }`.",
888
922
  inputSchema: z.object({
889
923
  cursor: z
890
924
  .number()
@@ -911,19 +945,14 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
911
945
  {
912
946
  title: "Get Zalo Message History",
913
947
  description:
914
- "Fetch historical messages from a Zalo DM or group conversation. " +
915
- "senderName preserves the historical snapshot; senderDisplayName resolves the current label by UID " +
916
- "(your alias, then Zalo profile name, then UID), with senderDisplayNameSource alias/zalo/uid. " +
917
- "Reads the local cache first (everything the listener and `zalo-agent sync` " +
918
- "have stored, which can be the full history), and falls back to asking the Zalo server " +
919
- "when the cache has nothing for the thread: a group's cloud-message store first, then the " +
920
- "socket stream, caching what comes back. Zalo serves only messages since this login that " +
921
- "way; `filtered: true` means it withheld older ones, which only `zalo-agent sync` restores. " +
922
- "Page the cache with 'before' (epoch ms, from the previous response's cursor) and the server " +
923
- "path with 'lastMsgId'. " +
924
- "WARNING: Large limits may consume significant memory/bandwidth. Start with a small limit and paginate.",
948
+ "Get the stored messages of one conversation (DM or group), for context older than the live buffer. It reads the local cache first (everything the listener and `zalo-agent sync` have saved, which can be the full history) and asks the Zalo server only when the cache has nothing for the thread. " +
949
+ "Page back with `before` (epoch ms: the `cursor` of the previous reply) or, on the server path, `lastMsgId` (the previous reply's `cursor`). Zalo's server serves only messages since this login: `filtered: true` in the reply means older ones exist that only `zalo-agent sync` can restore, and that needs the owner's phone. " +
950
+ "Each message has `msgId`, `senderId`, `senderDisplayName` (the current name: your alias, else the Zalo profile name, else the uid; `senderDisplayNameSource` says which), `senderName` (the name when first seen), `text`, `timestamp` and `type`. Reply: `{ threadId, threadType, count, messages, cursor, hasMore }`. " +
951
+ "Start with a small `limit` and page; a large one costs memory and time. To find messages by their text, use zalo_search_messages.",
925
952
  inputSchema: z.object({
926
- threadId: z.string().describe("Thread ID to fetch history from"),
953
+ threadId: z
954
+ .string()
955
+ .describe("The conversation: the other person's user id for a DM, the group id for a group."),
927
956
  threadType: z
928
957
  .number()
929
958
  .int()
@@ -931,21 +960,29 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
931
960
  .max(1)
932
961
  .optional()
933
962
  .describe(
934
- "Thread type: 0=DM(User), 1=Group. Omit it and the cached thread type is " +
935
- "used, which is what the CLI does — pass a value only to override that.",
963
+ "0 = DM, 1 = group. Leave it out: the type stored for this thread is used. " +
964
+ "Pass it only to override that.",
936
965
  ),
937
- limit: z.number().int().min(1).max(200).default(50).describe("Max messages to fetch"),
966
+ limit: z
967
+ .number()
968
+ .int()
969
+ .min(1)
970
+ .max(200)
971
+ .default(50)
972
+ .describe("Most messages to return (1 to 200; default 50)."),
938
973
  lastMsgId: z
939
974
  .string()
940
975
  .optional()
941
976
  .nullable()
942
- .describe("Cursor: last message ID from previous fetch, for the server-side path"),
977
+ .describe("Server path only: the `cursor` of the previous reply, to page further back."),
943
978
  before: z
944
979
  .number()
945
980
  .int()
946
981
  .positive()
947
982
  .optional()
948
- .describe("Cursor: return cached messages older than this epoch-ms timestamp"),
983
+ .describe(
984
+ "Cache path: only messages older than this epoch-ms timestamp (the `cursor` of the previous reply), to page back.",
985
+ ),
949
986
  }),
950
987
  },
951
988
  async ({ threadId, threadType: threadTypeIn, limit, lastMsgId, before }) => {
@@ -1056,19 +1093,24 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
1056
1093
  {
1057
1094
  title: "View Zalo Media",
1058
1095
  description:
1059
- "Open a Zalo media file (image, audio, video) with the system viewer. " +
1060
- "Media is auto-downloaded when received, organized by thread folder with date/sender metadata filenames. " +
1061
- "If not yet downloaded, downloads first then opens.",
1096
+ "Get a received photo, voice message, video or file as a local file, and by default open it with the viewer of the machine this server runs on. Media is downloaded when a message arrives; if the file is not on disk yet it is downloaded first. " +
1097
+ "Give the message's `msgId`. Returns `{ success, path, mediaType }`: `path` is where the file is on the server's machine. Pass `open: false` when you only need the path (for example on a remote or headless server).",
1062
1098
  inputSchema: z.object({
1063
- messageId: z.string().describe("Message ID from zalo_get_messages that has a media attachment"),
1064
- threadId: z.string().optional().describe("Thread ID to search in. Omit to search all threads."),
1099
+ msgId: z
1100
+ .string()
1101
+ .describe(
1102
+ "msgId of a message that has a photo, voice message, video or file (from zalo_get_messages, zalo_wait_for_message or zalo_get_history).",
1103
+ ),
1104
+ threadId: z.string().optional().describe("Only look in this conversation. Omit to search all of them."),
1065
1105
  open: z
1066
1106
  .boolean()
1067
1107
  .default(mediaConfig.autoOpen ?? true)
1068
- .describe("Open media with system viewer"),
1108
+ .describe(
1109
+ "Also open the file with the viewer of the machine this server runs on. Pass false when you only need the path.",
1110
+ ),
1069
1111
  }),
1070
1112
  },
1071
- async ({ messageId, threadId, open }) => {
1113
+ async ({ msgId, threadId, open }) => {
1072
1114
  try {
1073
1115
  // The cached row is authoritative: it records where a fetched
1074
1116
  // file actually landed, so a message that arrived before this
@@ -1076,14 +1118,14 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
1076
1118
  // fallback for a thread id.
1077
1119
  let row = null;
1078
1120
  try {
1079
- row = getMessageById(messageId);
1121
+ row = getMessageById(msgId);
1080
1122
  } catch (e) {
1081
1123
  console.error("[mcp-tools] cache unavailable:", e.message);
1082
1124
  }
1083
- const buffered = buffer.read(threadId, 0, 9999).messages.find((m) => m.id === messageId) || null;
1084
- if (!row && !buffered) return err(`Message ${messageId} not found in cache or buffer`);
1125
+ const buffered = buffer.read(threadId, 0, 9999).messages.find((m) => m.id === msgId) || null;
1126
+ if (!row && !buffered) return err(`Message ${msgId} not found in cache or buffer`);
1085
1127
  if (row && !row.has_attachment && !buffered?.attachment?.url) {
1086
- return err(`Message ${messageId} has no media attachment`);
1128
+ return err(`Message ${msgId} has no media attachment`);
1087
1129
  }
1088
1130
 
1089
1131
  let localPath = row?.localPath || null;
@@ -1098,10 +1140,10 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
1098
1140
  concurrency: 2,
1099
1141
  mediaRoot: process.env.ZALO_MEDIA_DIR || mediaConfig.downloadDir || undefined,
1100
1142
  });
1101
- localPath = getMessageById(messageId)?.localPath || null;
1143
+ localPath = getMessageById(msgId)?.localPath || null;
1102
1144
  if (!localPath) {
1103
1145
  return err(
1104
- `Could not fetch media for ${messageId} (${stats.expired} expired, ` +
1146
+ `Could not fetch media for ${msgId} (${stats.expired} expired, ` +
1105
1147
  `${stats.throttled} rate limited, ${stats.unknown} unexplained, ` +
1106
1148
  `${stats.failed} failed). Rate limiting clears on a retry; an unexplained ` +
1107
1149
  `failure is usually a 403, which Zalo returns for a lapsed signature and ` +
@@ -1128,8 +1170,8 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
1128
1170
  .max(1)
1129
1171
  .optional()
1130
1172
  .describe(
1131
- "Thread type: 0=DM(User), 1=Group. Omit it and the cached thread type is " +
1132
- "used, which is what the CLI does — pass a value only to override that.",
1173
+ "0 = DM, 1 = group. Leave it out: the type stored for this thread is used. " +
1174
+ "Pass it only to override that.",
1133
1175
  );
1134
1176
  const cliMsgIdSchema = z
1135
1177
  .string()
@@ -1146,19 +1188,28 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
1146
1188
  {
1147
1189
  title: "React to a Zalo Message",
1148
1190
  description:
1149
- "Add a reaction to a message, as `msg react` does. `reaction` is a Zalo reaction code: " +
1150
- "`/-strong` (like), `/-heart` (heart), `:>` (haha), `:o` (wow), `:-((` (cry), `:-h` (angry), " +
1151
- "`:-*` (kiss), `:')` (tears of joy), `/-weak` (dislike). Zalo keys a reaction on the message's " +
1152
- "cliMsgId as well as its msgId, so the cliMsgId comes from `cliMsgId` or the local cache; when " +
1153
- "neither has it the reaction is refused and nothing is sent — Zalo accepts a reaction keyed on the " +
1154
- "msgId alone and never shows it. `threadId` may be `me` for My Documents.",
1191
+ "React to a message with a Zalo reaction. `reaction` is a reaction code, not an emoji character: `/-strong` like, `/-heart` heart, `:>` haha, `:o` wow, `:-((` cry, `:-h` angry, `:-*` kiss, `:')` tears of joy, `/-weak` dislike. " +
1192
+ "Give the message's `msgId` (the `msgId` of a message from zalo_get_messages, zalo_wait_for_message, zalo_get_history or zalo_search_messages, or of a zalo_send_message reply). Zalo also keys a reaction on the message's `cliMsgId`, which is read from the local cache unless you pass it (zalo_send_message returns it for your own messages); when neither has it the call is refused and nothing is sent, because Zalo accepts a reaction without it and never shows it. " +
1193
+ "`threadId` may be `me` for My Documents. Returns `{ success, reaction, msgId, cliMsgId, threadId, threadType }`.",
1155
1194
  inputSchema: z.object({
1156
1195
  msgId: z
1157
1196
  .string()
1158
1197
  .min(1)
1159
- .describe("msgId of the message to react to (from zalo_get_messages or zalo_get_history)"),
1160
- threadId: z.string().min(1).describe("The conversation the message is in. `me` is My Documents"),
1161
- reaction: z.string().min(1).describe("Reaction code, e.g. `/-strong`, `/-heart`, `:>`"),
1198
+ .describe(
1199
+ "msgId of the message to react to: the `msgId` of a message from zalo_get_messages, zalo_wait_for_message, zalo_get_history or zalo_search_messages, or of a zalo_send_message reply.",
1200
+ ),
1201
+ threadId: z
1202
+ .string()
1203
+ .min(1)
1204
+ .describe(
1205
+ "The conversation the message is in: the user id for a DM, the group id for a group. `me` is My Documents.",
1206
+ ),
1207
+ reaction: z
1208
+ .string()
1209
+ .min(1)
1210
+ .describe(
1211
+ "A Zalo reaction code, not an emoji character: `/-strong` like, `/-heart` heart, `:>` haha, `:o` wow, `:-((` cry, `:-h` angry, `:-*` kiss, `:')` tears of joy, `/-weak` dislike.",
1212
+ ),
1162
1213
  threadType: threadTypeSchema,
1163
1214
  cliMsgId: cliMsgIdSchema,
1164
1215
  }),
@@ -1203,13 +1254,21 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
1203
1254
  {
1204
1255
  title: "Recall a Zalo Message",
1205
1256
  description:
1206
- "Recall one of your own messages for everyone in the conversation, as `msg undo` does (the apps' " +
1207
- "Thu hồi). Zalo names the message by msgId and cliMsgId, so the cliMsgId comes from `cliMsgId` or " +
1208
- "the local cache; when neither has it nothing is sent. Zalo accepts a recall only for a while after " +
1209
- "the message was sent. `threadId` may be `me` for My Documents.",
1257
+ 'Recall (unsend) one of your own messages for everyone in the conversation, as the apps\' "Thu hồi" does. Zalo accepts a recall only for a while after the message was sent. ' +
1258
+ "Give the message's `msgId` (from the zalo_send_message reply, or from zalo_get_history) and its `cliMsgId` (also in the send reply; read from the local cache when you leave it out); with neither, nothing is sent. To remove a message from your own view only, or someone else's message in a group as an admin, use zalo_delete_message. `threadId` may be `me` for My Documents.",
1210
1259
  inputSchema: z.object({
1211
- msgId: z.string().min(1).describe("msgId of your message to recall"),
1212
- threadId: z.string().min(1).describe("The conversation the message is in. `me` is My Documents"),
1260
+ msgId: z
1261
+ .string()
1262
+ .min(1)
1263
+ .describe(
1264
+ "msgId of your own message to recall (from the zalo_send_message reply, or from zalo_get_history).",
1265
+ ),
1266
+ threadId: z
1267
+ .string()
1268
+ .min(1)
1269
+ .describe(
1270
+ "The conversation the message is in: the user id for a DM, the group id for a group. `me` is My Documents.",
1271
+ ),
1213
1272
  threadType: threadTypeSchema,
1214
1273
  cliMsgId: cliMsgIdSchema,
1215
1274
  }),
@@ -1250,9 +1309,7 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
1250
1309
  {
1251
1310
  title: "List Zalo Group Members",
1252
1311
  description:
1253
- "List a group's members, as `group members` does: each member's uid, with a display name — from " +
1254
- "the local cache, or from Zalo for members the cache cannot name. `totalMember` is Zalo's own " +
1255
- "count. A member's uid can be @-mentioned in zalo_send_message as `@[uid]`.",
1312
+ "List the members of a group: each member's `userId` (`uid` is the same value, kept for older clients) and `displayName` (from the cache, or from Zalo for members the cache cannot name), and `totalMember`, Zalo's own count (it can be larger than the members listed). Use a `userId` as `@[userId]` in zalo_send_message to mention that member.",
1256
1313
  inputSchema: z.object({
1257
1314
  groupId: z
1258
1315
  .string()
@@ -1291,7 +1348,7 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
1291
1348
  }
1292
1349
  for (const w of warnings) console.error("[mcp-tools] zalo_get_group_members warning:", w);
1293
1350
 
1294
- const members = group.uids.map((uid) => ({ uid, displayName: names.get(uid) ?? null }));
1351
+ const members = group.uids.map((uid) => ({ userId: uid, uid, displayName: names.get(uid) ?? null }));
1295
1352
  return ok({
1296
1353
  groupId,
1297
1354
  name: group.name,
@@ -1313,13 +1370,8 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
1313
1370
  {
1314
1371
  title: "List Recent Zalo Conversations",
1315
1372
  description:
1316
- "List the conversations this account was most recently active in, newest first, from the local " +
1317
- "cache — what `conv recent` lists: thread id, type, name and last activity. Unlike " +
1318
- "zalo_list_threads it is not limited to what arrived since this server started. `limit` is per " +
1319
- "type, as `conv recent -n` is: with type 'all' that is up to `limit` DMs and `limit` groups. " +
1320
- "`readState` is how far the account itself has read each conversation on Zalo (the human, on any " +
1321
- "device): the newest message read, how many cached messages from others came after it, and whether " +
1322
- "it is marked unread; null when the listener has not heard.",
1373
+ "List the conversations this account was most recently active in, newest first, from the local cache, to get a `threadId` or to see what is current. Unlike zalo_list_threads it is not limited to messages that arrived since this server started. " +
1374
+ "Each has its thread id, type, name and last activity, plus `readState`: how far the account owner has read it on Zalo (on any device), meaning the newest message read, how many cached messages from others came after it, and whether it is marked unread (null when unknown). `limit` is per type: with `type` 'all' that is up to `limit` DMs and `limit` groups.",
1323
1375
  inputSchema: z.object({
1324
1376
  type: z
1325
1377
  .enum(["group", "dm", "all"])
@@ -1379,12 +1431,7 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
1379
1431
  {
1380
1432
  title: "Zalo Cache Coverage",
1381
1433
  description:
1382
- "How complete the local message cache is. Lists the coverage gaps still pending — windows in which " +
1383
- "this account's connection to Zalo was down, so messages from them may be missing — with each " +
1384
- "gap's from/to and reason, counts the gaps already resolved, and names the `zalo-agent sync --from " +
1385
- "<date>` run that restores the pending ones from the owner's phone (it needs a tap on the phone, " +
1386
- "so the owner must approve it). Use zalo_sync only when the owner asks to restore. " +
1387
- "Read-only: nothing is sent to Zalo.",
1434
+ "Check how complete the local message cache is. Lists the coverage gaps still pending (windows in which this account's connection to Zalo was down, so messages from them may be missing), each with its from/to and reason, counts the gaps already resolved, and names the `zalo-agent sync --from <date>` run that restores the pending ones from the owner's phone. That run needs a tap on the phone, so the owner must approve it; call zalo_sync only when the owner asks. Read-only: nothing is sent to Zalo.",
1388
1435
  inputSchema: z.object({}),
1389
1436
  },
1390
1437
  async () => {
@@ -1403,11 +1450,8 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
1403
1450
  {
1404
1451
  title: "Request Zalo Sync",
1405
1452
  description:
1406
- "Start a messages restore or reaction-backlog sync on this MCP account's existing socket. " +
1407
- "Only call when the owner explicitly requests sync; messages sends a confirmation to their phone. " +
1408
- "Returns a job immediately: poll zalo_sync_status every few seconds. Reuse requestId for retries. " +
1409
- "This updates the local cache, not Zalo messages. Messages restores all conversations in the window; " +
1410
- "it is not a per-thread sync. No media download, boards, cloud index or conversation-state sync.",
1453
+ "OWNER ONLY: call it only when the owner explicitly asks. Start a restore of messages (or of the reaction backlog) on this server's existing Zalo connection. The `messages` stage makes the owner's phone ask for a confirmation, which the owner must tap. " +
1454
+ "It returns a job at once: poll zalo_sync_status every few seconds. Reuse the same `requestId` and arguments when retrying, so a retry does not start a second sync. It updates the local cache, not Zalo itself. `messages` restores every conversation in the window; it is not per conversation, and it downloads no media, boards, cloud index or conversation state.",
1411
1455
  inputSchema: z
1412
1456
  .object({
1413
1457
  requestId: z
@@ -1418,7 +1462,12 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
1418
1462
  .describe(
1419
1463
  "Unique request key; reuse the same key and arguments after a timeout to avoid duplicate sync.",
1420
1464
  ),
1421
- stage: z.enum(["messages", "reactions"]).default("messages"),
1465
+ stage: z
1466
+ .enum(["messages", "reactions"])
1467
+ .default("messages")
1468
+ .describe(
1469
+ "`messages` restores chat history from the owner's phone (the phone asks for a confirmation); `reactions` drains Zalo's backlog of reactions (no phone tap).",
1470
+ ),
1422
1471
  days: z
1423
1472
  .number()
1424
1473
  .int()
@@ -1477,10 +1526,16 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
1477
1526
  {
1478
1527
  title: "Zalo Sync Status",
1479
1528
  description:
1480
- "Read sync progress and the final result without starting or repeating sync. Omit jobId for the latest " +
1481
- "job (or idle). Completed means the runner returned: inspect result.reason for partial/timeout/socket loss, " +
1482
- "not proof that all history was restored. Last ten jobs are kept in memory until daemon restart.",
1483
- inputSchema: z.object({ jobId: z.string().uuid().optional() }).strict(),
1529
+ "Read the progress and final result of a zalo_sync job without starting or repeating anything. Omit `jobId` for the latest job (or idle). `completed` only means the runner returned: read `result.reason` for partial, timeout or socket loss; it is not proof that all history was restored. The last ten jobs are kept in memory until the server restarts.",
1530
+ inputSchema: z
1531
+ .object({
1532
+ jobId: z
1533
+ .string()
1534
+ .uuid()
1535
+ .optional()
1536
+ .describe("The job to read: the `jobId` that zalo_sync returned. Omit for the latest job."),
1537
+ })
1538
+ .strict(),
1484
1539
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1485
1540
  },
1486
1541
  async ({ jobId }) => {
@@ -1499,13 +1554,20 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
1499
1554
  {
1500
1555
  title: "Search Zalo Messages",
1501
1556
  description:
1502
- "Search message text across the local SQLite cache (`zalo.db`). Zero network calls. " +
1503
- "Filter optionally by `threadId`, `senderId`, `since` (epoch ms), and `until` (epoch ms). " +
1504
- "Results are ordered newest-first.",
1557
+ "Search the text of messages in the local cache (`zalo.db`), across all conversations or one. No network call. It is a case-insensitive substring match. " +
1558
+ "Narrow it with `threadId`, `senderId`, `since` or `until` (epoch ms). Results are newest first.",
1505
1559
  inputSchema: z.object({
1506
1560
  query: z.string().min(1).describe("Case-insensitive text substring to search for in cached messages"),
1507
- threadId: z.string().min(1).optional().describe("Restrict search to a single thread ID"),
1508
- senderId: z.string().min(1).optional().describe("Restrict search to a specific sender UID"),
1561
+ threadId: z
1562
+ .string()
1563
+ .min(1)
1564
+ .optional()
1565
+ .describe("Only this conversation (its threadId). Omit to search all of them."),
1566
+ senderId: z
1567
+ .string()
1568
+ .min(1)
1569
+ .optional()
1570
+ .describe("Only messages from this user (the `senderId` of a message)"),
1509
1571
  since: z
1510
1572
  .number()
1511
1573
  .int()
@@ -1568,9 +1630,7 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
1568
1630
  {
1569
1631
  title: "Lookup Zalo User",
1570
1632
  description:
1571
- "Look up a Zalo user by phone number (`phone`), username (`username`), user ID (`userId`), or inspect " +
1572
- "this logged-in account's own profile (`self: true`). Useful for finding a contact's `userId` (which is " +
1573
- "also their 1-1 DM `threadId`) before sending a message or contact card.",
1633
+ "Look up a Zalo user by phone number (`phone`), username (`username`) or user id (`userId`), or read this logged-in account's own profile (`self: true`). Use it to find someone's `userId`, which is also the `threadId` of your 1-1 conversation with them, before sending a message or a contact card.",
1574
1634
  inputSchema: z
1575
1635
  .object({
1576
1636
  self: z
@@ -1583,7 +1643,7 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
1583
1643
  .optional()
1584
1644
  .describe("Phone number to search for on Zalo (e.g. '0901234567' or '+84901234567')"),
1585
1645
  username: z.string().min(1).optional().describe("Zalo username to search for"),
1586
- userId: z.string().min(1).optional().describe("Zalo user ID to fetch full profile info for"),
1646
+ userId: z.string().min(1).optional().describe("A Zalo user id, to fetch that user's full profile."),
1587
1647
  })
1588
1648
  .refine(
1589
1649
  (v) =>
@@ -1658,9 +1718,7 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
1658
1718
  {
1659
1719
  title: "Forward Zalo Message",
1660
1720
  description:
1661
- "Forward a cached message (`msgId`) to another Zalo conversation (`threadId`). Text messages are sent " +
1662
- "through Zalo's forward API with the 'forwarded' badge; cards, stickers, links, videos, voices, and " +
1663
- "locally downloaded media attachments are re-sent as a message of their own kind, matching `msg forward`.",
1721
+ "Forward a message from the local cache (`msgId`) to another conversation (`threadId`). Text goes through Zalo's forward API with the 'forwarded' badge; cards, stickers, links, videos, voice messages and downloaded media are re-sent as a message of their own kind, as `msg forward` does.",
1664
1722
  inputSchema: z.object({
1665
1723
  msgId: z
1666
1724
  .string()
@@ -1710,7 +1768,7 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
1710
1768
  const resendLocal = async () => {
1711
1769
  if (!row.localPath || !existsSync(row.localPath)) {
1712
1770
  throw new Error(
1713
- `Message ${msgId} is "${row.type}" but its media is not downloaded locally. Call zalo_view_media with messageId="${msgId}" first.`,
1771
+ `Message ${msgId} is "${row.type}" but its media is not downloaded locally. Call zalo_view_media with msgId="${msgId}" first.`,
1714
1772
  );
1715
1773
  }
1716
1774
  return api.sendMessage(
@@ -1820,12 +1878,16 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
1820
1878
  {
1821
1879
  title: "Delete Zalo Message",
1822
1880
  description:
1823
- "Delete a message from your own view (`everyone: false`, the default) or for everyone in a group " +
1824
- "(`everyone: true`, only valid for someone else's message in a group — to recall your own sent " +
1825
- "message for both sides, use `zalo_undo`). Resolves `cliMsgId` and `uidFrom` from the local cache when omitted.",
1881
+ "Delete a message from your own view only (`everyone: false`, the default), or, as a group admin, delete someone else's message for everyone in the group (`everyone: true`). To recall your own message for both sides, use zalo_undo. `cliMsgId` and `uidFrom` are read from the local cache when you leave them out.",
1826
1882
  inputSchema: z.object({
1827
- msgId: z.string().min(1).describe("Message ID (`msgId`) to delete"),
1828
- threadId: z.string().min(1).describe("Thread ID the message belongs to"),
1883
+ msgId: z
1884
+ .string()
1885
+ .min(1)
1886
+ .describe("msgId of the message to delete (from zalo_get_messages or zalo_get_history)."),
1887
+ threadId: z
1888
+ .string()
1889
+ .min(1)
1890
+ .describe("The conversation the message is in: the user id for a DM, the group id for a group."),
1829
1891
  threadType: z
1830
1892
  .number()
1831
1893
  .int()
@@ -1838,12 +1900,12 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
1838
1900
  .min(1)
1839
1901
  .optional()
1840
1902
  .describe("Client message ID (`cliMsgId`). Looked up in the local cache when omitted."),
1841
- uidFrom: z
1903
+ senderId: z
1842
1904
  .string()
1843
1905
  .min(1)
1844
1906
  .optional()
1845
1907
  .describe(
1846
- "Sender UID (`uidFrom`). Looked up in the local cache or defaults to own UID when omitted.",
1908
+ "The `senderId` of the message: its author's user id. Looked up in the local cache, or your own user id when omitted.",
1847
1909
  ),
1848
1910
  everyone: z
1849
1911
  .boolean()
@@ -1856,7 +1918,7 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
1856
1918
  threadId: threadIdIn,
1857
1919
  threadType: threadTypeIn,
1858
1920
  cliMsgId: cliMsgIdIn,
1859
- uidFrom: uidFromIn,
1921
+ senderId: senderIdIn,
1860
1922
  everyone,
1861
1923
  }) => {
1862
1924
  const thread = actionThread(api, threadIdIn, threadTypeIn);
@@ -1867,7 +1929,7 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
1867
1929
  const { threadId, threadType } = thread;
1868
1930
  try {
1869
1931
  let cliMsgId = cliMsgIdIn;
1870
- let uidFrom = uidFromIn;
1932
+ let uidFrom = senderIdIn;
1871
1933
  if (!cliMsgId || !uidFrom) {
1872
1934
  const cached = cachedMessageById(threadId, msgId);
1873
1935
  cliMsgId = cliMsgId || cached?.cliMsgId;
@@ -2088,7 +2150,11 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
2088
2150
  .describe(
2089
2151
  "Thread type for 'pin_message' / 'unpin_message': 0=DM(User), 1=Group. Inferred from cache when omitted.",
2090
2152
  ),
2091
- noteId: z.string().min(1).optional().describe("Note topicId to update (required for 'edit')"),
2153
+ noteId: z
2154
+ .string()
2155
+ .min(1)
2156
+ .optional()
2157
+ .describe("The note's `topicId`, as listed by action 'list' (required for 'edit')."),
2092
2158
  title: z.string().min(1).optional().describe("Note text/title (required for 'create' and 'edit')"),
2093
2159
  pin: z
2094
2160
  .boolean()
@@ -2098,7 +2164,9 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
2098
2164
  .string()
2099
2165
  .min(1)
2100
2166
  .optional()
2101
- .describe("Message ID (`msgId`) to pin or unpin (required for 'pin_message' and 'unpin_message')"),
2167
+ .describe(
2168
+ "msgId of a cached text message to pin or unpin (required for 'pin_message' and 'unpin_message').",
2169
+ ),
2102
2170
  page: z.number().int().min(1).default(1).describe("Page number for 'list' (default 1)"),
2103
2171
  count: z.number().int().min(1).max(100).default(20).describe("Items per page for 'list' (default 20)"),
2104
2172
  }),
@@ -2236,7 +2304,11 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
2236
2304
  action: z
2237
2305
  .enum(["create", "info", "vote", "unvote", "add_options", "lock", "share"])
2238
2306
  .describe("Poll operation to perform"),
2239
- groupId: z.string().min(1).optional().describe("Group ID (required for 'create')"),
2307
+ groupId: z
2308
+ .string()
2309
+ .min(1)
2310
+ .optional()
2311
+ .describe("The group's id, which is its threadId (required for 'create')."),
2240
2312
  pollId: z
2241
2313
  .union([z.number().int().positive(), z.string().regex(/^\d+$/)])
2242
2314
  .optional()
@@ -2375,13 +2447,8 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
2375
2447
  {
2376
2448
  title: "Wait for Incoming Zalo Message",
2377
2449
  description:
2378
- "Long-poll the live MCP message buffer for the next incoming watched message without manual polling. " +
2379
- "Returns immediately if a buffered message newer than `since` is already waiting; otherwise waits up to " +
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 " +
2384
- "remote HTTPS/OAuth2 MCP clients.",
2450
+ 'Wait for the next incoming message, without polling. It returns at once when a buffered message newer than `since` is already waiting; otherwise it waits up to `timeoutSec` seconds (default 55, max 300) and then returns `status: "timeout"` with no message. Call it in a loop to listen, and call zalo_mark_read with the `cursor` when a message is handled. ' +
2451
+ "`limit` (default 1, max 100) returns up to that many buffered messages in one call, in order, in `messages` (`message` is the first, `cursor` is the last, so `since: cursor` continues where the batch ended; `hasMore` says more are already waiting); `settleMs` waits that long after the first message for the rest of a burst. A message's `id` is its msgId. Mirrors GET /events/wait for remote HTTPS/OAuth2 MCP clients.",
2385
2452
  inputSchema: z.object({
2386
2453
  threadId: z
2387
2454
  .string()
@@ -2493,7 +2560,11 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
2493
2560
  .describe(
2494
2561
  "Label name/text, e.g. 'Nội bộ', 'Khách hàng', 'Đối tác' (case-insensitive match for 'assign' or 'unassign')",
2495
2562
  ),
2496
- threadId: z.string().min(1).optional().describe("Single thread ID to assign or unassign"),
2563
+ threadId: z
2564
+ .string()
2565
+ .min(1)
2566
+ .optional()
2567
+ .describe("One conversation (its threadId) to assign to or remove from the label."),
2497
2568
  threadIds: z
2498
2569
  .array(z.string().min(1))
2499
2570
  .optional()
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The one place an MCP server instance is made, for both transports (stdio and HTTP), so every client is told the
3
+ * same thing on connect: the server's instructions (src/mcp/instructions.js).
4
+ */
5
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
+ import { SERVER_INSTRUCTIONS } from "./instructions.js";
7
+
8
+ /**
9
+ * @param {string} version - the package version, reported as the server's version
10
+ * @returns {McpServer} a server with no tools registered yet
11
+ */
12
+ export function newMcpServer(version) {
13
+ return new McpServer({ name: "zalo-agent", version }, { instructions: SERVER_INSTRUCTIONS });
14
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * A text that has the two characters backslash and `n` where a line break was meant.
3
+ *
4
+ * A model that double-escapes its tool arguments sends `Hello\n\nWorld` as a backslash and an n, and Zalo shows them
5
+ * as typed. The text is never changed here (a backslash-n can be exactly what someone means, in a code sample or a
6
+ * Windows path): the sender is only told, so the agent can see its own mistake in the tool result.
7
+ *
8
+ * It fires only when the text has such a sequence and no real line break at all; a text that has both is a code
9
+ * sample or a deliberate mix, and a text of many lines that happens to contain `\n` once is not the mistake.
10
+ */
11
+
12
+ /**
13
+ * @param {string} text
14
+ * @returns {boolean} true when `text` contains a backslash followed by `n` and no real newline
15
+ */
16
+ export function hasLiteralNewline(text) {
17
+ return typeof text === "string" && text.includes("\\n") && !/[\r\n]/.test(text);
18
+ }
19
+
20
+ /** What the sender is told, in the reply's `warnings`. */
21
+ export const LITERAL_NEWLINE_WARNING =
22
+ "The text has the two characters backslash and n (\\n) and no line break, and Zalo shows them as typed, not as " +
23
+ "a new line. If you meant line breaks, send the message again with real newlines and recall this one with " +
24
+ "zalo_undo (its msgId and cliMsgId are in this reply).";
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Splitting a long text message the way Zalo Web does.
3
+ *
4
+ * Zalo refuses one oversized message instead of cutting it: `114 "Tham số không hợp lệ"` for plain text past 3000
5
+ * UTF-16 units, and `112 "Lỗi không xác định"` for a styled message (what Markdown becomes) whose text plus style
6
+ * list passes roughly 3.1 to 3.5 KB. Chinese and long bulleted answers hit the second one early, because a Chinese
7
+ * character is three bytes and each bullet carries a style entry. Measured live, 2026-10-29.
8
+ *
9
+ * The Web client never sends such a message. It budgets every message at `limitMsgByte` (3000) UTF-8 bytes of the
10
+ * text plus, for a styled message, the JSON of its styles; cuts at a newline when it can, else at a sentence end or
11
+ * a space; trims the pieces; and sends them one after another, 300 ms apart. This does the same.
12
+ *
13
+ * Pure: no I/O, no Zalo. Styles are `{ start, len, st }` and mentions `{ pos, len, uid }`, both in UTF-16 units of
14
+ * the text they belong to; each part gets its own text and the ranges re-based to it.
15
+ */
16
+
17
+ /** Zalo Web's `limitMsgByte`: the most UTF-8 bytes of text (plus style JSON) in one message. */
18
+ export const MESSAGE_BYTE_LIMIT = 3000;
19
+
20
+ /** The pause between parts of one split message, as Web's `setTimeout(…, 300)`. */
21
+ export const PART_GAP_MS = 300;
22
+
23
+ const byteLength = (s) => Buffer.byteLength(s, "utf8");
24
+
25
+ const graphemes = new Intl.Segmenter("und", { granularity: "grapheme" });
26
+
27
+ /**
28
+ * Move `cut` back to the nearest boundary between user-perceived characters, so a hard cut never splits a Thai or
29
+ * Devanagari syllable (a base letter and its vowel and tone marks), an Arabic letter and its marks, a flag, a skin
30
+ * tone or an emoji joined by zero-width joiners. Only looks a little either side: a cluster is never that long.
31
+ *
32
+ * @returns {number} a boundary in `(pos, cut]`, or `cut` itself when none is found
33
+ */
34
+ function snapToGrapheme(text, pos, cut) {
35
+ if (cut <= pos || cut >= text.length) return cut;
36
+ const from = Math.max(pos, cut - 64);
37
+ let best = -1;
38
+ for (const { index } of graphemes.segment(text.slice(from, cut + 64))) {
39
+ const at = from + index;
40
+ if (at > cut) break;
41
+ if (at > pos) best = at;
42
+ }
43
+ return best > pos ? best : cut;
44
+ }
45
+
46
+ /** Style ranges clipped to `[from, to)` and re-based to `from`. */
47
+ function clipStyles(styles, from, to) {
48
+ const out = [];
49
+ for (const s of styles) {
50
+ const a = Math.max(s.start, from);
51
+ const b = Math.min(s.start + s.len, to);
52
+ if (b > a) out.push({ ...s, start: a - from, len: b - a });
53
+ }
54
+ return out;
55
+ }
56
+
57
+ /** What one message costs against the limit: its text bytes plus its style list, as Web counts it. */
58
+ function cost(text, styles) {
59
+ return byteLength(text) + (styles.length > 0 ? byteLength(JSON.stringify(styles)) : 0);
60
+ }
61
+
62
+ /**
63
+ * Sentence and clause marks of the scripts Zalo is used in, ranked like Web's ASCII ones (a full stop strongest). They
64
+ * need no space after them: Chinese, Japanese, Thai, Burmese and Khmer write none.
65
+ */
66
+ const MARKS = {
67
+ // full stops: CJK, fullwidth, Devanagari danda and double danda, Arabic/Urdu, Armenian, Ethiopic, Burmese, Khmer
68
+ "。": 6,
69
+ ".": 6,
70
+ "।": 6,
71
+ "॥": 6,
72
+ "۔": 6,
73
+ "։": 6,
74
+ "።": 6,
75
+ "။": 6,
76
+ "។": 6,
77
+ "៕": 6,
78
+ // question marks: fullwidth, Arabic
79
+ "?": 5,
80
+ "؟": 5,
81
+ // exclamation: fullwidth
82
+ "!": 4,
83
+ // semicolons: fullwidth, Arabic
84
+ ";": 2,
85
+ "؛": 2,
86
+ // commas: fullwidth, ideographic, halfwidth ideographic, Arabic, Ethiopic; and the ideographic space
87
+ ",": 1,
88
+ "、": 1,
89
+ "、": 1,
90
+ "،": 1,
91
+ "፣": 1,
92
+ "\u3000": 0,
93
+ };
94
+
95
+ /**
96
+ * How good a place `text[i]` is to end a part after: higher is better, null when it is no break at all.
97
+ * Web's own order (`" ,;)!?."`, a full stop strongest), a newline above all, and the marks other scripts use for the
98
+ * same jobs (Web's list is ASCII only, so a long Chinese, Hindi or Arabic text with few spaces would be cut blind).
99
+ */
100
+ function breakRank(text, i) {
101
+ const c = text[i];
102
+ if (c === "\n") return 7;
103
+ const mark = MARKS[c];
104
+ if (mark !== undefined) return mark;
105
+ const ascii = " ,;)!?.".indexOf(c);
106
+ if (ascii === -1) return null;
107
+ const next = text[i + 1];
108
+ return c === " " || next === undefined || next === " " || next === "\n" ? ascii : null;
109
+ }
110
+
111
+ /**
112
+ * Where to cut `text` between `pos` and `end` (the farthest the budget allows): the nearest newline in the last two
113
+ * thirds of the window, else the strongest sentence or clause end there, else `end` itself.
114
+ *
115
+ * @returns {number} the cut: the part is `text.slice(pos, cut)`
116
+ */
117
+ function chooseCut(text, pos, end) {
118
+ const floor = pos + Math.floor((end - pos) / 3);
119
+ let best = -1;
120
+ let bestRank = -1;
121
+ for (let i = end - 1; i >= floor && i >= pos; i--) {
122
+ const rank = breakRank(text, i);
123
+ if (rank === null) continue;
124
+ if (rank === 7) return i; // the newline is dropped with the whitespace that follows
125
+ if (rank > bestRank) {
126
+ best = i + 1;
127
+ bestRank = rank;
128
+ }
129
+ }
130
+ return best > pos ? best : end;
131
+ }
132
+
133
+ /**
134
+ * Split one message into parts that each fit {@link MESSAGE_BYTE_LIMIT}. A message that fits comes back untouched
135
+ * (the same text, no trimming), so only a message that needed splitting is ever changed.
136
+ *
137
+ * A style range that spans a cut is clipped into both parts. A mention is never cut: the cut moves before it. A
138
+ * user-perceived character (an emoji, a flag, a Thai or Devanagari syllable with its marks) is never cut. Whitespace
139
+ * at a cut is dropped, as Web trims its parts.
140
+ *
141
+ * @param {{text: string, styles?: Array<{start: number, len: number, st: string}>, mentions?: Array<{pos: number, len: number, uid: string}>}} message
142
+ * @param {number} [limit] - bytes per part
143
+ * @returns {Array<{text: string, styles: object[], mentions: object[]}>} at least one part
144
+ */
145
+ export function splitMessage({ text, styles = [], mentions = [] }, limit = MESSAGE_BYTE_LIMIT) {
146
+ if (cost(text, styles) <= limit) return [{ text, styles, mentions }];
147
+
148
+ const parts = [];
149
+ let pos = 0;
150
+ while (pos < text.length) {
151
+ // The farthest end this part can reach inside the budget (always at least one character).
152
+ let lo = pos + 1;
153
+ let hi = text.length;
154
+ while (lo < hi) {
155
+ const mid = Math.ceil((lo + hi) / 2);
156
+ if (cost(text.slice(pos, mid), clipStyles(styles, pos, mid)) <= limit) lo = mid;
157
+ else hi = mid - 1;
158
+ }
159
+ let cut = lo;
160
+
161
+ if (cut < text.length) {
162
+ cut = chooseCut(text, pos, cut);
163
+ for (const m of mentions) {
164
+ if (m.pos > pos && m.pos < cut && cut < m.pos + m.len) cut = m.pos;
165
+ }
166
+ cut = snapToGrapheme(text, pos, cut);
167
+ const high = text.charCodeAt(cut - 1);
168
+ if (high >= 0xd800 && high <= 0xdbff) cut -= 1;
169
+ if (cut <= pos) cut = Math.min(text.length, pos + 2);
170
+ }
171
+
172
+ const raw = text.slice(pos, cut);
173
+ const body = raw.trimEnd();
174
+ if (body.length > 0) {
175
+ const end = pos + body.length;
176
+ parts.push({
177
+ text: body,
178
+ styles: clipStyles(styles, pos, end),
179
+ mentions: mentions
180
+ .filter((m) => m.pos >= pos && m.pos + m.len <= end)
181
+ .map((m) => ({ ...m, pos: m.pos - pos })),
182
+ });
183
+ }
184
+ pos = cut;
185
+ // Web trims each remainder: the whitespace at a cut belongs to no part.
186
+ while (pos < text.length && /\s/.test(text[pos])) pos++;
187
+ }
188
+ return parts.length > 0 ? parts : [{ text, styles, mentions }];
189
+ }
190
+
191
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
192
+
193
+ /**
194
+ * Send the parts of one message one after another, {@link PART_GAP_MS} apart. Stops at the first part Zalo refuses
195
+ * and says how many had already gone out, because nothing can take those back.
196
+ *
197
+ * @template T
198
+ * @param {Array<object>} parts - from {@link splitMessage}
199
+ * @param {(part: object, index: number) => Promise<T>} send - sends one part, resolves what Zalo answered
200
+ * @param {{gapMs?: number, sleep?: (ms: number) => Promise<void>}} [opts]
201
+ * @returns {Promise<T[]>} what Zalo answered for each part, in order
202
+ * @throws {Error} with `.sent` (the answers of the parts that went out) set when a later part failed
203
+ */
204
+ export async function sendParts(parts, send, { gapMs = PART_GAP_MS, sleep = defaultSleep } = {}) {
205
+ const answers = [];
206
+ for (let i = 0; i < parts.length; i++) {
207
+ if (i > 0) await sleep(gapMs);
208
+ try {
209
+ answers.push(await send(parts[i], i));
210
+ } catch (e) {
211
+ if (i === 0) throw e;
212
+ const failure = new Error(
213
+ `${e?.message ?? e} (part ${i + 1} of ${parts.length} failed; the ${i} earlier part(s) were already delivered)`,
214
+ { cause: e },
215
+ );
216
+ failure.sent = answers;
217
+ if (e?.code !== undefined) failure.code = e.code;
218
+ throw failure;
219
+ }
220
+ }
221
+ return answers;
222
+ }