@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 +1 -1
- package/src/commands/mcp.js +3 -0
- package/src/commands/msg.js +37 -13
- package/src/mcp/instructions.js +32 -0
- package/src/mcp/mcp-http-transport.js +2 -2
- package/src/mcp/mcp-server.js +3 -6
- package/src/mcp/mcp-tools.js +229 -158
- package/src/mcp/new-server.js +14 -0
- package/src/utils/literal-newline.js +24 -0
- package/src/utils/split-message.js +222 -0
package/package.json
CHANGED
package/src/commands/mcp.js
CHANGED
|
@@ -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",
|
package/src/commands/msg.js
CHANGED
|
@@ -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
|
|
683
|
-
const
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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, () =>
|
|
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 {
|
|
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 =
|
|
542
|
+
const server = newMcpServer(pkg.version);
|
|
543
543
|
registerToolsFn(
|
|
544
544
|
server,
|
|
545
545
|
deps.api,
|
package/src/mcp/mcp-server.js
CHANGED
|
@@ -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 =
|
|
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
|
|
package/src/mcp/mcp-tools.js
CHANGED
|
@@ -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
|
-
"
|
|
416
|
-
"
|
|
417
|
-
"Each message
|
|
418
|
-
"
|
|
419
|
-
"
|
|
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
|
|
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(
|
|
429
|
-
|
|
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
|
|
483
|
-
|
|
484
|
-
"
|
|
485
|
-
"
|
|
486
|
-
|
|
487
|
-
"
|
|
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(
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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("
|
|
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("
|
|
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
|
-
"
|
|
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
|
|
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
|
-
//
|
|
756
|
-
//
|
|
757
|
-
|
|
758
|
-
const
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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
|
|
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
|
|
805
|
-
"
|
|
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
|
-
"
|
|
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
|
|
886
|
-
"
|
|
887
|
-
"
|
|
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
|
-
"
|
|
915
|
-
"
|
|
916
|
-
"(your alias,
|
|
917
|
-
"
|
|
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
|
|
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
|
-
"
|
|
935
|
-
"
|
|
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
|
|
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("
|
|
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(
|
|
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
|
-
"
|
|
1060
|
-
"
|
|
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
|
-
|
|
1064
|
-
|
|
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(
|
|
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 ({
|
|
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(
|
|
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 ===
|
|
1084
|
-
if (!row && !buffered) return err(`Message ${
|
|
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 ${
|
|
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(
|
|
1143
|
+
localPath = getMessageById(msgId)?.localPath || null;
|
|
1102
1144
|
if (!localPath) {
|
|
1103
1145
|
return err(
|
|
1104
|
-
`Could not fetch media for ${
|
|
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
|
-
"
|
|
1132
|
-
"
|
|
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
|
-
"
|
|
1150
|
-
"
|
|
1151
|
-
"
|
|
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(
|
|
1160
|
-
|
|
1161
|
-
|
|
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
|
-
|
|
1207
|
-
"
|
|
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
|
|
1212
|
-
|
|
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
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
|
1407
|
-
"
|
|
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
|
|
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
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
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
|
|
1503
|
-
"
|
|
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
|
|
1508
|
-
|
|
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`)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1828
|
-
|
|
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
|
-
|
|
1903
|
+
senderId: z
|
|
1842
1904
|
.string()
|
|
1843
1905
|
.min(1)
|
|
1844
1906
|
.optional()
|
|
1845
1907
|
.describe(
|
|
1846
|
-
"
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
2379
|
-
"
|
|
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
|
|
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
|
+
}
|