abs-zalo-bot 0.8.2 → 0.11.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.
Files changed (44) hide show
  1. package/README.md +26 -12
  2. package/hermes-plugin/EXTENDING.md +418 -0
  3. package/hermes-plugin/starter-kit/AGENT.md +3 -3
  4. package/hermes-plugin/zalo/__init__.py +3 -0
  5. package/hermes-plugin/zalo/adapter.py +1945 -0
  6. package/hermes-plugin/zalo/flood.py +91 -0
  7. package/hermes-plugin/zalo/plugin.yaml +56 -0
  8. package/hermes-plugin/zalo-style-guide.md +98 -50
  9. package/hermes-plugin/zalo_tools/__init__.py +29 -0
  10. package/hermes-plugin/zalo_tools/facebook.py +249 -0
  11. package/hermes-plugin/zalo_tools/file_maker.py +583 -0
  12. package/hermes-plugin/zalo_tools/people.py +163 -0
  13. package/hermes-plugin/zalo_tools/plugin.yaml +39 -0
  14. package/hermes-plugin/zalo_tools/tools.py +3115 -0
  15. package/mcp/server.js +1 -1
  16. package/package.json +34 -51
  17. package/scripts/doctor.js +19 -0
  18. package/scripts/hermes-install-lib.js +411 -0
  19. package/scripts/install-hermes.js +20 -0
  20. package/scripts/setup-env.js +66 -0
  21. package/scripts/uninstall-hermes.js +19 -0
  22. package/src/config.js +10 -0
  23. package/src/hermes_bridge.js +19 -8
  24. package/src/hermes_media.js +107 -4
  25. package/src/inbound_router.js +13 -6
  26. package/src/policy.js +67 -8
  27. package/src/quote_resolver.js +45 -6
  28. package/src/schema.js +39 -2
  29. package/src/store.js +24 -0
  30. package/src/zalo_math.js +122 -0
  31. package/src/zalo_mentions.js +123 -4
  32. package/src/zalo_runtime.js +162 -55
  33. package/src/zalo_styler.js +274 -127
  34. package/.env.example +0 -61
  35. package/Dockerfile +0 -23
  36. package/SECURITY.md +0 -22
  37. package/config/bots.example.json +0 -33
  38. package/config.toml +0 -65
  39. package/docker-compose.yml +0 -15
  40. package/install.sh +0 -6
  41. package/setup.bat +0 -67
  42. package/setup.ps1 +0 -30
  43. package/setup.sh +0 -12
  44. package/start.bat +0 -22
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-91%2F91%20Passing-brightgreen.svg)](test/)
5
+ [![Automated Tests](https://img.shields.io/badge/Tests-138%2F138%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
 
@@ -20,7 +20,7 @@ Install once · run with 1 command or browser QR · AI Agents connect via Model
20
20
  | **Architecture** | **Dual-Adapter: Personal QR + Official OA (Webhook)** | Single unofficial scraping adapter |
21
21
  | **Safety & Privacy** | **Fail-Closed PolicyGuard + Secret Redaction** | No guardrails (high ban/checkpoint risk) |
22
22
  | **AI Integration** | **Native Model Context Protocol (MCP Stdio Server)** | Raw HTTP webhooks / Manual glue code |
23
- | **Code Quality** | **72/72 Automated Unit & Integration Tests** | Little to no test coverage |
23
+ | **Code Quality** | **138/138 Automated Unit & Integration Tests** | Little to no test coverage |
24
24
  | **Multi-Agent Ready** | **Hermes Agent, Claude Code, OpenAI Codex, Cursor** | Single-system or standalone CLI only |
25
25
 
26
26
  ---
@@ -184,7 +184,7 @@ For profile-aware quality, select `gateway_skill = "your-owner-authored-hermes-s
184
184
  - `__underline__`: Zalo Underline (`u`).
185
185
  - `~~strikethrough~~`: Zalo StrikeThrough (`s`).
186
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").
187
+ - **Safe Styled Chunker (`formatAndChunkZaloMarkdown`)**: Splits long output without dropping characters or breaking UTF-16 surrogate pairs. Every bubble is capped at 2,000 UTF-16 code units, 40 styles, and 3,000 encoded bytes; styles are clipped and rebased per bubble.
188
188
 
189
189
  ---
190
190
 
@@ -227,22 +227,36 @@ cat hermes-plugin/starter-kit/SOUL.md >> ~/.hermes/SOUL.md
227
227
 
228
228
  ---
229
229
 
230
- ## 🆕 What's New in v0.7.1
230
+ ## 🆕 What's New in v0.11.0 (Hermes Platform & Enterprise Toolsets)
231
231
 
232
- > **Robustness release** — self-healing listener, safe styled messages, fixed group member API, expanded payload safety.
232
+ > **Hermes Native Platform Release** — 1-click Hermes integration, dual-tier role-based security, live dashboard telemetry, and modular zero-bloat MCP extensibility.
233
233
 
234
234
  | Feature | Detail |
235
235
  | :--- | :--- |
236
- | **Listener Auto-Restart** | Catches `closed` event from `zca-js` when the listener shuts down permanently (bot goes "deaf" after network drop). Schedules automatic reconnect with exponential back-off: `5s → 15s → 30s → 60s → 120s → 300s`. No operator restart needed. |
237
- | **Plaintext Fallback for Styled Messages** | When Zalo server rejects a styled (formatted) message with a numeric error code, the bot automatically strips formatting and re-sends as plain text — so the content is never silently lost. Network errors (no error code) are NOT retried to prevent duplicate messages. |
238
- | **Payload Byte Budget** | `MAX_ZALO_PAYLOAD_BYTES = 3000` + `measurePayloadBytes()` in `zalo_styler.js` cap total UTF-8 bytes of text + style JSON, matching real-world Zalo rejection threshold of ~3,448 bytes. |
239
- | **Fix `getGroupMembers` API Order** | New `AccountRuntime.getGroupMembers(groupId)` calls `getGroupInfo` first to obtain member UID list, then passes UIDs to `getGroupMembersInfo` — eliminating "Lỗi không xác định" that occurred when a group ID was passed where member UIDs were expected. |
236
+ | **1-Click Hermes Installer** | Run `npm run install:hermes` to automatically detect Hermes layout, atomically merge `config.yaml`, and enable `platforms/zalo`. |
237
+ | **Diagnostic Doctor** | Run `npm run doctor` to execute an 11-point health check verifying plugins, tokens, and bridge connectivity. |
238
+ | **Dual-Tier Toolsets** | **Owner Mode (`zalo_owner`)**: Owner UID unlocks full Hermes terminal, file I/O, browser, and skills.<br>**Public Mode (`zalo_public`)**: Safe conversational tools only, blocking terminal and file tampering from strangers. |
239
+ | **Live Dashboard Telemetry** | Full bi-directional event stream rendered in real-time on Hermes Agent Dashboard / Web UI. |
240
+ | **Lean Modular Architecture** | Zero bloated dependencies in core. Connect any local or cloud tool (ZeroTTS, yt-dlp, deep research) on-demand via standard MCP. |
240
241
 
241
242
  ---
242
243
 
243
- ## 🔄 Seamless Zero-Downtime Upgrade (For Existing Users)
244
+ ## 🆕 What's New in v0.9.2
244
245
 
245
- If you already installed `abs-zalo-bot`, upgrading to **v0.7.1** takes 5 seconds with **ZERO data loss and NO QR re-scan**:
246
+ > **Rich-message reliability release** — complete long replies, quoted context, and broader media normalization.
247
+
248
+ | Feature | Detail |
249
+ | :--- | :--- |
250
+ | **Lossless Styled Chunking** | Long Markdown replies are rendered once, split on readable boundaries, then sent sequentially. Each bubble stays within 2,000 UTF-16 code units, 40 styles, and 3,000 encoded bytes. Styles are clipped and rebased instead of discarded. |
251
+ | **Safe Delivery Semantics** | A quote appears only on the first bubble; an attachment only on the last. Plain-text retry occurs only after a numeric provider rejection. Ambiguous network failures are not retried, preventing likely duplicate sends. |
252
+ | **Quoted Context** | Quoted text and media become normalized Hermes context. Provider URLs remain staging inputs and are excluded from model-facing raw metadata. |
253
+ | **Media Normalization** | Additional Zalo media URL fields are classified and staged through the existing jail and SSRF protections. |
254
+
255
+ ---
256
+
257
+ ## 🔄 Upgrade (For Existing Users)
258
+
259
+ If you already installed `abs-zalo-bot`, upgrade to **v0.9.2** after backing up your runtime data:
246
260
 
247
261
  - **If installed via NPM:**
248
262
  ```bash
@@ -258,7 +272,7 @@ If you already installed `abs-zalo-bot`, upgrading to **v0.7.1** takes 5 seconds
258
272
  docker compose pull && docker compose up -d
259
273
  ```
260
274
 
261
- > *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.*
275
+ > The upgrade does not intentionally modify `data/sessions/`, `data/bridge.sqlite3`, or `.env`. Back them up before upgrading; reconnection behavior still depends on the active Zalo session.
262
276
 
263
277
  ---
264
278
 
@@ -0,0 +1,418 @@
1
+ # Thêm công cụ mới — những cái bẫy đã trả giá
2
+
3
+ Tài liệu này không mô tả kiến trúc (xem README). Nó ghi lại **những chỗ hỏng mà
4
+ không báo lỗi** — loại lỗi tốn nhiều giờ nhất, vì mọi thứ trông vẫn chạy đúng.
5
+
6
+ Điểm chung của sáu cái bẫy dưới đây: hệ thống không hề gãy. Log vẫn xanh, bot vẫn
7
+ trả lời, chỉ là trả lời sai thứ. Nên nguyên tắc bao trùm là **đo ở nơi người dùng
8
+ thật chạm tới**, không đo ở tầng gần mình nhất.
9
+
10
+ ---
11
+
12
+ ## 1. Hai bản module, hai trạng thái khác nhau
13
+
14
+ **Triệu chứng:** bot trả lời "Zalo chưa kết nối, chạy `npm start`…" trong khi
15
+ log vừa in `sidecar ready — logged in as …`.
16
+
17
+ Hermes nạp plugin dưới namespace riêng `hermes_plugins.<slug>`. Nếu adapter viết
18
+
19
+ ```python
20
+ from plugins.zalo_tools.tools import set_active_adapter # ✗
21
+ ```
22
+
23
+ thì Python dựng ra một đối tượng module **thứ hai**, mang `_ACTIVE_ADAPTER` riêng:
24
+
25
+ ```
26
+ adapter ──gắn cầu vào──> plugins.zalo_tools.tools _ACTIVE_ADAPTER = adapter
27
+ agent ──gọi công cụ──> hermes_plugins.zalo_tools.tools _ACTIVE_ADAPTER = None
28
+ ```
29
+
30
+ Hai bên không bao giờ gặp nhau. Cách đúng là phân giải lúc chạy (xem `_zalo_tools()`
31
+ trong `zalo/adapter.py`) và **ghi tên module đã chọn vào log**:
32
+
33
+ ```
34
+ [zalo] connected to sidecar at ws://… (công cụ: hermes_plugins.zalo_tools.tools)
35
+ ```
36
+
37
+ > **Áp dụng rộng:** bất kỳ trạng thái nào chia sẻ giữa hai plugin đều dính bẫy này.
38
+ > Hằng số là chuỗi thì trùng lặp vô hại — chỉ **trạng thái thay đổi được** mới bắt
39
+ > buộc dùng chung một bản.
40
+
41
+ ## 2. Plugin `kind: platform` nạp lười
42
+
43
+ Hermes hoãn import mọi platform plugin cho tới khi gateway thật sự chạm tới nền
44
+ tảng đó. Công cụ đăng ký ở đấy vào registry **muộn hơn** lúc Hermes chốt danh sách
45
+ toolset, nên bị coi là tên lạ và loại sạch — không một dòng cảnh báo.
46
+
47
+ **Quy tắc:** công cụ luôn nằm ở plugin `kind: standalone` riêng. Platform plugin
48
+ chỉ giữ adapter.
49
+
50
+ ## 3. Hermes tự bật mọi toolset plugin nó chưa từng thấy
51
+
52
+ Đây là bẫy nguy hiểm nhất, vì nó **âm thầm vô hiệu hoá phân quyền**. Toolset mới
53
+ mặc định BẬT cho mọi phiên cho tới khi được khai là "đã biết":
54
+
55
+ ```yaml
56
+ known_plugin_toolsets:
57
+ zalo:
58
+ - zalo_owner
59
+ - zalo_public
60
+ ```
61
+
62
+ Thiếu dòng này thì `zalo_owner` được cấp cho cả người lạ nhắn vào nhóm, dù adapter
63
+ đã giới hạn qua `toolsets_for_source()`.
64
+
65
+ ## 4. Đừng đặt tên toolset trùng khoá nền tảng
66
+
67
+ Toolset tên `zalo` bị Hermes tự bật cho mọi phiên vì trùng khoá platform. Đổi
68
+ thành `zalo_owner` mới chặn được. (Đổi tên là cần, nhưng chưa đủ — vẫn phải làm
69
+ mục 3.)
70
+
71
+ ## 5. Phân toolset chỉ là *giấu*, không phải *chặn*
72
+
73
+ Mục 3 và 4 cho thấy danh sách toolset có thể bị hệ thống can thiệp sau lưng mình.
74
+ Nên rào chắn thật phải nằm ở **tầng thực thi**: mỗi công cụ nhóm chủ nhân được
75
+ bọc một lớp kiểm tra danh tính người gửi (`_owner_only` trong `tools.py`). Dù công
76
+ cụ có lọt vào danh sách vì cấu hình sai, người ngoài gọi vẫn bị từ chối.
77
+
78
+ ## 6. Composite `hermes-<platform>` tự sinh kéo theo cả kanban
79
+
80
+ Bẫy này ảnh hưởng **mọi** plugin platform, không riêng Zalo.
81
+
82
+ `hermes-zalo` không có trong `TOOLSETS`. Khi thiếu, `resolve_toolset()` tự sinh nó
83
+ bằng `_HERMES_CORE_TOOLS` — và bộ lõi ấy chứa sẵn 14 công cụ `kanban_*`. Khối
84
+ "recover non-configurable toolsets" trong `tools_config.py` thấy
85
+ `kanban ⊆ universe` nên bật kanban cho **mọi** người nhắn vào nền tảng, đi vòng
86
+ qua `toolsets_for_source()`. Không cấu hình nào cản được:
87
+ `known_builtin_toolsets` không ăn thua (kanban không phải "recently shipped"),
88
+ còn `agent.disabled_toolsets` thì áp dụng toàn cục cho mọi nền tảng.
89
+
90
+ Cách xử lý ở đây (`define_platform_composite()` trong `tools.py`): **định nghĩa
91
+ tường minh** `hermes-zalo` để nhánh tự sinh không chạy nữa, lấy
92
+ `_HERMES_CORE_TOOLS` làm gốc (để bám theo Hermes khi nâng cấp) và trừ đi đúng
93
+ phần kanban. Chủ nhân vẫn dùng kanban qua Zalo được vì adapter liệt kê thẳng
94
+ `kanban` trong override dành riêng cho họ.
95
+
96
+ > Nhớ dọn `toolsets._resolve_toolset_memo` sau khi định nghĩa: bộ đệm khoá theo
97
+ > registry chứ không theo `TOOLSETS`, nên định nghĩa đến muộn có thể bị kết quả
98
+ > đã đệm che mất.
99
+
100
+ ---
101
+
102
+ ## Ghép API zca-js: đọc `.d.ts`, đừng tin trí nhớ
103
+
104
+ Tên trường giữa các API **không** thống nhất. Đầu ra của API này thường không
105
+ vừa đầu vào của API kia.
106
+
107
+ Ví dụ đã trả giá — `zalo_send_sticker` im lặng thất bại suốt vì:
108
+
109
+ | | Hình dạng |
110
+ |---|---|
111
+ | `searchSticker` **trả về** | `{sticker_id, cate_id, type}` — snake_case |
112
+ | `sendSticker` **đòi** | `{id, cateId, type}` — camelCase, tên khác |
113
+
114
+ Truyền thẳng object sang thì `id`/`cateId` thành `undefined`, Zalo trả
115
+ `"Missing sticker id"`, còn agent thì thử vài lượt rồi tự chế lại câu trả lời
116
+ bằng emoji chữ — nhìn từ ngoài y như bot "không thích" gửi sticker.
117
+
118
+ Cũng vậy, **số lượng tham số** phải khớp. `getGroupChatHistory(groupId, count?)`
119
+ chỉ nhận hai; gọi ba tham số thì `null` rơi vào chỗ `count` và số tin yêu cầu bị
120
+ bỏ qua trong im lặng.
121
+
122
+ **Quy trình bắt buộc trước khi thêm một công cụ gọi API mới:**
123
+
124
+ ```bash
125
+ cat node_modules/zca-js/dist/apis/<tenApi>.d.ts
126
+ ```
127
+
128
+ Đọc đúng ba thứ: **thứ tự tham số**, **số lượng tham số**, **tên trường** của
129
+ object. Rồi gọi thử thật qua cầu nối trước khi viết công cụ Python.
130
+
131
+ ---
132
+
133
+ ## Kiểm chứng: đo đúng tầng
134
+
135
+ Ba lần trong quá trình làm, phép thử báo xanh trong khi hệ thống thật vẫn hỏng —
136
+ đều vì đo ở tầng thấp hơn tầng người dùng chạm tới:
137
+
138
+ | Đã đo | Bỏ qua mất | Hệ quả |
139
+ |---|---|---|
140
+ | `resolve_toolset()` trực tiếp | `_get_platform_tools()` | tưởng agent có công cụ, thực ra không |
141
+ | script Node gọi thẳng bridge | toàn bộ tầng Python | sticker "gửi được" nhưng bot vẫn không gửi được |
142
+ | số toolset trả về | công cụ có gọi nổi không | tưởng phân quyền xong, thực ra công cụ chết |
143
+
144
+ **Thứ tự kiểm chứng đúng, từ yếu tới mạnh:**
145
+
146
+ 1. Đọc `.d.ts` — biết hình dạng đúng
147
+ 2. Gọi thật qua cầu nối — biết API sống
148
+ 3. Gọi qua handler Python với `set_turn_context` — biết phân quyền và ngữ cảnh đúng
149
+ 4. **Tag bot trong nhóm thật** — cái duy nhất chứng minh cả chuỗi thông
150
+
151
+ Chỉ bước 4 mới là bằng chứng. Ba bước trên chỉ giúp thu hẹp chỗ hỏng.
152
+
153
+ ---
154
+
155
+ ## Log lúc đăng ký plugin KHÔNG tới được tệp
156
+
157
+ Plugin nạp trước khi handler ghi log gắn vào, nên mọi `logger.info` trong
158
+ `register()` biến mất — kể cả khi đăng ký thành công. Đừng dùng nó để xác minh.
159
+
160
+ Chỗ đo được thật là **trong adapter lúc `connect()`**: logger ở đó đã hoạt động,
161
+ và nó nằm trong đúng tiến trình gateway đang chạy. Xem
162
+ `_log_permission_selfcheck()` — mỗi lần khởi động ghi đúng hai dòng:
163
+
164
+ ```
165
+ [zalo] tự kiểm quyền — chủ nhân: 92 công cụ (39 Zalo), nhạy cảm: [...]
166
+ [zalo] tự kiểm quyền — người trong nhóm: 13 công cụ (13 Zalo), không có công cụ nhạy cảm
167
+ ```
168
+
169
+ Nếu dòng thứ hai có bất kỳ công cụ nhạy cảm nào, phân quyền đã hỏng — biết ngay
170
+ lúc khởi động thay vì đợi ai đó phát hiện trong nhóm.
171
+
172
+
173
+ ---
174
+
175
+ ## Trạng thái từng công cụ (kiểm chứng 2026-09-06)
176
+
177
+ Đã gọi thật qua cầu nối, không suy từ tài liệu. Bot lúc kiểm là **phó nhóm** ở
178
+ nhóm thử.
179
+
180
+ **Chạy được — 31 công cụ.** Toàn bộ nhóm đọc dữ liệu, gửi nội dung (văn bản,
181
+ sticker, tệp, liên kết, thoại, chuyển tiếp), bình chọn, lời nhắc, đổi tên nhóm,
182
+ link nhóm, tắt thông báo, ghim, hồ sơ bot, sổ người quen, kho tài liệu, đọc
183
+ trang web.
184
+
185
+ **Hỏng hoặc không ổn định — 3 công cụ:**
186
+
187
+ | Công cụ | Triệu chứng | Nguyên nhân |
188
+ |---|---|---|
189
+ | `zalo_read_history` | HTTP 404 | Giới hạn zca-js 2.1.2, gọi đúng chữ ký vẫn hỏng |
190
+ | `zalo_undo` | Không dùng được | `sendMessage` chỉ trả `{msgId}`, còn `undo` đòi cả `cliMsgId` — không có đường lấy |
191
+ | `zalo_web_search` | **Chập chờn** — cùng lúc có truy vấn được, có truy vấn lỗi | Đang dùng chế độ không khoá của Exa. Đặt `EXA_API_KEY` cho ổn định |
192
+
193
+ `zalo_undo` sửa được: listener vẫn nhận lại tin bot tự gửi (kèm `cliMsgId`), nên
194
+ có thể đệm một bảng `msgId → cliMsgId` ngắn hạn rồi tra khi thu hồi.
195
+
196
+ **Chưa kiểm được — 6 công cụ.** Chúng để lại dấu vết vĩnh viễn hoặc tác động
197
+ tới người thật, nên không thử tự động: `zalo_create_note` (không có API xoá ghi
198
+ chú), `zalo_create_group`, `zalo_invite_to_groups`, `zalo_join_group_link`,
199
+ `zalo_group_member_change`, `zalo_group_deputy`, `zalo_review_member` (cần có
200
+ người đang chờ duyệt). Tham số của chúng đã đối chiếu với `.d.ts`, nhưng đối
201
+ chiếu không phải là bằng chứng.
202
+
203
+ ---
204
+
205
+ ## Hai lỗi khả dụng đã sửa trong đợt này
206
+
207
+ **`zalo_list_groups` trả về vô dụng.** `getAllGroups` một mình chỉ cho
208
+ `{groupId: version}` — agent nhận một nắm số và không nói nổi cho người dùng
209
+ biết đó là nhóm nào. Nay gọi thêm `getGroupInfo` để trả tên, sĩ số và vai trò
210
+ của bot trong nhóm.
211
+
212
+ **Tạo được lời nhắc mà không xoá được.** Đặt nhầm giờ là lời nhắc nằm lại trong
213
+ nhóm vĩnh viễn, phải nhờ người vào Zalo xoá tay. Đã thêm `zalo_remove_reminder`.
214
+
215
+ Bài học chung: một công cụ "gọi không lỗi" chưa chắc dùng được. Phải nhìn vào
216
+ thứ nó trả về và hỏi *agent làm gì được với cái này*, và mỗi hành động tạo ra
217
+ thứ gì đó phải có đường dọn tương ứng.
218
+
219
+
220
+ ## Một lỗ hổng phát hiện khi rà lại quyền trong nhóm
221
+
222
+ `zalo_send_file` là công cụ **công khai** và nó nhận đường dẫn tệp trên máy chủ.
223
+ Không giới hạn thư mục, nên bất kỳ ai trong nhóm chỉ cần nhờ *"gửi giúp mình
224
+ tệp `<hermes-home>/.env`"* là bot tải khoá API lên nhóm.
225
+
226
+ Việc lọc bí mật của Hermes không cứu được: nó soát **văn bản** đầu ra, còn đây
227
+ là tệp nhị phân đi thẳng lên máy chủ Zalo. Nhắc trong prompt cũng không phải là
228
+ ranh giới.
229
+
230
+ Đã vá: người ngoài chỉ gửi được tệp **nằm trong kho tài liệu** — đúng phạm vi
231
+ `zalo_kb_read` đã mở, không rộng thêm một tấc. Chủ nhân giữ nguyên quyền gửi tệp
232
+ bất kỳ.
233
+
234
+ Bài học rộng hơn: **mỗi công cụ công khai nhận đường dẫn, URL hay ID đều phải
235
+ được hỏi lại là "người ngoài truyền giá trị xấu nhất vào đây thì sao"**. Lần rà
236
+ đầu chỉ chia công cụ theo mức nguy hiểm mà quên soi từng tham số một.
237
+
238
+ ---
239
+
240
+ ## Ba lỗi tìm ra khi chạy thật trong nhóm
241
+
242
+ Cả ba đều không lộ ra ở bất kỳ phép thử tầng dưới nào.
243
+
244
+ ### `uploadAttachment` báo thành công nhưng không gửi gì
245
+
246
+ Agent nói *"em đã gửi đính kèm 2 file"*, lịch sử phiên xác nhận nó **đã gọi**
247
+ `zalo_send_file` hai lần và cả hai trả `success: true` — nhưng trong nhóm không
248
+ có tệp nào.
249
+
250
+ `uploadAttachment` chỉ đẩy tệp lên CDN của Zalo và trả về `fileUrl`/`fileId`.
251
+ Nó **không** đăng tệp thành tin nhắn. Muốn gửi thật phải dùng
252
+ `sendMessage({msg, attachments}, threadId, type)`.
253
+
254
+ Dấu hiệu phân biệt nằm ngay ở giá trị trả về:
255
+
256
+ | Cách gọi | Trả về | Thành tin nhắn? |
257
+ |---|---|---|
258
+ | `uploadAttachment` | `{fileUrl, fileId, totalSize…}` | ❌ |
259
+ | `sendMessage` + `attachments` | `{message:{msgId}, attachment:[{msgId}]}` | ✅ |
260
+
261
+ **Quy tắc rút ra: mọi API gửi nội dung phải trả về `msgId`. Không có `msgId`
262
+ thì chưa có tin nhắn nào cả, dù `success: true`.**
263
+
264
+ ### Tin nhắn kèm link bị vứt trong im lặng
265
+
266
+ `msg.data.content` không phải lúc nào cũng là chuỗi. Dán một đường link, gửi
267
+ ảnh hay tệp thì Zalo đổi nó thành object `{title, description, href, thumb…}`.
268
+ Code cũ chỉ nhận chuỗi nên tin có link thành rỗng, và bị bỏ ngay ở dòng
269
+ `if not text: return` — **không một dòng log nào**, nên nhìn từ ngoài y như bot
270
+ cố tình phớt lờ.
271
+
272
+ Cách phát hiện: đối chiếu tin nhắn thấy trên điện thoại với log. Tin không kèm
273
+ link đều có log, tin kèm link không có dòng nào — chênh lệch đó chỉ ra chỗ hỏng.
274
+
275
+ ### Tiến trình nội bộ nhảy vào nhóm
276
+
277
+ `⌛ Working — 3 min — iteration 3/500, zalo_kb_list` hiện giữa cuộc trò chuyện.
278
+
279
+ Hermes có mặc định hiển thị riêng cho từng nền tảng (`_PLATFORM_DEFAULTS`),
280
+ nhưng **plugin platform không có mặc định nào** nên rơi vào cấu hình toàn cục
281
+ vốn dành cho terminal. Nhóm chat giống kênh Slack chứ không giống terminal: mỗi
282
+ dòng là một tin vĩnh viễn ai cũng thấy, không sửa lại được.
283
+
284
+ Khai trong `config.yaml`:
285
+
286
+ ```yaml
287
+ display:
288
+ platforms:
289
+ zalo:
290
+ tool_progress: "off"
291
+ long_running_notifications: false
292
+ busy_ack_detail: false
293
+ ```
294
+
295
+ > Bất kỳ plugin platform nào cũng nên khai khối này ngay khi dựng, đừng đợi tới
296
+ > lúc tiến trình nội bộ rơi vào mặt khách hàng.
297
+
298
+ ---
299
+
300
+ ## Chậm ở đâu: đo, đừng đoán
301
+
302
+ Một lượt trả lời mất **320 giây** trong khi mô hình được quảng cáo là siêu
303
+ nhanh. Đo từng tầng thì thấy mô hình vô can:
304
+
305
+ | Tầng | Thời gian |
306
+ |---|---|
307
+ | Gọi mô hình (đo trực tiếp qua router) | 1,8 – 3,0s |
308
+ | Gọi mô hình kèm 14 lược đồ công cụ | 3,1 – 5,2s |
309
+ | Mọi công cụ khi ổ đĩa đang nóng | < 1s |
310
+
311
+ Mốc thời gian trong `state.db` chỉ đúng thủ phạm:
312
+
313
+ ```
314
+ 5.3s gọi tool_search
315
+ 1.6s gọi zalo_kb_list
316
+ 239.98s ← kết quả zalo_kb_list
317
+ 0.7s ← lần gọi thứ hai, cùng công cụ
318
+ 30.02s ← zalo_send_file
319
+ 30.03s ← zalo_send_file
320
+ ```
321
+
322
+ Lần hai chỉ 0,7s. Đây là chênh lệch **nguội / nóng** của ổ mạng: kho tài liệu
323
+ nằm trên RaiDrive gắn Google Drive, và khi nguội thì duyệt 1180 thư mục hoặc
324
+ kéo một tệp về đều mất hàng chục giây tới vài phút.
325
+
326
+ Hai bản vá:
327
+
328
+ * **Đệm danh sách kho** (`_kb_listing`, TTL 300s) — đo được 4,6s lần đầu và
329
+ 0,00s các lần sau. Đệm cả cây rồi lọc trong bộ nhớ, nên câu hỏi với từ khoá
330
+ khác cũng không phải duyệt lại.
331
+ * **Nới thời gian chờ riêng cho lệnh đọc tệp** (`SLOW_METHODS`, 150s). Hai lần
332
+ đo được 30,02s và 30,03s — sát ngưỡng `ACK_TIMEOUT_SECONDS = 30` tới mức chỉ
333
+ cần chậm thêm chút là hỏng. Nới riêng nhóm này chứ không nới tất cả: một lệnh
334
+ gửi chữ mà treo 2 phút thì nên báo hỏng sớm.
335
+
336
+ > Truy vết bằng mốc thời gian trong `state.db` hiệu quả hơn hẳn việc đoán, vì
337
+ > nó chỉ thẳng ra khoảng trống nằm ở đâu. Con số tròn (30,0 / 240,0) luôn đáng
338
+ > ngờ — hoặc là timeout, hoặc là một hằng số nào đó, hiếm khi là công việc thật.
339
+
340
+ ## Công cụ web chập chờn vì không có khoá backend
341
+
342
+ `web_search` và `web_extract` khi chưa cấu hình khoá sẽ xoay vòng qua các dịch
343
+ vụ không khoá (Firecrawl, Keenable, Exa). Đo cùng một URL sáu lần: **3 lần hỏng,
344
+ 3 lần được** — mỗi lần một backend khác nhau báo lỗi.
345
+
346
+ Đã thêm thử lại trong `_core` — che bớt triệu chứng, đưa tỉ lệ lên 4/5.
347
+
348
+ **Cách chữa thật là đặt khoá.** Sau khi cắm `EXA_API_KEY`, đo lại với đầu vào
349
+ khác nhau mỗi lần (để bộ đệm không che kết quả):
350
+
351
+ | | Chưa có khoá | Có khoá |
352
+ |---|---|---|
353
+ | `web_search` | chập chờn | **4/4**, mỗi lượt ~1–1,5s |
354
+ | `web_extract` | 3/6 | **5/6** |
355
+
356
+ Lần trượt duy nhất là `moet.gov.vn`, và Exa trả rõ `CRAWL_LIVECRAWL_TIMEOUT` —
357
+ trang đích không cho thu thập chứ không phải backend hỏng. Lượt đó tính phí $0.
358
+
359
+ Vì backend đã ổn định, số lần thử lại giảm từ 3 xuống 2: một trang thật sự
360
+ không đọc được thì thử lại chỉ tổ bắt người trong nhóm chờ thêm.
361
+
362
+ ## Link Google Docs: `/edit` không phải là nội dung
363
+
364
+ Link `/edit` trả về khung ứng dụng JavaScript, nên bộ đọc trang nhận được một
365
+ trang gần như trống kèm nút đăng nhập — và rất dễ kết luận nhầm là *"tài liệu
366
+ không được chia sẻ"*. Tài liệu công khai vẫn đọc được bình thường qua đường
367
+ `/export`:
368
+
369
+ | Loại | Đường đọc được |
370
+ |---|---|
371
+ | Docs | `/document/d/<id>/export?format=txt` |
372
+ | Sheets | `/spreadsheets/d/<id>/export?format=csv` |
373
+ | Slides | `/presentation/d/<id>/export/txt` |
374
+ | Tệp Drive | `/uc?export=download&id=<id>` |
375
+
376
+ `_google_export_url()` đổi tự động. Đổi **sau** khi kiểm tra an toàn, không phải
377
+ trước — để phép kiểm luôn nhìn đúng địa chỉ người dùng đưa vào.
378
+
379
+ ---
380
+
381
+ ## Kho tài liệu trên Google Drive: 10% số tệp vô hình
382
+
383
+ Bot báo *"chưa có tờ trình cho năm học 26-27"* trong khi người dùng mở File
384
+ Explorer ra thì thấy rõ. Soát thẳng ổ đĩa mới ra:
385
+
386
+ ```
387
+ ĐOÀN CNT 26-27\...\01. Đội Thanh niên Xung kích\ĐỘI TNXK - TỜ TRÌNH CÔNG NHẬN BLĐ KHOÁ MỚI.gdoc.URL
388
+ ```
389
+
390
+ Đuôi tệp là **`.gdoc.URL`**. Khi kho tài liệu là một ổ Google Drive gắn qua
391
+ RaiDrive (hoặc Drive for desktop), mọi tài liệu Google **gốc** — Docs, Sheets,
392
+ Slides — không hiện thành `.docx` mà thành một tệp lối tắt bé xíu:
393
+
394
+ ```ini
395
+ [InternetShortcut]
396
+ URL=https://docs.google.com/document/d/<id>/edit?usp=drivesdk
397
+ ```
398
+
399
+ Bộ lọc định dạng chỉ nhận `.docx`, `.pdf`, `.txt`… nên toàn bộ nhóm này bị bỏ
400
+ qua trong im lặng. Đếm trên kho thật: **553/5388 tệp (10,3%)** là `.url` —
401
+ trong đó có đúng tài liệu người dùng đang hỏi.
402
+
403
+ Bot không hề báo lỗi. Nó liệt kê những tệp nó *thấy được*, không tìm thấy thứ
404
+ cần, rồi kết luận là "chưa có" — nghe rất thuyết phục và hoàn toàn sai.
405
+
406
+ Bản vá: coi `.url` là một loại đọc được, đọc địa chỉ trong tệp rồi tải chính
407
+ tài liệu đó về qua đường `/export`. Kết quả: số tệp khớp "xung kích" tăng từ 3
408
+ lên 10, và tài liệu kia đọc ra đủ 922 ký tự.
409
+
410
+ > **Bắt buộc kiểm địa chỉ trước khi tải.** Một tệp `.url` là nội dung do người
411
+ > khác đặt vào kho. Thả vào một lối tắt trỏ tới `http://127.0.0.1/...` là có
412
+ > ngay đường vòng đọc dữ liệu nội bộ, đi qua lưng bộ chặn của `zalo_web_read`.
413
+ > Đã thử bốn dạng (loopback, metadata đám mây, `file://`, lối tắt rỗng) — chặn
414
+ > hết.
415
+
416
+ Bài học chung: **khi bot nói "không tìm thấy", hãy kiểm bộ lọc trước khi tin
417
+ nó.** Một câu trả lời tự tin dựa trên dữ liệu bị lọc mất còn nguy hiểm hơn một
418
+ lỗi rõ ràng, vì không ai nghĩ tới chuyện đi kiểm lại.
@@ -21,6 +21,6 @@ Tài liệu này xác định ranh giới vận hành, cơ chế bảo vệ và
21
21
 
22
22
  ## 3. Quản lý Độ dài & Rich Text Zalo
23
23
 
24
- - Zalo giới hạn ký tự mỗi bong bóng chat.
25
- - Khi gửi tin dài hoặc có định dạng, tự động sử dụng cấu trúc `splitIntoSafeZaloChunks` (tối đa 650 ký tự/bong bóng) để tránh lỗi 118 Zalo.
26
- - Định dạng văn bản sử dụng Markdown chuẩn: `**in đậm**`, `# Tiêu đề`, `[RED]...[/RED]` để hệ thống tự động biên dịch sang Zalo TextStyle sang trọng.
24
+ - Zalo giới hạn độ dài, số style và kích thước mã hóa của mỗi bong bóng chat.
25
+ - Khi gửi tin dài hoặc có định dạng, dùng `formatAndChunkZaloMarkdown`: tối đa 2.000 UTF-16 code units, 40 style và 3.000 byte mỗi bong bóng; không bỏ ký tự, không cắt đôi surrogate pair.
26
+ - Định dạng bằng Markdown: `**in đậm**`, `# Tiêu đề`, `[RED]...[/RED]`. Nếu provider trả mã lỗi dạng số cho styled payload, bridge retry plain text đúng một lần; lỗi mạng không retry để tránh gửi trùng.
@@ -0,0 +1,3 @@
1
+ from .adapter import register
2
+
3
+ __all__ = ["register"]