abs-zalo-bot 0.6.0 โ†’ 0.7.0

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/abs-zalo-bot.svg?color=blue)](https://www.npmjs.com/package/abs-zalo-bot)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
5
- [![Automated Tests](https://img.shields.io/badge/Tests-77%2F77%20Passing-brightgreen.svg)](test/)
5
+ [![Automated Tests](https://img.shields.io/badge/Tests-85%2F85%20Passing-brightgreen.svg)](test/)
6
6
  [![AI Agent Ready](https://img.shields.io/badge/AI%20Agent-Hermes%20%7C%20Claude%20Code%20%7C%20Codex-purple.svg)](mcp/)
7
7
  [![Model Context Protocol](https://img.shields.io/badge/MCP-Standard%20v1.3.0-blueviolet.svg)](mcp/)
8
8
 
@@ -46,7 +46,17 @@ Attach `npx abs-zalo-bot` or `node mcp/server.js` to your Agent configuration:
46
46
  | | `abs_zalo_list_groups` | List allowlisted source & destination groups |
47
47
  | | `abs_zalo_recent_messages` | Read captured message streams with full metadata |
48
48
  | | `abs_zalo_corpus_summary` | Get aggregated inventory of users, groups, and logs |
49
- | **Group Administration** | `abs_zalo_kick_member` | Remove a member from a group (Admin/Owner required) |
49
+ | **Group Administration** | `abs_zalo_rename_group` | Rename group name (Admin/Leader required) |
50
+ | | `abs_zalo_change_group_avatar` | Change group avatar image from URL or file |
51
+ | | `abs_zalo_create_group_note` | Create and pin announcement/note at the top |
52
+ | | `abs_zalo_get_pending_members` | List members waiting for approval to join |
53
+ | | `abs_zalo_review_pending_member` | Approve or reject pending member requests |
54
+ | | `abs_zalo_block_group_member` | Block a member permanently from the group |
55
+ | | `abs_zalo_unblock_group_member` | Unblock a previously blocked member |
56
+ | | `abs_zalo_get_group_link` | Get public group invite link (URL) |
57
+ | | `abs_zalo_set_group_link` | Enable or disable public group join link |
58
+ | | `abs_zalo_update_group_settings` | Configure group permissions (lock chat, pin, etc.) |
59
+ | | `abs_zalo_kick_member` | Remove a member from a group (Admin/Owner required) |
50
60
  | | `abs_zalo_transfer_owner` | Transfer group ownership (Owner required) |
51
61
  | | `abs_zalo_add_deputy` | Promote a member to Group Deputy / Admin |
52
62
  | | `abs_zalo_remove_deputy` | Demote a Group Deputy back to regular member |
@@ -152,7 +162,7 @@ Start MCP with `ABS_ZALO_TOOL_PACK=reader` (default). Move deliberately to `oper
152
162
 
153
163
  Set `HERMES_ZALO_MEDIA_INGEST=true` only on the private bridge host to stage inbound Zalo attachments for an authenticated Hermes platform plugin. The bridge returns opaque attachment references and serves the staged local file through its authenticated `/v1/hermes/media/:eventId/:attachmentId` endpoint; it never passes provider CDN URLs or Zalo session data to Hermes. Images, documents, audio/voice and video are bounded to 25 MB for images/files and 100 MB for audio/video.
154
164
 
155
- ### Hermes Zalo Gateway (0.5)
165
+ ### Hermes Zalo Gateway (0.5+)
156
166
 
157
167
  `hermes-plugin/platforms/zalo` is an installable Hermes gateway adapter. It polls the authenticated local bridge and converts only normalized, approved Zalo events into Hermes `MessageEvent`s; it never handles QR, cookies, sessions or arbitrary `zca-js` calls.
158
168
 
@@ -162,6 +172,73 @@ For profile-aware quality, select `gateway_skill = "your-owner-authored-hermes-s
162
172
 
163
173
  ---
164
174
 
175
+ ## ๐ŸŽจ Zalo Rich Text & Auto Styling Engine (Built-in)
176
+
177
+ `abs-zalo-bot` automatically compiles Markdown syntax and brand color tags into native Zalo TextStyle formatting:
178
+
179
+ - `# Heading 1`: Large Header (`f_18`) + Bold (`b`) + Ruby Red (`c_db342e`).
180
+ - `## Heading 2`: Header Bold (`b`) + Emerald Green (`c_15a85f`).
181
+ - `### Heading 3`: Header Bold (`b`) + Amber Orange (`c_f27806`).
182
+ - `**bold**`: Zalo Bold (`b`).
183
+ - `*italic*`: Zalo Italic (`i`).
184
+ - `__underline__`: Zalo Underline (`u`).
185
+ - `~~strikethrough~~`: Zalo StrikeThrough (`s`).
186
+ - Color tags: `[RED]...[/RED]` (Ruby Red), `[GREEN]...[/GREEN]` (Emerald Green), `[ORANGE]...[/ORANGE]` (Amber Orange), `[YELLOW]...[/YELLOW]` (Royal Gold) โ€” supports Vietnamese equivalents `[ฤแปŽ]`, `[XANH]`, `[CAM]`, `[Vร€NG]`.
187
+ - **Safe Bubble Chunker (`splitIntoSafeZaloChunks`)**: Automatically breaks long outputs into sequential bubbles <= 650 chars, permanently eliminating Zalo API Error 118 ("Content too long").
188
+
189
+ ---
190
+
191
+ ## ๐Ÿง  Hermes Agent Starter Kit (`hermes-plugin/starter-kit/`)
192
+
193
+ Ready-to-use "Digital Brain" template for Hermes Agent:
194
+
195
+ 1. **`SOUL.md`**: Conversational, warm, Vietnamese executive assistant voice (zero corporate slop, short mobile-optimized sentences, authentic tone).
196
+ 2. **`AGENT.md`**: Deterministic AI architecture, safety rules, fail-closed boundaries, mention-only in groups.
197
+ 3. **`skills/zalo-customer-care`**: 1-on-1 customer service, empathetic problem diagnosis, and natural lead qualification.
198
+ 4. **`skills/zalo-community-admin`**: 24/7 group moderation (new member welcome, FAQ answering, rule pinning, spam defense).
199
+
200
+ To enable, run 1 command:
201
+ ```bash
202
+ cp -R hermes-plugin/starter-kit/skills/* ~/.hermes/skills/
203
+ cat hermes-plugin/starter-kit/SOUL.md >> ~/.hermes/SOUL.md
204
+ ```
205
+
206
+ ---
207
+
208
+ ## ๐Ÿ”„ Seamless Zero-Downtime Upgrade (For Existing Users)
209
+
210
+ If you already installed `abs-zalo-bot` (v0.4.0 or v0.5.0), upgrading to **v0.6.0** takes 5 seconds with **ZERO data loss and NO QR re-scan**:
211
+
212
+ - **If installed via NPM:**
213
+ ```bash
214
+ npm install -g abs-zalo-bot@latest
215
+ ```
216
+ - **If cloned via Git:**
217
+ ```bash
218
+ git pull origin main
219
+ npm install
220
+ ```
221
+ - **If running via Docker:**
222
+ ```bash
223
+ docker compose pull && docker compose up -d
224
+ ```
225
+
226
+ > *Your active login sessions (`data/sessions/`), databases (`data/bridge.sqlite3`), and `.env` settings are 100% preserved. The bot seamlessly reconnects without requesting a new QR scan.*
227
+
228
+ ---
229
+
230
+ ## โญ Support & Community Nudge
231
+
232
+ If ABS Zalo Bot helps your operations or business, please consider starring the repository:
233
+
234
+ ```bash
235
+ # Star via GitHub CLI
236
+ gh repo star teddiesloco/abs-zalo-bot
237
+ ```
238
+ Or click Star directly at: **[https://github.com/teddiesloco/abs-zalo-bot](https://github.com/teddiesloco/abs-zalo-bot)** โญ
239
+
240
+ ---
241
+
165
242
  ## ๐Ÿ”’ Security & Policy Boundaries
166
243
 
167
244
  - **Side-effect control**: Every outbound message and administrative action is audited through `PolicyGuard`.
package/mcp/server.js CHANGED
@@ -12,6 +12,7 @@
12
12
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13
13
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
14
14
  import { z } from "zod";
15
+ import crypto from "node:crypto";
15
16
  import {
16
17
  capabilityPackSummary,
17
18
  canUseToolPack,
@@ -19,6 +20,47 @@ import {
19
20
  requiredToolPackForBridgeRequest,
20
21
  } from "../src/mcp_capabilities.js";
21
22
 
23
+ const PENDING_CONFIRMATION_TTL_MS = 5 * 60 * 1000; // 5 minutes
24
+ const pendingConfirmations = new Map(); // code -> { action, targetKey, expiresAt }
25
+
26
+ function cleanupExpiredConfirmations() {
27
+ const now = Date.now();
28
+ for (const [code, entry] of pendingConfirmations.entries()) {
29
+ if (now > entry.expiresAt) {
30
+ pendingConfirmations.delete(code);
31
+ }
32
+ }
33
+ }
34
+
35
+ function requestConfirmation(action, targetKey) {
36
+ cleanupExpiredConfirmations();
37
+ const code = crypto.randomBytes(3).toString("hex").toUpperCase();
38
+ pendingConfirmations.set(code, {
39
+ action,
40
+ targetKey,
41
+ expiresAt: Date.now() + PENDING_CONFIRMATION_TTL_MS,
42
+ });
43
+ return {
44
+ status: "confirmation_required",
45
+ requires_confirmation: true,
46
+ action,
47
+ confirmation_code: code,
48
+ expires_in_seconds: 300,
49
+ warning: `โš ๏ธ Hร€NH ฤแป˜NG NHแบ Y CแบขM: Thao tรกc nร y cรณ tรญnh phรก hแปงy/แบฃnh hฦฐแปŸng lแป›n. ฤแปƒ thแปฑc thi, vui lรฒng gแปi lแบกi cรดng cแปฅ kรจm tham sแป‘ confirmation_code="${code}" trong vรฒng 5 phรบt (hoแบทc yรชu cแบงu xรกc nhแบญn trong chat).`,
50
+ };
51
+ }
52
+
53
+ function verifyConfirmation(code, action, targetKey) {
54
+ cleanupExpiredConfirmations();
55
+ if (!code || typeof code !== "string") return false;
56
+ const key = code.trim().toUpperCase();
57
+ const entry = pendingConfirmations.get(key);
58
+ if (!entry) return false;
59
+ if (entry.action !== action || entry.targetKey !== targetKey) return false;
60
+ pendingConfirmations.delete(key);
61
+ return true;
62
+ }
63
+
22
64
  const BRIDGE_URL = (process.env.ZALO_BRIDGE_URL || "http://127.0.0.1:3871").replace(/\/$/, "");
23
65
  const TOKEN = process.env.DASHBOARD_TOKEN || process.env.ZALO_BRIDGE_TOKEN || "";
24
66
  const TOOL_PACK = normalizeToolPack(process.env.ABS_ZALO_TOOL_PACK || "reader");
@@ -270,13 +312,18 @@ server.tool(
270
312
 
271
313
  server.tool(
272
314
  "abs_zalo_kick_member",
273
- "Remove a user/member from a group (Requires Group Admin or Owner rights).",
315
+ "Remove a user/member from a group (Requires Group Admin or Owner rights). Note: Destructive action requires 2-step confirmation.",
274
316
  {
275
317
  group_id: z.string().describe("Target Zalo group id"),
276
318
  user_id: z.string().describe("User ID to kick from group"),
277
319
  account_id: z.string().optional(),
320
+ confirmation_code: z.string().optional().describe("6-char OTP confirmation code. Call without code first to generate OTP."),
278
321
  },
279
- async ({ group_id, user_id, account_id }) => {
322
+ async ({ group_id, user_id, account_id, confirmation_code }) => {
323
+ const targetKey = `${group_id}:${user_id}`;
324
+ if (!verifyConfirmation(confirmation_code, "kick_member", targetKey)) {
325
+ return ok(requestConfirmation("kick_member", targetKey));
326
+ }
280
327
  try {
281
328
  const data = await bridge(`/api/groups/${encodeURIComponent(group_id)}/kick`, {
282
329
  method: "POST",
@@ -291,13 +338,18 @@ server.tool(
291
338
 
292
339
  server.tool(
293
340
  "abs_zalo_transfer_owner",
294
- "Transfer group ownership to another member (Requires Group Owner rights).",
341
+ "Transfer group ownership to another member (Requires Group Owner rights). Note: Destructive action requires 2-step confirmation.",
295
342
  {
296
343
  group_id: z.string().describe("Target Zalo group id"),
297
344
  new_owner_id: z.string().describe("User ID of the new group owner"),
298
345
  account_id: z.string().optional(),
346
+ confirmation_code: z.string().optional().describe("6-char OTP confirmation code. Call without code first to generate OTP."),
299
347
  },
300
- async ({ group_id, new_owner_id, account_id }) => {
348
+ async ({ group_id, new_owner_id, account_id, confirmation_code }) => {
349
+ const targetKey = `${group_id}:${new_owner_id}`;
350
+ if (!verifyConfirmation(confirmation_code, "transfer_owner", targetKey)) {
351
+ return ok(requestConfirmation("transfer_owner", targetKey));
352
+ }
301
353
  try {
302
354
  const data = await bridge(`/api/groups/${encodeURIComponent(group_id)}/transfer-owner`, {
303
355
  method: "POST",
@@ -454,14 +506,19 @@ server.tool(
454
506
 
455
507
  server.tool(
456
508
  "abs_zalo_undo_message",
457
- "Undo / recall a sent message on Zalo.",
509
+ "Undo / recall a sent message on Zalo. Note: Irreversible action requires 2-step confirmation.",
458
510
  {
459
511
  dest: z.string().describe("Destination / message context"),
460
512
  thread_id: z.string().describe("Thread / group ID"),
461
513
  thread_type: z.number().optional().default(1).describe("1 for group, 0 for direct"),
462
514
  account_id: z.string().optional(),
515
+ confirmation_code: z.string().optional().describe("6-char OTP confirmation code. Call without code first to generate OTP."),
463
516
  },
464
- async ({ dest, thread_id, thread_type = 1, account_id }) => {
517
+ async ({ dest, thread_id, thread_type = 1, account_id, confirmation_code }) => {
518
+ const targetKey = `${thread_id}:${dest}`;
519
+ if (!verifyConfirmation(confirmation_code, "undo_message", targetKey)) {
520
+ return ok(requestConfirmation("undo_message", targetKey));
521
+ }
465
522
  try {
466
523
  const data = await bridge("/api/messages/undo", {
467
524
  method: "POST",
@@ -671,13 +728,18 @@ server.tool(
671
728
 
672
729
  server.tool(
673
730
  "abs_zalo_block_group_member",
674
- "Block a member permanently from joining or chatting in the group.",
731
+ "Block a member permanently from joining or chatting in the group. Note: Destructive action requires 2-step confirmation.",
675
732
  {
676
733
  group_id: z.string().describe("Zalo group ID"),
677
734
  member_id: z.string().describe("Zalo user ID to block"),
678
735
  account_id: z.string().optional(),
736
+ confirmation_code: z.string().optional().describe("6-char OTP confirmation code. Call without code first to generate OTP."),
679
737
  },
680
- async ({ group_id, member_id, account_id }) => {
738
+ async ({ group_id, member_id, account_id, confirmation_code }) => {
739
+ const targetKey = `${group_id}:${member_id}`;
740
+ if (!verifyConfirmation(confirmation_code, "block_group_member", targetKey)) {
741
+ return ok(requestConfirmation("block_group_member", targetKey));
742
+ }
681
743
  try {
682
744
  const data = await bridge(`/api/groups/${encodeURIComponent(group_id)}/blocked/add`, {
683
745
  method: "POST",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "abs-zalo-bot",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "description": "ABS Zalo Agent Engine โ€” Free, Transparent & Autonomous Zalo AI Agent Engine for Hermes, Claude Code & Codex. Dual Personal QR + Official OA, Group Administration, Lead Intel, Polls, Reactions & MCP Server.",
6
6
  "author": "teddiesloco",
@@ -64,7 +64,10 @@ export async function stageHermesMedia(event, { dataDir, fetchImpl = globalThis.
64
64
  const declared = Number(response.headers.get("content-length") || 0);
65
65
  if (declared > limit) throw new Error("attachment_too_large");
66
66
  const id = crypto.createHash("sha256").update(`${event.event_id}|${candidate.url}`).digest("hex").slice(0, 24);
67
- const relative = path.join("media", event.event_id, `${id}-${safeName(candidate.name, "attachment")}`);
67
+ const ext = path.extname(candidate.name) || (candidate.kind === "image" ? ".jpg" : candidate.kind === "audio" ? ".m4a" : candidate.kind === "video" ? ".mp4" : "");
68
+ const baseCandidateName = safeName(candidate.name, "attachment");
69
+ const candidateNameWithExt = ext && !baseCandidateName.toLowerCase().endsWith(ext) ? `${baseCandidateName}${ext}` : baseCandidateName;
70
+ const relative = path.join("media", event.event_id, `${id}-${candidateNameWithExt}`);
68
71
  const full = path.resolve(dataDir, relative);
69
72
  if (!full.startsWith(`${path.resolve(dataDir, "media")}${path.sep}`)) throw new Error("attachment_path_invalid");
70
73
  fs.mkdirSync(path.dirname(full), { recursive: true, mode: 0o700 });
@@ -84,7 +87,7 @@ export async function stageHermesMedia(event, { dataDir, fetchImpl = globalThis.
84
87
  });
85
88
  }
86
89
  if (!bytes) throw new Error("attachment_empty");
87
- refs.push({ id, path: relative, name: safeName(candidate.name, "attachment"), kind: candidate.kind, mime: response.headers.get("content-type")?.split(";")[0] || extensionMime(candidate.name, candidate.kind), size: bytes });
90
+ refs.push({ id, path: relative, name: candidateNameWithExt, kind: candidate.kind, mime: response.headers.get("content-type")?.split(";")[0] || extensionMime(candidateNameWithExt, candidate.kind), size: bytes });
88
91
  } catch (err) {
89
92
  refs.push({ id: crypto.randomUUID(), name: safeName(candidate.name, "attachment"), kind: candidate.kind || "file", status: "unavailable", error: String(err?.message || err).slice(0, 80) });
90
93
  }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * ABS Zalo Token Bucket Rate Limiter
3
+ *
4
+ * Smooths outbound messages using a token bucket algorithm to prevent
5
+ * triggering Zalo anti-spam / checkpoint mechanisms while keeping real-time
6
+ * conversational responses instantaneous (zero delay for the first burst).
7
+ */
8
+
9
+ export class RateLimitedError extends Error {
10
+ constructor(waitMs) {
11
+ super(
12
+ `ฤang bแป‹ giรฃn nhแป‹p chแป‘ng spam Zalo, cแบงn chแป ~${Math.ceil(waitMs / 1000)}s. ` +
13
+ `Vui lรฒng gแปญi รญt tin hฦกn hoแบทc thแปญ lแบกi sau.`
14
+ );
15
+ this.name = "RateLimitedError";
16
+ this.waitMs = waitMs;
17
+ }
18
+ }
19
+
20
+ export class RateLimiter {
21
+ #tokens;
22
+ #capacity;
23
+ #refillMs;
24
+ #maxWaitMs;
25
+ #last;
26
+ #hi = [];
27
+ #lo = [];
28
+ #timer = null;
29
+
30
+ /**
31
+ * @param {object} opts
32
+ * @param {number} opts.capacity Burst capacity (default 5: burst of 5 messages with 0 delay).
33
+ * @param {number} opts.refillMs Token regeneration time (default 3000ms = sustainable 20 msg/min).
34
+ * @param {number} opts.maxWaitMs Max wait before rejecting with RateLimitedError (default 20000ms).
35
+ */
36
+ constructor({ capacity = 5, refillMs = 3000, maxWaitMs = 20000 } = {}) {
37
+ this.#capacity = Math.max(1, capacity);
38
+ this.#refillMs = Math.max(1, refillMs);
39
+ this.#maxWaitMs = Math.max(0, maxWaitMs);
40
+ this.#tokens = this.#capacity;
41
+ this.#last = Date.now();
42
+ }
43
+
44
+ get queued() {
45
+ return this.#hi.length + this.#lo.length;
46
+ }
47
+
48
+ get available() {
49
+ this.#refill();
50
+ return Math.floor(this.#tokens);
51
+ }
52
+
53
+ #refill() {
54
+ const now = Date.now();
55
+ const gained = (now - this.#last) / this.#refillMs;
56
+ if (gained <= 0) return;
57
+ this.#tokens = Math.min(this.#capacity, this.#tokens + gained);
58
+ this.#last = now;
59
+ }
60
+
61
+ /**
62
+ * Acquire a rate limit slot.
63
+ *
64
+ * @param {'high'|'normal'} priority 'high' for immediate conversational replies, 'normal' for bulk/sync.
65
+ * @returns {Promise<void>} Resolves when granted permission to send.
66
+ * @throws {RateLimitedError} When queue wait time exceeds maxWaitMs.
67
+ */
68
+ acquire(priority = "normal") {
69
+ this.#refill();
70
+
71
+ // Fast path: tokens available and no existing queue -> immediate dispatch (0 latency)
72
+ if (this.queued === 0 && this.#tokens >= 1) {
73
+ this.#tokens -= 1;
74
+ return Promise.resolve();
75
+ }
76
+
77
+ const ahead = priority === "high" ? this.#hi.length : this.queued;
78
+ const waitMs = Math.max(0, Math.ceil((ahead + 1 - this.#tokens) * this.#refillMs));
79
+ if (waitMs > this.#maxWaitMs) {
80
+ return Promise.reject(new RateLimitedError(waitMs));
81
+ }
82
+
83
+ return new Promise((resolve) => {
84
+ (priority === "high" ? this.#hi : this.#lo).push(resolve);
85
+ this.#schedule();
86
+ });
87
+ }
88
+
89
+ #schedule() {
90
+ if (this.#timer) return;
91
+ const tick = () => {
92
+ this.#timer = null;
93
+ this.#refill();
94
+ while (this.#tokens >= 1 && this.queued > 0) {
95
+ const next = this.#hi.shift() ?? this.#lo.shift();
96
+ this.#tokens -= 1;
97
+ next();
98
+ }
99
+ if (this.queued > 0) {
100
+ const need = Math.ceil((1 - this.#tokens) * this.#refillMs);
101
+ this.#timer = setTimeout(tick, Math.max(25, need));
102
+ }
103
+ };
104
+ this.#timer = setTimeout(tick, 25);
105
+ }
106
+
107
+ /** Stop active timers to permit clean process termination. */
108
+ stop() {
109
+ if (this.#timer) {
110
+ clearTimeout(this.#timer);
111
+ this.#timer = null;
112
+ }
113
+ while (this.#hi.length > 0) {
114
+ const resolve = this.#hi.shift();
115
+ resolve();
116
+ }
117
+ while (this.#lo.length > 0) {
118
+ const resolve = this.#lo.shift();
119
+ resolve();
120
+ }
121
+ }
122
+ }
123
+
124
+ /** Methods subject to rate throttling */
125
+ export const THROTTLED_METHODS = new Set([
126
+ "sendMessage",
127
+ "sendVoice",
128
+ "sendVideo",
129
+ "sendSticker",
130
+ "sendLink",
131
+ "sendCard",
132
+ "uploadAttachment",
133
+ "forwardMessage",
134
+ "createPoll",
135
+ "createNote",
136
+ "createReminder",
137
+ "addUserToGroup",
138
+ "inviteUserToGroups",
139
+ ]);
package/src/server.js CHANGED
@@ -883,7 +883,8 @@ export function createApp({
883
883
  if (!dest || !thread_id) return res.status(400).json({ ok: false, error: "dest_and_thread_id_required" });
884
884
  const runtime = hub.getRuntime(accountId);
885
885
  if (!runtime.api) return res.status(400).json({ ok: false, error: "not_connected" });
886
- const result = await runtime.undoMessage(dest, thread_id, thread_type || 1);
886
+ const resolvedType = thread_type !== undefined ? Number(thread_type) : 0;
887
+ const result = await runtime.undoMessage(dest, thread_id, resolvedType);
887
888
  res.json({ ok: true, result });
888
889
  } catch (err) {
889
890
  res.status(500).json({ ok: false, error: String(err?.message || err) });
@@ -325,7 +325,7 @@ export class AccountRuntime extends EventEmitter {
325
325
  return this.api.addReaction(icon, dest);
326
326
  }
327
327
 
328
- async undoMessage(dest, threadId, threadType = 1) {
328
+ async undoMessage(dest, threadId, threadType = 0) {
329
329
  if (!this.api?.undo) throw new Error("not_connected");
330
330
  return this.api.undo(dest, String(threadId), threadType);
331
331
  }
@@ -18,6 +18,70 @@ export const ZALO_STYLES = {
18
18
  Indent: "ind_$",
19
19
  };
20
20
 
21
+ export const MAX_ZALO_STYLE_JSON_LENGTH = 250;
22
+ export const MAX_ZALO_UTF16_LENGTH = 2800;
23
+
24
+ /**
25
+ * Measure string length in UTF-16 code units (Zalo's internal character counter).
26
+ */
27
+ export function measureUtf16Length(str) {
28
+ return typeof str === "string" ? str.length : 0;
29
+ }
30
+
31
+ /**
32
+ * Score style priority so essential formatting (Headings, Colors) survives JSON budget cuts.
33
+ */
34
+ function getStylePriority(st) {
35
+ switch (st) {
36
+ case ZALO_STYLES.HeaderLarge:
37
+ case ZALO_STYLES.HeaderSmall:
38
+ return 100;
39
+ case ZALO_STYLES.RubyRed:
40
+ case ZALO_STYLES.EmeraldGreen:
41
+ case ZALO_STYLES.AmberOrange:
42
+ case ZALO_STYLES.RoyalGold:
43
+ return 80;
44
+ case ZALO_STYLES.Bold:
45
+ return 60;
46
+ case ZALO_STYLES.Underline:
47
+ case ZALO_STYLES.StrikeThrough:
48
+ return 40;
49
+ case ZALO_STYLES.Italic:
50
+ return 20;
51
+ default:
52
+ return 10;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Cap Zalo styles to prevent exceeding Zalo's JSON style payload ceiling (~256 bytes).
58
+ * When over budget, lower-priority styles (italic, bold) are pruned first
59
+ * while preserving high-priority headings and colors.
60
+ */
61
+ export function capStyles(styles, maxJsonLength = MAX_ZALO_STYLE_JSON_LENGTH) {
62
+ if (!Array.isArray(styles) || styles.length === 0) return [];
63
+ if (JSON.stringify(styles).length <= maxJsonLength) return styles;
64
+
65
+ // Clone and annotate with original index and priority
66
+ const items = styles.map((s, idx) => ({
67
+ style: s,
68
+ priority: getStylePriority(s.st),
69
+ idx,
70
+ }));
71
+
72
+ // Sort ascending by priority so lowest priority items are removed first
73
+ items.sort((a, b) => a.priority - b.priority);
74
+
75
+ const retained = new Set(styles);
76
+ while (items.length > 0 && JSON.stringify(Array.from(retained)).length > maxJsonLength) {
77
+ const lowest = items.shift();
78
+ retained.delete(lowest.style);
79
+ }
80
+
81
+ // Restore original ordering by start position
82
+ return styles.filter((s) => retained.has(s));
83
+ }
84
+
21
85
  /**
22
86
  * Split text into chunks safe for Zalo's message length limits.
23
87
  * Default max is 650 chars to avoid error 118 (content too long).
@@ -178,11 +242,13 @@ export function parseMarkdownStyles(input) {
178
242
 
179
243
  /**
180
244
  * Format message into ready-to-send Zalo payload with styles.
245
+ * Automatically enforces max JSON budget (~250 bytes) for style payload.
181
246
  */
182
- export function buildZaloStyledMessage(text) {
247
+ export function buildZaloStyledMessage(text, { maxStylesJsonLength = MAX_ZALO_STYLE_JSON_LENGTH } = {}) {
183
248
  const { text: cleanText, styles } = parseMarkdownStyles(text);
249
+ const cappedStyles = capStyles(styles, maxStylesJsonLength);
184
250
  return {
185
251
  msg: cleanText,
186
- styles: styles.length > 0 ? styles : undefined,
252
+ styles: cappedStyles.length > 0 ? cappedStyles : undefined,
187
253
  };
188
254
  }