@ardennguyen/zalo-agent-cli 2.5.6 → 2.5.8

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.
@@ -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 character is 3) 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()