@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.
@@ -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,166 @@
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
+ /** Style ranges clipped to `[from, to)` and re-based to `from`. */
26
+ function clipStyles(styles, from, to) {
27
+ const out = [];
28
+ for (const s of styles) {
29
+ const a = Math.max(s.start, from);
30
+ const b = Math.min(s.start + s.len, to);
31
+ if (b > a) out.push({ ...s, start: a - from, len: b - a });
32
+ }
33
+ return out;
34
+ }
35
+
36
+ /** What one message costs against the limit: its text bytes plus its style list, as Web counts it. */
37
+ function cost(text, styles) {
38
+ return byteLength(text) + (styles.length > 0 ? byteLength(JSON.stringify(styles)) : 0);
39
+ }
40
+
41
+ /**
42
+ * How good a place `text[i]` is to end a part after: higher is better, null when it is no break at all.
43
+ * Web's own order (`" ,;)!?."`, a full stop strongest), a newline above all, and the CJK marks that do the same job
44
+ * (Web's list is ASCII only, so a long Chinese text with no space would otherwise be cut blind).
45
+ */
46
+ function breakRank(text, i) {
47
+ const c = text[i];
48
+ if (c === "\n") return 7;
49
+ const cjk = { "。": 6, "?": 5, "!": 4, ";": 2, ",": 1, "、": 1 }[c];
50
+ if (cjk !== undefined) return cjk;
51
+ const ascii = " ,;)!?.".indexOf(c);
52
+ if (ascii === -1) return null;
53
+ const next = text[i + 1];
54
+ return c === " " || next === undefined || next === " " || next === "\n" ? ascii : null;
55
+ }
56
+
57
+ /**
58
+ * Where to cut `text` between `pos` and `end` (the farthest the budget allows): the nearest newline in the last two
59
+ * thirds of the window, else the strongest sentence or clause end there, else `end` itself.
60
+ *
61
+ * @returns {number} the cut: the part is `text.slice(pos, cut)`
62
+ */
63
+ function chooseCut(text, pos, end) {
64
+ const floor = pos + Math.floor((end - pos) / 3);
65
+ let best = -1;
66
+ let bestRank = -1;
67
+ for (let i = end - 1; i >= floor && i >= pos; i--) {
68
+ const rank = breakRank(text, i);
69
+ if (rank === null) continue;
70
+ if (rank === 7) return i; // the newline is dropped with the whitespace that follows
71
+ if (rank > bestRank) {
72
+ best = i + 1;
73
+ bestRank = rank;
74
+ }
75
+ }
76
+ return best > pos ? best : end;
77
+ }
78
+
79
+ /**
80
+ * Split one message into parts that each fit {@link MESSAGE_BYTE_LIMIT}. A message that fits comes back untouched
81
+ * (the same text, no trimming), so only a message that needed splitting is ever changed.
82
+ *
83
+ * A style range that spans a cut is clipped into both parts. A mention is never cut: the cut moves before it. A
84
+ * surrogate pair (an emoji) is never cut. Whitespace at a cut is dropped, as Web trims its parts.
85
+ *
86
+ * @param {{text: string, styles?: Array<{start: number, len: number, st: string}>, mentions?: Array<{pos: number, len: number, uid: string}>}} message
87
+ * @param {number} [limit] - bytes per part
88
+ * @returns {Array<{text: string, styles: object[], mentions: object[]}>} at least one part
89
+ */
90
+ export function splitMessage({ text, styles = [], mentions = [] }, limit = MESSAGE_BYTE_LIMIT) {
91
+ if (cost(text, styles) <= limit) return [{ text, styles, mentions }];
92
+
93
+ const parts = [];
94
+ let pos = 0;
95
+ while (pos < text.length) {
96
+ // The farthest end this part can reach inside the budget (always at least one character).
97
+ let lo = pos + 1;
98
+ let hi = text.length;
99
+ while (lo < hi) {
100
+ const mid = Math.ceil((lo + hi) / 2);
101
+ if (cost(text.slice(pos, mid), clipStyles(styles, pos, mid)) <= limit) lo = mid;
102
+ else hi = mid - 1;
103
+ }
104
+ let cut = lo;
105
+
106
+ if (cut < text.length) {
107
+ cut = chooseCut(text, pos, cut);
108
+ for (const m of mentions) {
109
+ if (m.pos > pos && m.pos < cut && cut < m.pos + m.len) cut = m.pos;
110
+ }
111
+ const high = text.charCodeAt(cut - 1);
112
+ if (high >= 0xd800 && high <= 0xdbff) cut -= 1;
113
+ if (cut <= pos) cut = Math.min(text.length, pos + 2);
114
+ }
115
+
116
+ const raw = text.slice(pos, cut);
117
+ const body = raw.trimEnd();
118
+ if (body.length > 0) {
119
+ const end = pos + body.length;
120
+ parts.push({
121
+ text: body,
122
+ styles: clipStyles(styles, pos, end),
123
+ mentions: mentions
124
+ .filter((m) => m.pos >= pos && m.pos + m.len <= end)
125
+ .map((m) => ({ ...m, pos: m.pos - pos })),
126
+ });
127
+ }
128
+ pos = cut;
129
+ // Web trims each remainder: the whitespace at a cut belongs to no part.
130
+ while (pos < text.length && /\s/.test(text[pos])) pos++;
131
+ }
132
+ return parts.length > 0 ? parts : [{ text, styles, mentions }];
133
+ }
134
+
135
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
136
+
137
+ /**
138
+ * Send the parts of one message one after another, {@link PART_GAP_MS} apart. Stops at the first part Zalo refuses
139
+ * and says how many had already gone out, because nothing can take those back.
140
+ *
141
+ * @template T
142
+ * @param {Array<object>} parts - from {@link splitMessage}
143
+ * @param {(part: object, index: number) => Promise<T>} send - sends one part, resolves what Zalo answered
144
+ * @param {{gapMs?: number, sleep?: (ms: number) => Promise<void>}} [opts]
145
+ * @returns {Promise<T[]>} what Zalo answered for each part, in order
146
+ * @throws {Error} with `.sent` (the answers of the parts that went out) set when a later part failed
147
+ */
148
+ export async function sendParts(parts, send, { gapMs = PART_GAP_MS, sleep = defaultSleep } = {}) {
149
+ const answers = [];
150
+ for (let i = 0; i < parts.length; i++) {
151
+ if (i > 0) await sleep(gapMs);
152
+ try {
153
+ answers.push(await send(parts[i], i));
154
+ } catch (e) {
155
+ if (i === 0) throw e;
156
+ const failure = new Error(
157
+ `${e?.message ?? e} (part ${i + 1} of ${parts.length} failed; the ${i} earlier part(s) were already delivered)`,
158
+ { cause: e },
159
+ );
160
+ failure.sent = answers;
161
+ if (e?.code !== undefined) failure.code = e.code;
162
+ throw failure;
163
+ }
164
+ }
165
+ return answers;
166
+ }