abs-zalo-bot 0.7.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.
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "abs-zalo-bot",
3
- "version": "0.7.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",
@@ -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) {
@@ -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 {
@@ -20,6 +20,20 @@ export const ZALO_STYLES = {
20
20
 
21
21
  export const MAX_ZALO_STYLE_JSON_LENGTH = 250;
22
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
+ }
23
37
 
24
38
  /**
25
39
  * Measure string length in UTF-16 code units (Zalo's internal character counter).
@@ -159,6 +173,11 @@ export function parseMarkdownStyles(input) {
159
173
 
160
174
  for (let line of lines) {
161
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
+
162
181
  if (line.startsWith("# ")) {
163
182
  hType = 1;
164
183
  line = line.slice(2);
@@ -225,11 +244,11 @@ export function parseMarkdownStyles(input) {
225
244
  }
226
245
  }
227
246
 
228
- // 2. Color tags (Vietnamese and English)
229
- 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]);
230
249
  replaceTag(/\[(?:GREEN|XANH)\]([\s\S]*?)\[\/(?:GREEN|XANH)\]/i, [ZALO_STYLES.Bold, ZALO_STYLES.EmeraldGreen]);
231
250
  replaceTag(/\[(?:ORANGE|CAM)\]([\s\S]*?)\[\/(?:ORANGE|CAM)\]/i, [ZALO_STYLES.Bold, ZALO_STYLES.AmberOrange]);
232
- 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]);
233
252
 
234
253
  // 3. Inline markdown tags
235
254
  replaceTag(/\*\*([\s\S]*?)\*\*/g, [ZALO_STYLES.Bold]);