@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.
- package/package.json +1 -1
- package/src/commands/mcp.js +3 -0
- package/src/commands/msg.js +37 -13
- package/src/commands/sync.js +12 -1
- package/src/core/sync-v2/index.js +16 -3
- package/src/core/zalo-client.js +7 -3
- 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 +166 -0
|
@@ -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
|
+
}
|