@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ardennguyen/zalo-agent-cli",
3
- "version": "2.5.8",
3
+ "version": "2.5.9",
4
4
  "description": "CLI tool for Zalo automation — multi-account, proxy, bank transfers, QR payments, Official Account API v3.0",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 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).
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 cjk = { "。": 6, "?": 5, "!": 4, ";": 2, ",": 1, "、": 1 }[c];
50
- if (cjk !== undefined) return cjk;
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
- * surrogate pair (an emoji) is never cut. Whitespace at a cut is dropped, as Web trims its parts.
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);