@ardennguyen/zalo-agent-cli 2.5.8 → 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/mcp/mcp-tools.js +1 -1
- package/src/utils/split-message.js +61 -5
package/package.json
CHANGED
package/src/mcp/mcp-tools.js
CHANGED
|
@@ -492,7 +492,7 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
|
|
|
492
492
|
title: "Send Zalo Message",
|
|
493
493
|
description:
|
|
494
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`). ' +
|
|
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
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
497
|
"Reply to a message: pass its msgId as `quoteMsgId` (text messages only, and the message must be in the local cache). " +
|
|
498
498
|
'`urgency` marks the message Important or Urgent, as the apps do. `threadId: "me"` sends to the owner\'s My Documents. ' +
|
|
@@ -22,6 +22,27 @@ export const PART_GAP_MS = 300;
|
|
|
22
22
|
|
|
23
23
|
const byteLength = (s) => Buffer.byteLength(s, "utf8");
|
|
24
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
|
+
|
|
25
46
|
/** Style ranges clipped to `[from, to)` and re-based to `from`. */
|
|
26
47
|
function clipStyles(styles, from, to) {
|
|
27
48
|
const out = [];
|
|
@@ -38,16 +59,49 @@ function cost(text, styles) {
|
|
|
38
59
|
return byteLength(text) + (styles.length > 0 ? byteLength(JSON.stringify(styles)) : 0);
|
|
39
60
|
}
|
|
40
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
|
+
|
|
41
95
|
/**
|
|
42
96
|
* 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
|
|
44
|
-
* (Web's list is ASCII only, so a long Chinese text with
|
|
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).
|
|
45
99
|
*/
|
|
46
100
|
function breakRank(text, i) {
|
|
47
101
|
const c = text[i];
|
|
48
102
|
if (c === "\n") return 7;
|
|
49
|
-
const
|
|
50
|
-
if (
|
|
103
|
+
const mark = MARKS[c];
|
|
104
|
+
if (mark !== undefined) return mark;
|
|
51
105
|
const ascii = " ,;)!?.".indexOf(c);
|
|
52
106
|
if (ascii === -1) return null;
|
|
53
107
|
const next = text[i + 1];
|
|
@@ -81,7 +135,8 @@ function chooseCut(text, pos, end) {
|
|
|
81
135
|
* (the same text, no trimming), so only a message that needed splitting is ever changed.
|
|
82
136
|
*
|
|
83
137
|
* A style range that spans a cut is clipped into both parts. A mention is never cut: the cut moves before it. A
|
|
84
|
-
*
|
|
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.
|
|
85
140
|
*
|
|
86
141
|
* @param {{text: string, styles?: Array<{start: number, len: number, st: string}>, mentions?: Array<{pos: number, len: number, uid: string}>}} message
|
|
87
142
|
* @param {number} [limit] - bytes per part
|
|
@@ -108,6 +163,7 @@ export function splitMessage({ text, styles = [], mentions = [] }, limit = MESSA
|
|
|
108
163
|
for (const m of mentions) {
|
|
109
164
|
if (m.pos > pos && m.pos < cut && cut < m.pos + m.len) cut = m.pos;
|
|
110
165
|
}
|
|
166
|
+
cut = snapToGrapheme(text, pos, cut);
|
|
111
167
|
const high = text.charCodeAt(cut - 1);
|
|
112
168
|
if (high >= 0xd800 && high <= 0xdbff) cut -= 1;
|
|
113
169
|
if (cut <= pos) cut = Math.min(text.length, pos + 2);
|