abs-zalo-bot 0.6.0 → 0.7.1

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`.
@@ -0,0 +1,65 @@
1
+ # Hướng Dẫn Trình Bày & Phong Cách Tin Nhắn Zalo (Zalo Style Guide)
2
+ > **Chuẩn hoá định dạng tin nhắn cho Zalo AI Agent (Amon, Lavie, Coach, Travel & Enterprise Bots).**
3
+ > Đúc kết từ thực chiến hàng trăm nghìn phiên chat, tối ưu trải nghiệm đọc trên màn hình điện thoại di động và tương thích tuyệt đối với giao thức Zalo API.
4
+
5
+ ---
6
+
7
+ ## 1. Nguyên Tắc Bố Cục & Đọc Nhanh (Mobile-First)
8
+
9
+ Màn hình Zalo trên di động có chiều ngang hẹp. Mắt người đọc thường quét theo hình chữ F hoặc chữ E:
10
+ * **In đậm nhãn mục:** Luôn in đậm đầu mục để mắt người lướt bắt điểm dừng ngay lập tức:
11
+ * `- Hiện tại:` / `- Thực trạng:`
12
+ * `- Phân tích:` / `- Nguyên nhân:`
13
+ * `- Đề xuất:` / `- Giải pháp:` / `- Bước tiếp theo:`
14
+ * **In đậm từ khoá then chốt:** Những con số quan trọng, thời hạn, tên riêng, hành động chính cần in đậm:
15
+ * Ví dụ: *Chi phí dự tính **1.500.000đ**, hoàn thành trước **17h00 hôm nay**.*
16
+ * **Khoảng thở giữa các ý:** Tách các đoạn tối đa 2–3 câu, để 1 dòng trống giữa các phần. Không viết khối văn bản đặc quánh (text-wall).
17
+
18
+ ---
19
+
20
+ ## 2. Thể Thức Phân Tầng Danh Sách (Chuẩn Văn Bản Hành Chính)
21
+
22
+ Tránh lồng bullet 3–4 cấp làm vỡ khung hiển thị Zalo. Chỉ dùng tối đa 2 cấp:
23
+ * **Cấp 1 (Mục chính):** Dùng gạch đầu dòng `- `
24
+ * **Cấp 2 (Mục con chi tiết):** Dùng chấm tròn `• `
25
+
26
+ **Ví dụ:**
27
+ ```markdown
28
+ - Gói Đồng Hành Khởi Đầu:
29
+ • Thời hạn hỗ trợ: 3 tháng liên tục.
30
+ • Kênh trao đổi: Nhóm Zalo riêng biệt 24/7.
31
+ - Gói Toàn Diện Doanh Nghiệp:
32
+ • Thiết lập toàn bộ AI Agent CSKH & Chốt đơn.
33
+ • Bàn giao mã nguồn và tài sản sở hữu 100%.
34
+ ```
35
+
36
+ ---
37
+
38
+ ## 3. Hệ Màu Nhận Diện Editorial Luxury Palette
39
+
40
+ Hệ thống hỗ trợ cả 3 dạng thẻ màu: tiếng Anh, tiếng Việt có dấu và tiếng Việt không dấu:
41
+
42
+ | Màu hiển thị | Thẻ hỗ trợ (Không phân biệt hoa/thường) | Mã màu Zalo | Ngữ nghĩa & Tình huống sử dụng |
43
+ | :--- | :--- | :--- | :--- |
44
+ | 🔴 **Đỏ Ruby** | `[RED]`, `[ĐỎ]`, `[do]` | `c_db342e` | Tiêu đề lớn, Băng rôn, Cảnh báo khẩn cấp, Điểm nghẽn rủi ro, Tổng kết quan trọng. |
45
+ | 🟢 **Xanh Lá Ngọc** | `[GREEN]`, `[XANH]` | `c_15a85f` | Đề mục đánh số (1., 2.), Quy trình chuẩn, Tín hiệu tích cực, Checklist hoàn tất. |
46
+ | 🟠 **Cam Hổ Phách** | `[ORANGE]`, `[CAM]` | `c_f27806` | Từ khóa hành động then chốt, Lưu ý thực thi, Cụm từ trong ngoặc kép `""`. |
47
+ | 🟡 **Vàng Hoàng Kim** | `[YELLOW]`, `[VÀNG]`, `[vang]` | `c_f7b503` | Triết lý cốt lõi, Insight sâu sắc, Bài học xương máu, Giá trị nền tảng. |
48
+
49
+ *Lưu ý:* Luôn đóng thẻ màu bằng `[/RED]`, `[/ĐỎ]`, `[/do]`, v.v.
50
+
51
+ ---
52
+
53
+ ## 4. Những Điều CẤM & Tránh Khi Viết Cho Zalo
54
+
55
+ 1. **TUYỆT ĐỐI KHÔNG DÙNG `---` (Đường kẻ ngang Markdown):**
56
+ * Trên ứng dụng Zalo di động, thẻ `---` không hiển thị thành đường kẻ mỏng mà biến thành khoảng trống kép rất thô, làm loãng nội dung.
57
+ * *Giải pháp:* Chỉ cần cách 1 dòng trống đơn thuần là đủ trang nhã. Engine `abs-zalo-bot` đã được tích hợp bộ lọc tự động triệt tiêu `---` thành khoảng trắng sạch.
58
+ 2. **Không lạm dụng thẻ màu trên toàn bộ đoạn văn:**
59
+ * Chỉ bọc màu cho từ ngữ đắt giá (1–5 từ). Bọc nguyên một câu dài bằng màu đỏ hoặc vàng sẽ gây chói mắt và làm mất tính sang trọng.
60
+ 3. **Không lo vượt trần Style JSON (Đã có `capStyles` tự động):**
61
+ * Zalo Web API có giới hạn ngầm mảng style không được vượt quá ~256 bytes JSON.
62
+ * Engine tự động bảo vệ: Nếu bài viết quá nhiều style, thuật toán sẽ tự động gọt bớt các style phụ (gạch chân, in nghiêng) và bảo tồn 100% Tiêu đề và Thẻ màu. Không bao giờ xảy ra lỗi drop tin ngầm.
63
+ 4. **Kỷ luật Sticker & Voice:**
64
+ * Trong các nhóm thảo luận công việc, quản trị, hoặc hỗ trợ kỹ thuật: Tuyệt đối không tự ý gửi sticker hoạt hình làm phiền người dùng.
65
+ * Tin nhắn thoại (Voice): Chỉ gửi khi người dùng yêu cầu nghe giọng nói.
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.1",
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) });
@@ -43,6 +43,9 @@ function controlledAttachment(filePath) {
43
43
  }
44
44
 
45
45
  export class AccountRuntime extends EventEmitter {
46
+ // Auto-restart constants (exponential back-off, same as 2anh-zalo-bot v1.1.1)
47
+ static RESTART_DELAYS_MS = [5_000, 15_000, 30_000, 60_000, 120_000, 300_000];
48
+
46
49
  constructor({ accountId, store, policy, onEvent, clientFactory = null }) {
47
50
  super();
48
51
  this.accountId = String(accountId);
@@ -55,6 +58,9 @@ export class AccountRuntime extends EventEmitter {
55
58
  this.loginPromise = null;
56
59
  this.lastMessageAt = null;
57
60
  this.listenerWired = false;
61
+ this._listenerStopped = false;
62
+ this._restartAttempt = 0;
63
+ this._restartTimer = null;
58
64
  }
59
65
 
60
66
  status() {
@@ -248,7 +254,44 @@ export class AccountRuntime extends EventEmitter {
248
254
  /* ignore closed store */
249
255
  }
250
256
  });
251
- this.api.listener.start({ retryOnClose: true });
257
+ // "closed" = zca-js hit retry limit and gave up entirely. Bot is still "connected"
258
+ // but deaf. Schedule a listener restart with exponential back-off so the process
259
+ // self-heals without operator intervention (mirrors 2anh-zalo-bot v1.1.1 fix).
260
+ this.api.listener.on("closed", (code, reason) => {
261
+ try {
262
+ this.store.setHealth(
263
+ `listener_closed_${this.accountId}`,
264
+ `code=${code} reason=${String(reason || "").slice(0, 100)} at=${utcNow()}`,
265
+ );
266
+ this.emit("listener_down", { reason: `closed:${code}`, at: utcNow() });
267
+ } catch { /* ignore */ }
268
+ if (!this._listenerStopped) this.#scheduleListenerRestart();
269
+ });
270
+ this.#startListener();
271
+ }
272
+
273
+ #scheduleListenerRestart() {
274
+ if (this._listenerStopped || this._restartTimer) return;
275
+ const delays = AccountRuntime.RESTART_DELAYS_MS;
276
+ const delay = delays[Math.min(this._restartAttempt, delays.length - 1)];
277
+ this._restartAttempt += 1;
278
+ console.warn(
279
+ `[zalo_runtime] 🔁 Listener đóng hoàn toàn — thử mở lại sau ${Math.round(delay / 1000)}s (lần ${this._restartAttempt})`,
280
+ );
281
+ this._restartTimer = setTimeout(() => {
282
+ this._restartTimer = null;
283
+ this.#startListener();
284
+ }, delay);
285
+ }
286
+
287
+ #startListener() {
288
+ if (this._listenerStopped || !this.api?.listener) return;
289
+ try {
290
+ this.api.listener.start({ retryOnClose: true });
291
+ } catch (err) {
292
+ console.error("[zalo_runtime] không start được listener:", err?.message || err);
293
+ this.#scheduleListenerRestart();
294
+ }
252
295
  }
253
296
 
254
297
  async sendText(targetId, text, threadType = 1) {
@@ -325,7 +368,7 @@ export class AccountRuntime extends EventEmitter {
325
368
  return this.api.addReaction(icon, dest);
326
369
  }
327
370
 
328
- async undoMessage(dest, threadId, threadType = 1) {
371
+ async undoMessage(dest, threadId, threadType = 0) {
329
372
  if (!this.api?.undo) throw new Error("not_connected");
330
373
  return this.api.undo(dest, String(threadId), threadType);
331
374
  }
@@ -335,6 +378,29 @@ export class AccountRuntime extends EventEmitter {
335
378
  return this.api.getGroupInfo(String(groupId));
336
379
  }
337
380
 
381
+ // getGroupMembersInfo requires a list of member UIDs, NOT a group ID.
382
+ // Call getGroupInfo first to obtain the member list, then pass member UIDs here.
383
+ // (mirrors 2anh-zalo-bot v1.2.0 group_members fix)
384
+ async getGroupMembers(groupId, { limit = 50 } = {}) {
385
+ if (!this.api?.getGroupInfo || !this.api?.getGroupMembersInfo) throw new Error("not_connected");
386
+ const info = await this.api.getGroupInfo(String(groupId));
387
+ const gid = String(groupId);
388
+ const memberIds = (info?.gridInfoMap?.[gid]?.memVerList || [])
389
+ .map((entry) => String(entry).replace(/_\d+$/, ""))
390
+ .filter(Boolean);
391
+ const lookup = memberIds.slice(0, Math.min(limit, 100));
392
+ const profiles = lookup.length
393
+ ? ((await this.api.getGroupMembersInfo(lookup))?.profiles || {})
394
+ : {};
395
+ return {
396
+ total: memberIds.length,
397
+ members: lookup.map((id) => ({
398
+ id,
399
+ displayName: profiles[id]?.displayName || profiles[id]?.zaloName || "",
400
+ })),
401
+ };
402
+ }
403
+
338
404
  async getUserInfo(userId) {
339
405
  if (!this.api?.getUserInfo) throw new Error("not_connected");
340
406
  return this.api.getUserInfo(String(userId));
@@ -431,7 +497,20 @@ export class AccountRuntime extends EventEmitter {
431
497
  if (Array.isArray(payload.mentions)) message.mentions = payload.mentions.slice(0, 50);
432
498
  if (payload.attachment_path) message.attachments = controlledAttachment(payload.attachment_path);
433
499
  if (styles) message.styles = styles;
434
- return api.sendMessage(message, target(), threadType(payload.thread_type));
500
+ // Plaintext fallback: if Zalo rejects a styled message with a numeric error
501
+ // code (server-side rejection), retry as plain text to avoid silently losing
502
+ // the message. Network errors (no code) are NOT retried to avoid duplicates.
503
+ // (mirrors 2anh-zalo-bot v1.1.1 hermes-bridge.js fix)
504
+ try {
505
+ return await api.sendMessage(message, target(), threadType(payload.thread_type));
506
+ } catch (err) {
507
+ if (!message.styles || !/^-?\d+$/.test(String(err?.code ?? ""))) throw err;
508
+ console.warn(
509
+ `[zalo_runtime] Zalo từ chối tin có style (mã ${err.code}) — gửi lại dạng chữ thường`,
510
+ );
511
+ const { styles: _dropped, ...plain } = message;
512
+ return api.sendMessage(plain, target(), threadType(payload.thread_type));
513
+ }
435
514
  }
436
515
  case "send_sticker":
437
516
  if (typeof api.sendSticker !== "function") break;
@@ -503,6 +582,9 @@ export class AccountRuntime extends EventEmitter {
503
582
  }
504
583
 
505
584
  async pause() {
585
+ this._listenerStopped = true;
586
+ clearTimeout(this._restartTimer);
587
+ this._restartTimer = null;
506
588
  try {
507
589
  this.api?.listener?.stop?.();
508
590
  } catch {
@@ -18,6 +18,84 @@ 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
+ export const MAX_ZALO_PAYLOAD_BYTES = 3000;
24
+
25
+ /**
26
+ * Measure payload volume in UTF-8 bytes (text + style JSON length).
27
+ * Zalo web servers reject messages where text bytes + style JSON length >= 3,448 bytes.
28
+ * Keeping under 3,000 bytes ensures 100% safe delivery for Vietnamese text & emoji.
29
+ */
30
+ export function measurePayloadBytes(msg, styles) {
31
+ const text = typeof msg === "string" ? msg : String(msg?.msg || msg?.text || "");
32
+ const textBytes = Buffer.byteLength(text, "utf8");
33
+ const styleList = styles || msg?.styles;
34
+ const styleBytes = Array.isArray(styleList) && styleList.length > 0 ? JSON.stringify(styleList).length : 0;
35
+ return textBytes + styleBytes;
36
+ }
37
+
38
+ /**
39
+ * Measure string length in UTF-16 code units (Zalo's internal character counter).
40
+ */
41
+ export function measureUtf16Length(str) {
42
+ return typeof str === "string" ? str.length : 0;
43
+ }
44
+
45
+ /**
46
+ * Score style priority so essential formatting (Headings, Colors) survives JSON budget cuts.
47
+ */
48
+ function getStylePriority(st) {
49
+ switch (st) {
50
+ case ZALO_STYLES.HeaderLarge:
51
+ case ZALO_STYLES.HeaderSmall:
52
+ return 100;
53
+ case ZALO_STYLES.RubyRed:
54
+ case ZALO_STYLES.EmeraldGreen:
55
+ case ZALO_STYLES.AmberOrange:
56
+ case ZALO_STYLES.RoyalGold:
57
+ return 80;
58
+ case ZALO_STYLES.Bold:
59
+ return 60;
60
+ case ZALO_STYLES.Underline:
61
+ case ZALO_STYLES.StrikeThrough:
62
+ return 40;
63
+ case ZALO_STYLES.Italic:
64
+ return 20;
65
+ default:
66
+ return 10;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Cap Zalo styles to prevent exceeding Zalo's JSON style payload ceiling (~256 bytes).
72
+ * When over budget, lower-priority styles (italic, bold) are pruned first
73
+ * while preserving high-priority headings and colors.
74
+ */
75
+ export function capStyles(styles, maxJsonLength = MAX_ZALO_STYLE_JSON_LENGTH) {
76
+ if (!Array.isArray(styles) || styles.length === 0) return [];
77
+ if (JSON.stringify(styles).length <= maxJsonLength) return styles;
78
+
79
+ // Clone and annotate with original index and priority
80
+ const items = styles.map((s, idx) => ({
81
+ style: s,
82
+ priority: getStylePriority(s.st),
83
+ idx,
84
+ }));
85
+
86
+ // Sort ascending by priority so lowest priority items are removed first
87
+ items.sort((a, b) => a.priority - b.priority);
88
+
89
+ const retained = new Set(styles);
90
+ while (items.length > 0 && JSON.stringify(Array.from(retained)).length > maxJsonLength) {
91
+ const lowest = items.shift();
92
+ retained.delete(lowest.style);
93
+ }
94
+
95
+ // Restore original ordering by start position
96
+ return styles.filter((s) => retained.has(s));
97
+ }
98
+
21
99
  /**
22
100
  * Split text into chunks safe for Zalo's message length limits.
23
101
  * Default max is 650 chars to avoid error 118 (content too long).
@@ -95,6 +173,11 @@ export function parseMarkdownStyles(input) {
95
173
 
96
174
  for (let line of lines) {
97
175
  let hType = 0;
176
+ // Strip markdown horizontal rules (---, ***, ___) to avoid ugly wide gap in Zalo mobile
177
+ if (/^\s*[-*_]{3,}\s*$/.test(line)) {
178
+ line = "";
179
+ }
180
+
98
181
  if (line.startsWith("# ")) {
99
182
  hType = 1;
100
183
  line = line.slice(2);
@@ -161,11 +244,11 @@ export function parseMarkdownStyles(input) {
161
244
  }
162
245
  }
163
246
 
164
- // 2. Color tags (Vietnamese and English)
165
- replaceTag(/\[(?:RED|ĐỎ)\]([\s\S]*?)\[\/(?:RED|ĐỎ)\]/i, [ZALO_STYLES.Bold, ZALO_STYLES.RubyRed]);
247
+ // 2. Color tags (Vietnamese and English, accented & unaccented)
248
+ replaceTag(/\[(?:RED|ĐỎ|DO)\]([\s\S]*?)\[\/(?:RED|ĐỎ|DO)\]/i, [ZALO_STYLES.Bold, ZALO_STYLES.RubyRed]);
166
249
  replaceTag(/\[(?:GREEN|XANH)\]([\s\S]*?)\[\/(?:GREEN|XANH)\]/i, [ZALO_STYLES.Bold, ZALO_STYLES.EmeraldGreen]);
167
250
  replaceTag(/\[(?:ORANGE|CAM)\]([\s\S]*?)\[\/(?:ORANGE|CAM)\]/i, [ZALO_STYLES.Bold, ZALO_STYLES.AmberOrange]);
168
- replaceTag(/\[(?:YELLOW|VÀNG)\]([\s\S]*?)\[\/(?:YELLOW|VÀNG)\]/i, [ZALO_STYLES.Bold, ZALO_STYLES.RoyalGold]);
251
+ replaceTag(/\[(?:YELLOW|VÀNG|VANG)\]([\s\S]*?)\[\/(?:YELLOW|VÀNG|VANG)\]/i, [ZALO_STYLES.Bold, ZALO_STYLES.RoyalGold]);
169
252
 
170
253
  // 3. Inline markdown tags
171
254
  replaceTag(/\*\*([\s\S]*?)\*\*/g, [ZALO_STYLES.Bold]);
@@ -178,11 +261,13 @@ export function parseMarkdownStyles(input) {
178
261
 
179
262
  /**
180
263
  * Format message into ready-to-send Zalo payload with styles.
264
+ * Automatically enforces max JSON budget (~250 bytes) for style payload.
181
265
  */
182
- export function buildZaloStyledMessage(text) {
266
+ export function buildZaloStyledMessage(text, { maxStylesJsonLength = MAX_ZALO_STYLE_JSON_LENGTH } = {}) {
183
267
  const { text: cleanText, styles } = parseMarkdownStyles(text);
268
+ const cappedStyles = capStyles(styles, maxStylesJsonLength);
184
269
  return {
185
270
  msg: cleanText,
186
- styles: styles.length > 0 ? styles : undefined,
271
+ styles: cappedStyles.length > 0 ? cappedStyles : undefined,
187
272
  };
188
273
  }