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 +80 -3
- package/hermes-plugin/zalo-style-guide.md +65 -0
- package/mcp/server.js +70 -8
- package/package.json +1 -1
- package/src/hermes_media.js +5 -2
- package/src/rate_limiter.js +139 -0
- package/src/server.js +2 -1
- package/src/zalo_runtime.js +85 -3
- package/src/zalo_styler.js +90 -5
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/abs-zalo-bot)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
-
[](test/)
|
|
6
6
|
[](mcp/)
|
|
7
7
|
[](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** | `
|
|
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.
|
|
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",
|
package/src/hermes_media.js
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
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) });
|
package/src/zalo_runtime.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 {
|
package/src/zalo_styler.js
CHANGED
|
@@ -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
|
|
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:
|
|
271
|
+
styles: cappedStyles.length > 0 ? cappedStyles : undefined,
|
|
187
272
|
};
|
|
188
273
|
}
|