abs-zalo-bot 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -404
- package/mcp/server.js +324 -24
- package/package.json +2 -2
- package/src/server.js +198 -0
- package/src/zalo_runtime.js +84 -0
package/README.md
CHANGED
|
@@ -1,435 +1,82 @@
|
|
|
1
|
-
# ABS Zalo Bot
|
|
1
|
+
# ABS Zalo Bot (Agent Business System)
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**The Most Powerful Autonomous Zalo Engine for AI Agents & Hermes**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Install once · run from a dashboard button or one command · agents read the repo and know how to operate autonomously.
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
|
|
9
|
-
- **
|
|
10
|
-
- **
|
|
11
|
-
- **
|
|
7
|
+
An enterprise-ready local/VPS Zalo bridge for Hermes, Claude Code, Codex and multi-agent systems:
|
|
8
|
+
|
|
9
|
+
- **Zalo Personal QR Engine**: Realtime listener, group management (kick, promote, transfer owner), interactive polls, message reactions, recall (undo), media upload, and destination digests.
|
|
10
|
+
- **Zalo Official Account (OA)**: Enterprise webhook adapter for customer support, lead capture, and AI chatbot automation with strict HMAC verification.
|
|
11
|
+
- **Policy Guard & RBAC**: Fail-closed by default. Multi-tiered permissions prevent unauthorized mutations or unapproved outbound side-effects.
|
|
12
|
+
- **Unified Control Dashboard**: Human-in-the-loop web UI (`http://127.0.0.1:3871`) for QR scanning, source group allowlisting, and emergency kill-switch.
|
|
13
|
+
- **Model Context Protocol (MCP)**: Native stdio MCP server (`abs-zalo-mcp`) providing 15+ specialized AI tools for group administration, user intelligence, and interactive workflows.
|
|
12
14
|
|
|
13
15
|
> Just want it running for the owner? Read the **[Non-coder quickstart](docs/quickstart-non-coder.md)**.
|
|
14
16
|
>
|
|
15
|
-
> Handing this repo to Claude Code, Codex, Hermes or any other agent?
|
|
17
|
+
> Handing this repo to Claude Code, Codex, Hermes or any other agent? Read **[AGENTS.md](AGENTS.md)** and **[MCP.md](MCP.md)**.
|
|
16
18
|
|
|
17
19
|
---
|
|
18
20
|
|
|
19
|
-
## Tóm tắt tiếng Việt
|
|
20
|
-
|
|
21
|
-
Repo này là cầu nối Zalo cho agent. Hai đường tách biệt: **Personal QR** (không chính thức, dùng account riêng, chỉ nội bộ/demo) và **Zalo OA** (chính thức, dùng cho khách hàng). Mặc định **fail-closed** — chưa cấu hình thì không gửi gì cả.
|
|
21
|
+
## 🇻🇳 Tóm tắt tiếng Việt
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
`abs-zalo-bot` là hạ tầng kết nối Zalo toàn diện nhất cho AI Agents và Hermes:
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
1. **Zalo Cá nhân (Personal Engine)**: Quản trị nhóm chuyên sâu (kick thành viên, chuyển nhượng trưởng nhóm, bổ nhiệm/bãi nhiệm phó nhóm), tạo & khoá bình chọn (Polls), thả reaction, thu hồi tin nhắn (Undo), tra cứu thông tin người dùng/nhóm, và tự động thu thập ngữ cảnh (Corpus Listener).
|
|
26
|
+
2. **Zalo OA (Doanh nghiệp)**: Webhook 2 chiều chuẩn xác thực HMAC, quản lý hội thoại khách hàng, hỗ trợ lead generation & CSKH tự động.
|
|
27
|
+
3. **Bảo mật & Phân quyền (RBAC Policy Guard)**: Mặc định `fail-closed`, kiểm soát chặt chẽ quyền hạn trước khi thực thi bất kỳ tác vụ gửi tin hay quản trị nhóm nào.
|
|
26
28
|
|
|
27
|
-
|
|
29
|
+
Cài nhanh: `bash setup.sh` rồi `npm start`, mở `http://127.0.0.1:3871`.
|
|
28
30
|
|
|
29
31
|
---
|
|
30
32
|
|
|
31
|
-
##
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
|
36
|
-
|
|
37
|
-
| `
|
|
38
|
-
| `
|
|
39
|
-
| `
|
|
40
|
-
|
|
|
41
|
-
|
|
|
42
|
-
|
|
|
43
|
-
|
|
|
44
|
-
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
|
52
|
-
|
|
53
|
-
| Claude Code (Sonnet/Opus) | `CLAUDE.md` | open repo, run `claude` |
|
|
54
|
-
| Codex CLI (GPT) | `CODEX.md` + `AGENTS.md` | open repo, run `codex` |
|
|
55
|
-
| Antigravity / Gemini CLI | `GEMINI.md` | open repo, run the agent |
|
|
56
|
-
| Hermes Agent | `HERMES.md` + `SKILL.md` | `cp -r . ~/.hermes/skills/abs-zalo-bot` or attach via MCP |
|
|
57
|
-
| Cursor | `.cursorrules` | auto-loaded on open |
|
|
58
|
-
| GitHub Copilot | `.github/copilot-instructions.md` | auto-loaded in VS Code |
|
|
59
|
-
| Any other agent | `AGENTS.md` | drop the file into chat |
|
|
60
|
-
|
|
61
|
-
Same bootstrap for every tool:
|
|
62
|
-
|
|
63
|
-
```bash
|
|
64
|
-
node --version # requires >= 22.5
|
|
65
|
-
npm ci
|
|
66
|
-
npm run doctor
|
|
67
|
-
npm test
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
## 1. Pick the right path before installing
|
|
71
|
-
|
|
72
|
-
| What you need | Pick | Notes |
|
|
73
|
-
|---|---|---|
|
|
74
|
-
| Listen to internal groups and digest them into one ops group | Personal QR | Unofficial. Use a dedicated account. No spam, no broadcast |
|
|
75
|
-
| Talk to customers: FAQ, leads, booking, official notices | Zalo OA | The official path. Needs OA credentials and an HTTPS webhook |
|
|
76
|
-
| Not sure yet | Start with Personal QR in `listen_only` | No automatic sending. Inspect the data first |
|
|
77
|
-
|
|
78
|
-
**Do not use your main personal Zalo account for automation.** Never auto-enter OTP/PIN, never bypass login, never broadcast.
|
|
33
|
+
## ⚡ MCP Tool Surface for AI Agents (`abs-zalo-mcp`)
|
|
34
|
+
|
|
35
|
+
| Category | Tool Name | Description |
|
|
36
|
+
| :--- | :--- | :--- |
|
|
37
|
+
| **Telemetry & Health** | `abs_zalo_status` | Check bridge connection, safety flags & corpus counts |
|
|
38
|
+
| | `abs_zalo_list_groups` | List allowlisted source & destination groups |
|
|
39
|
+
| | `abs_zalo_recent_messages` | Read captured message streams with rich metadata |
|
|
40
|
+
| | `abs_zalo_corpus_summary` | Get aggregated inventory of users, groups, and logs |
|
|
41
|
+
| **Group Administration** | `abs_zalo_kick_member` | Remove a member from a group (Admin/Owner required) |
|
|
42
|
+
| | `abs_zalo_transfer_owner` | Transfer group ownership (Owner required) |
|
|
43
|
+
| | `abs_zalo_add_deputy` | Promote a member to Group Deputy / Admin |
|
|
44
|
+
| | `abs_zalo_remove_deputy` | Demote a Group Deputy back to regular member |
|
|
45
|
+
| | `abs_zalo_invite_member` | Invite a user into a group |
|
|
46
|
+
| **Interaction & Polls** | `abs_zalo_create_poll` | Create interactive polls with custom options |
|
|
47
|
+
| | `abs_zalo_lock_poll` | Lock / close an active voting poll |
|
|
48
|
+
| | `abs_zalo_react_message` | Send emoji reactions to messages (`/:heart`, `/:like`, etc.) |
|
|
49
|
+
| | `abs_zalo_undo_message` | Recall / undo a previously sent message |
|
|
50
|
+
| **Discovery & Search** | `abs_zalo_get_user_info` | Fetch public user profile by userId |
|
|
51
|
+
| | `abs_zalo_get_group_info` | Fetch group settings and metadata |
|
|
52
|
+
| | `abs_zalo_find_user` | Lookup user profile by phone number |
|
|
53
|
+
| | `abs_zalo_list_friends` | List all friends of the account |
|
|
54
|
+
| | `abs_zalo_list_all_groups`| Fetch all joined groups from Zalo server |
|
|
79
55
|
|
|
80
56
|
---
|
|
81
57
|
|
|
82
|
-
##
|
|
83
|
-
|
|
84
|
-
### Requirements
|
|
85
|
-
|
|
86
|
-
- A Linux or macOS machine.
|
|
87
|
-
- Node.js **22.5 or newer**.
|
|
88
|
-
- A dedicated Zalo account if you use Personal QR.
|
|
89
|
-
- No API key is needed to run Personal QR in local listen/digest mode.
|
|
90
|
-
|
|
91
|
-
### Are a VPS, a model, and Telegram required?
|
|
92
|
-
|
|
93
|
-
- **VPS:** not required to try it or run locally. Only needed for 24/7 operation. For an MVP, start around 1 vCPU / 1 GB RAM / 10 GB disk, and put HTTPS plus auth in front of it if it is reachable from outside. That is a starting point for operations, not a load guarantee.
|
|
94
|
-
- **Model/API key:** not required for `listen_only` or the local fallback. You only need a Hermes API compatible with OpenAI's `/v1/chat/completions` if you want an LLM to analyse or rewrite digests. This repo does not install a model and does not issue keys.
|
|
95
|
-
- **Telegram:** not required for the core bridge. The QR is shown in the dashboard on the local machine or VPS; this version does not forward the QR image over Telegram.
|
|
96
|
-
- **Live QR:** there are real routes — `POST /api/accounts/:id/connect`, the `QRCodeGenerated` callback, `GET /api/accounts/:id/qr` — and the dashboard renders it. Scanning and confirming on the phone stays a manual action by the account owner.
|
|
97
|
-
|
|
98
|
-
If you do not know whether Node.js is installed, hand the agent exactly this:
|
|
99
|
-
|
|
100
|
-
> "Check whether this machine has Node.js 22.5+. If not, walk me through installing it. Do not enter any secret, OTP or PIN on my behalf."
|
|
101
|
-
|
|
102
|
-
### One install command
|
|
103
|
-
|
|
104
|
-
From the repo directory:
|
|
105
|
-
|
|
106
|
-
```bash
|
|
107
|
-
bash setup.sh
|
|
108
|
-
```
|
|
109
|
-
|
|
110
|
-
Setup will:
|
|
111
|
-
|
|
112
|
-
1. check Node.js;
|
|
113
|
-
2. create a local `.env` if missing;
|
|
114
|
-
3. create `config/bots.json` from the example if missing;
|
|
115
|
-
4. create the data directory with local-only permissions;
|
|
116
|
-
5. run `npm ci`;
|
|
117
|
-
6. run tests, config validation, secret scan, syntax check and self-check;
|
|
118
|
-
7. print the next step.
|
|
119
|
-
|
|
120
|
-
Setup does **not** log into Zalo, scan a QR, send messages, enter OTP/PIN, or touch real credentials.
|
|
121
|
-
|
|
122
|
-
For agents or CI that must run unattended:
|
|
123
|
-
|
|
124
|
-
```bash
|
|
125
|
-
bash setup.sh --non-interactive
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
To set up and immediately run in the foreground:
|
|
129
|
-
|
|
130
|
-
```bash
|
|
131
|
-
bash setup.sh --start
|
|
132
|
-
```
|
|
133
|
-
|
|
134
|
-
### Run the dashboard
|
|
135
|
-
|
|
136
|
-
If you did not use `--start`:
|
|
58
|
+
## 🚀 Quickstart & Verification
|
|
137
59
|
|
|
138
60
|
```bash
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
Then open:
|
|
143
|
-
|
|
144
|
-
```text
|
|
145
|
-
http://127.0.0.1:3871
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
The dashboard binds to localhost by default. On a remote machine or VPS, **do not expose the port directly to the Internet** — use an HTTPS reverse proxy with its own token/auth.
|
|
149
|
-
|
|
150
|
-
---
|
|
151
|
-
|
|
152
|
-
## 3. First Personal QR connection
|
|
153
|
-
|
|
154
|
-
1. Run `npm start`.
|
|
155
|
-
2. Open the dashboard.
|
|
156
|
-
3. Click **Connect QR** — the dashboard calls the live route and renders the QR image.
|
|
157
|
-
4. Scan it from your phone using a **dedicated account**.
|
|
158
|
-
5. Wait for status `connected`.
|
|
159
|
-
6. Click **Scan group IDs** if the group list needs refreshing.
|
|
160
|
-
7. Pick one group as the **destination group**.
|
|
161
|
-
8. Add sources under **Allowlist source**.
|
|
162
|
-
9. Start in `listen_only`.
|
|
163
|
-
10. Only after the stored data looks right, move one source to `digest_only`.
|
|
164
|
-
|
|
165
|
-
What the modes mean:
|
|
166
|
-
|
|
167
|
-
- `off`: source disabled.
|
|
168
|
-
- `listen_only`: store only. No digest, no send.
|
|
169
|
-
- `digest_only`: included in digests on request or on schedule.
|
|
170
|
-
- `alert_only`: only high-priority candidates are considered for alerts.
|
|
171
|
-
- `mention_only`: still blocked from replying while `READ_ONLY_SOURCE=true`.
|
|
172
|
-
- `reply_enabled`: does not override Policy Guard on its own. Use only with a reviewed policy.
|
|
173
|
-
|
|
174
|
-
**Safe defaults:** sources are not replied to, DMs are not replied to, mentions are not replied to, and outbound goes only to the configured destination.
|
|
175
|
-
|
|
176
|
-
On a headless VPS, reach the dashboard over an SSH tunnel or an authenticated HTTPS reverse proxy. Do not open port `3871` to the Internet, and never put a QR or session into logs or chat.
|
|
177
|
-
|
|
178
|
-
---
|
|
179
|
-
|
|
180
|
-
## 4. Verify the install
|
|
181
|
-
|
|
182
|
-
None of these send a real message:
|
|
61
|
+
# 1. Install dependencies
|
|
62
|
+
npm ci
|
|
183
63
|
|
|
184
|
-
|
|
64
|
+
# 2. Run system doctor & verify tests (68 tests passing)
|
|
185
65
|
npm run doctor
|
|
186
|
-
npm run status
|
|
187
66
|
npm test
|
|
188
|
-
npm run validate-config
|
|
189
|
-
npm run secret-scan
|
|
190
|
-
npm run syntax-check
|
|
191
|
-
npm run self-check
|
|
192
|
-
```
|
|
193
|
-
|
|
194
|
-
What to look for:
|
|
195
|
-
|
|
196
|
-
- `npm test`: everything passes.
|
|
197
|
-
- `npm run doctor`: no public/runtime config errors.
|
|
198
|
-
- `status`: never prints cookies, IMEI, refresh tokens or a raw session.
|
|
199
|
-
- `self-check`: an empty destination/allowlist is a normal fail-closed state.
|
|
200
|
-
|
|
201
|
-
Daemon smoke test:
|
|
202
|
-
|
|
203
|
-
```bash
|
|
204
|
-
npm run smoke
|
|
205
|
-
```
|
|
206
|
-
|
|
207
|
-
`npm run smoke` only checks health, MCP and safety. It **does not send a real message** unless `BATTLE_SEND=true` is set.
|
|
208
|
-
|
|
209
|
-
---
|
|
210
|
-
|
|
211
|
-
## 5. Official OA
|
|
212
|
-
|
|
213
|
-
Personal QR and OA are two different paths. Never mix a Personal session with OA OAuth.
|
|
214
|
-
|
|
215
|
-
### Local preparation
|
|
216
|
-
|
|
217
|
-
```bash
|
|
218
|
-
cp config/bots.example.json config/bots.json
|
|
219
|
-
```
|
|
220
|
-
|
|
221
|
-
Open `config/bots.json`, keep the bot on `draft_first`, and enable it only once valid credentials exist. That file is git-ignored and must not be committed.
|
|
222
67
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
- `ZALO_OA_APP_ID_DEMO_OA`
|
|
226
|
-
- `ZALO_OA_APP_SECRET_DEMO_OA`
|
|
227
|
-
- `ZALO_OA_REFRESH_TOKEN_DEMO_OA`
|
|
228
|
-
- `ZALO_OA_WEBHOOK_SECRET`
|
|
229
|
-
|
|
230
|
-
Real values never live in the public repo.
|
|
231
|
-
|
|
232
|
-
### Webhook
|
|
233
|
-
|
|
234
|
-
Adapter route:
|
|
235
|
-
|
|
236
|
-
```text
|
|
237
|
-
POST /webhooks/zalo/oa/:bot_id
|
|
238
|
-
```
|
|
239
|
-
|
|
240
|
-
The handler validates ingress, normalises text events, and acknowledges quickly. It **does not call an AI and does not send a reply**. Any outbound must go through an external workflow or agent with its own approval and policy.
|
|
241
|
-
|
|
242
|
-
Production needs:
|
|
243
|
-
|
|
244
|
-
- an HTTPS reverse proxy;
|
|
245
|
-
- a signature check or an authenticated edge;
|
|
246
|
-
- a body size limit;
|
|
247
|
-
- logs limited to redacted event IDs/receipts;
|
|
248
|
-
- `draft_first` before enabling any send path.
|
|
249
|
-
|
|
250
|
-
Details: [docs/official-oa.md](docs/official-oa.md).
|
|
251
|
-
|
|
252
|
-
---
|
|
253
|
-
|
|
254
|
-
## 6. Repo layout for agents and CLIs
|
|
255
|
-
|
|
256
|
-
```text
|
|
257
|
-
AGENTS.md non-negotiable rules for every agent
|
|
258
|
-
CLAUDE.md entrypoint for Claude Code
|
|
259
|
-
CODEX.md entrypoint/checklist for Codex CLI
|
|
260
|
-
README.md home page and quickstart
|
|
261
|
-
CONTRIBUTING.md contribution rules
|
|
262
|
-
SECURITY.md how to report a vulnerability
|
|
263
|
-
docs/internal/00-DESIGN.md architecture
|
|
264
|
-
docs/internal/01-SPEC.md I/O contract
|
|
265
|
-
docs/internal/02-PLAN.md build order
|
|
266
|
-
docs/internal/03-HARNESS.md offline test harness
|
|
267
|
-
docs/internal/04-VERIFY.md verification gate
|
|
268
|
-
docs/internal/05-CONTEXT.md runtime constraints
|
|
269
|
-
|
|
270
|
-
setup.sh / install.sh one-command install
|
|
271
|
-
scripts/setup.js setup + doctor + dashboard info
|
|
272
|
-
scripts/public-gate.js validate-config/secret-scan/syntax-check
|
|
273
|
-
|
|
274
|
-
src/ deterministic runtime
|
|
275
|
-
policy.js inbound/outbound guard
|
|
276
|
-
store.js local SQLite
|
|
277
|
-
zalo_runtime.js personal QR listener
|
|
278
|
-
bot_registry.js registry + credential references
|
|
279
|
-
oa_adapter.js official OA OAuth/send boundary
|
|
280
|
-
oa_webhook.js official OA ingress boundary
|
|
281
|
-
server.js dashboard/API/webhook routes
|
|
282
|
-
|
|
283
|
-
config/bots.example.json public example registry
|
|
284
|
-
config/bots.json local registry, git-ignored
|
|
285
|
-
.env.example example environment variables
|
|
286
|
-
.env local, git-ignored
|
|
287
|
-
|
|
288
|
-
data/ SQLite/session/QR, git-ignored
|
|
289
|
-
public/ dashboard static files
|
|
290
|
-
mcp/ MCP stdio facade
|
|
291
|
-
test/ offline tests
|
|
292
|
-
```
|
|
293
|
-
|
|
294
|
-
---
|
|
295
|
-
|
|
296
|
-
## 7. Rules for agents, Claude Code and Codex
|
|
297
|
-
|
|
298
|
-
Agents work in this order:
|
|
299
|
-
|
|
300
|
-
```text
|
|
301
|
-
1. read AGENTS.md
|
|
302
|
-
2. read docs/agent-handoff.md
|
|
303
|
-
3. run npm run doctor
|
|
304
|
-
4. open only the relevant files — do not load the whole repo
|
|
305
|
-
5. make small changes and test immediately
|
|
306
|
-
6. report real evidence: command, exit code, file, test
|
|
307
|
-
```
|
|
308
|
-
|
|
309
|
-
An agent must **never**:
|
|
310
|
-
|
|
311
|
-
- ask the owner to paste an API key, refresh token, cookie, IMEI, session, OTP or PIN into chat;
|
|
312
|
-
- enter an OTP/PIN or confirm a third-party login on the owner's behalf;
|
|
313
|
-
- enable public send, broadcast, deploy, systemd or credential changes without approval;
|
|
314
|
-
- treat "code written" as "code ran" — every done claim needs runtime evidence;
|
|
315
|
-
- claim a GitHub publish without real auth and a real receipt;
|
|
316
|
-
- use an LLM to decide deterministic gates such as empty, duplicate, quota, policy or signature.
|
|
317
|
-
|
|
318
|
-
Full instructions: [docs/agent-handoff.md](docs/agent-handoff.md).
|
|
319
|
-
|
|
320
|
-
---
|
|
321
|
-
|
|
322
|
-
## 8. Quick troubleshooting
|
|
323
|
-
|
|
324
|
-
### `Node.js 22.5+ is required`
|
|
325
|
-
|
|
326
|
-
Install a current Node.js LTS, open a new terminal, then:
|
|
327
|
-
|
|
328
|
-
```bash
|
|
329
|
-
node --version
|
|
330
|
-
bash setup.sh
|
|
331
|
-
```
|
|
332
|
-
|
|
333
|
-
### The dashboard will not open
|
|
334
|
-
|
|
335
|
-
```bash
|
|
336
|
-
npm run doctor
|
|
68
|
+
# 3. Start the daemon (Port 3871)
|
|
337
69
|
npm start
|
|
338
|
-
```
|
|
339
|
-
|
|
340
|
-
If port 3871 is taken, use another one:
|
|
341
|
-
|
|
342
|
-
```bash
|
|
343
|
-
PORT=3872 npm start
|
|
344
|
-
```
|
|
345
|
-
|
|
346
|
-
Then open `http://127.0.0.1:3872`.
|
|
347
|
-
|
|
348
|
-
### The QR does not appear
|
|
349
|
-
|
|
350
|
-
- Check that `npm start` is still running.
|
|
351
|
-
- Click **Connect QR** again.
|
|
352
|
-
- Do not give a PIN/OTP to an agent — do it yourself on the phone/app.
|
|
353
|
-
- Use a dedicated account, not your main personal one.
|
|
354
|
-
|
|
355
|
-
### `not_connected` on digest/send
|
|
356
|
-
|
|
357
|
-
That is the safety block doing its job. Connect the QR, check the destination, then retry. Do not delete the session unless you intend to log in again.
|
|
358
|
-
|
|
359
|
-
### `destination_unset`
|
|
360
|
-
|
|
361
|
-
Open the dashboard and pick a destination group. Never set a destination from a guessed ID.
|
|
362
|
-
|
|
363
|
-
### `npm test` fails
|
|
364
|
-
|
|
365
|
-
Do not skip tests. Hand the agent this:
|
|
366
|
-
|
|
367
|
-
> "Run npm test, read the first failing test, find the cause in the related file/test, make the smallest fix, and run it again. Do not touch secrets or runtime data."
|
|
368
|
-
|
|
369
|
-
### Stop all outbound immediately
|
|
370
|
-
|
|
371
|
-
Click **Kill switch ON** in the dashboard, or:
|
|
372
|
-
|
|
373
|
-
```bash
|
|
374
|
-
curl -X POST http://127.0.0.1:3871/api/kill-switch \
|
|
375
|
-
-H 'content-type: application/json' \
|
|
376
|
-
-d '{"paused":true}'
|
|
377
|
-
```
|
|
378
|
-
|
|
379
|
-
---
|
|
380
|
-
|
|
381
|
-
## 9. Stopping, backups and data
|
|
382
70
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
Local data lives in `data/` and is never committed. The Personal session is stored with restricted permissions. Never hand the database, session or QR to an agent or a third party without redacting it first.
|
|
386
|
-
|
|
387
|
-
Before a large config change:
|
|
388
|
-
|
|
389
|
-
```bash
|
|
390
|
-
cp .env .env.backup.local
|
|
391
|
-
cp config/bots.json config/bots.backup.local.json
|
|
71
|
+
# 4. Attach MCP Server to Hermes or Claude Desktop
|
|
72
|
+
node mcp/server.js
|
|
392
73
|
```
|
|
393
74
|
|
|
394
|
-
|
|
75
|
+
## 🔒 Security & Policy Boundaries
|
|
395
76
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
An open dashboard is not production. You need all of:
|
|
401
|
-
|
|
402
|
-
- clear account/OA ownership;
|
|
403
|
-
- a reviewed allowlist and destination;
|
|
404
|
-
- green tests;
|
|
405
|
-
- secrets in a secret manager or env, never in Git;
|
|
406
|
-
- HTTPS + auth on any public endpoint;
|
|
407
|
-
- a kill switch you have actually tested;
|
|
408
|
-
- logs free of PII and credentials;
|
|
409
|
-
- systemd/Docker enabled only after approval;
|
|
410
|
-
- a live smoke test with real evidence;
|
|
411
|
-
- a tested rollback: pause, disconnect, disable bot.
|
|
412
|
-
|
|
413
|
-
The systemd file in this repo is a template. It does not enable itself.
|
|
77
|
+
- **Side-effect control**: Every outbound message and administrative action is audited through `PolicyGuard`.
|
|
78
|
+
- **Credential isolation**: All session cookies and tokens are kept in private local storage; never exposed over prompts or logs.
|
|
79
|
+
- **Fail-closed default**: Inbound events are listener-only until explicitly allowlisted.
|
|
414
80
|
|
|
415
81
|
---
|
|
416
|
-
|
|
417
|
-
## Next documents
|
|
418
|
-
|
|
419
|
-
- [Non-coder quickstart](docs/quickstart-non-coder.md)
|
|
420
|
-
- [Install guide](docs/install.md)
|
|
421
|
-
- [Agent handoff](docs/agent-handoff.md)
|
|
422
|
-
- [Personal QR](docs/personal-qr.md)
|
|
423
|
-
- [Official OA](docs/official-oa.md)
|
|
424
|
-
- [Commands](docs/commands.md)
|
|
425
|
-
- [Configuration](docs/configuration.md)
|
|
426
|
-
- [Operations](docs/operations.md)
|
|
427
|
-
- [Troubleshooting](docs/troubleshooting.md)
|
|
428
|
-
- [Release checklist](docs/release-checklist.md)
|
|
429
|
-
- [MCP notes](MCP.md)
|
|
430
|
-
|
|
431
|
-
## License
|
|
432
|
-
|
|
433
|
-
MIT. See [LICENSE](LICENSE).
|
|
434
|
-
|
|
435
|
-
**There is no "100% safe".** Official OA is the path to prefer for customers; Personal QR is unofficial, carries platform risk, and belongs only inside a boundary you have reviewed.
|
|
82
|
+
*Built with ❤️ by ABS (Agent Business System).*
|
package/mcp/server.js
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Zalo
|
|
3
|
+
* ABS Zalo MCP Server — Enterprise & Autonomous MCP facade over ABS Zalo Engine.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* -
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
11
|
-
* Our difference: Policy Guard + READ_ONLY_SOURCE + destination-only send.
|
|
5
|
+
* Branding: ABS Bot (Agent Business System)
|
|
6
|
+
* Capabilities:
|
|
7
|
+
* - Read/Corpus & Health checks (abs_zalo_status, abs_zalo_list_groups, abs_zalo_recent_messages, etc.)
|
|
8
|
+
* - Group Administration (abs_zalo_kick_member, abs_zalo_transfer_owner, abs_zalo_add_deputy, abs_zalo_remove_deputy, abs_zalo_invite_member)
|
|
9
|
+
* - Group Interaction & Polls (abs_zalo_create_poll, abs_zalo_lock_poll, abs_zalo_react_message, abs_zalo_undo_message)
|
|
10
|
+
* - Discovery & Info (abs_zalo_get_user_info, abs_zalo_get_group_info, abs_zalo_find_user, abs_zalo_list_friends, abs_zalo_list_all_groups)
|
|
12
11
|
*/
|
|
13
12
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
14
13
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -18,7 +17,7 @@ const BRIDGE_URL = (process.env.ZALO_BRIDGE_URL || "http://127.0.0.1:3871").repl
|
|
|
18
17
|
const TOKEN = process.env.DASHBOARD_TOKEN || process.env.ZALO_BRIDGE_TOKEN || "";
|
|
19
18
|
|
|
20
19
|
function log(...args) {
|
|
21
|
-
console.error("[zalo-
|
|
20
|
+
console.error("[abs-zalo-mcp]", ...args);
|
|
22
21
|
}
|
|
23
22
|
|
|
24
23
|
async function bridge(path, { method = "GET", body = null } = {}) {
|
|
@@ -57,13 +56,14 @@ function fail(err) {
|
|
|
57
56
|
}
|
|
58
57
|
|
|
59
58
|
const server = new McpServer({
|
|
60
|
-
name: "zalo-
|
|
61
|
-
version: "0.
|
|
59
|
+
name: "abs-zalo-mcp",
|
|
60
|
+
version: "0.3.0",
|
|
62
61
|
});
|
|
63
62
|
|
|
64
|
-
//
|
|
63
|
+
// ── Read & Telemetry Tools ──
|
|
64
|
+
|
|
65
65
|
server.tool(
|
|
66
|
-
"
|
|
66
|
+
"abs_zalo_status",
|
|
67
67
|
"Bridge/account safety status: connected?, READ_ONLY_SOURCE, destination, corpus counts.",
|
|
68
68
|
{},
|
|
69
69
|
async () => {
|
|
@@ -87,8 +87,8 @@ server.tool(
|
|
|
87
87
|
);
|
|
88
88
|
|
|
89
89
|
server.tool(
|
|
90
|
-
"
|
|
91
|
-
"List known groups (source_id + name + mode). From bridge catalog / discovery.",
|
|
90
|
+
"abs_zalo_list_groups",
|
|
91
|
+
"List known groups (source_id + name + mode). From ABS bridge catalog / discovery.",
|
|
92
92
|
{
|
|
93
93
|
account_id: z.string().optional().describe("Account id, default bridge default"),
|
|
94
94
|
},
|
|
@@ -113,7 +113,7 @@ server.tool(
|
|
|
113
113
|
);
|
|
114
114
|
|
|
115
115
|
server.tool(
|
|
116
|
-
"
|
|
116
|
+
"abs_zalo_list_users",
|
|
117
117
|
"List known user ids / display names from corpus (members + senders).",
|
|
118
118
|
{
|
|
119
119
|
limit: z.number().int().min(1).max(1000).optional(),
|
|
@@ -133,7 +133,7 @@ server.tool(
|
|
|
133
133
|
);
|
|
134
134
|
|
|
135
135
|
server.tool(
|
|
136
|
-
"
|
|
136
|
+
"abs_zalo_group_members",
|
|
137
137
|
"List members of a group source_id (user_id, role, display_name).",
|
|
138
138
|
{
|
|
139
139
|
source_id: z.string().describe("Zalo group id"),
|
|
@@ -154,7 +154,7 @@ server.tool(
|
|
|
154
154
|
);
|
|
155
155
|
|
|
156
156
|
server.tool(
|
|
157
|
-
"
|
|
157
|
+
"abs_zalo_recent_messages",
|
|
158
158
|
"Read recently captured/stored messages from the bridge corpus (daemon/listener). Prefer this over history API.",
|
|
159
159
|
{
|
|
160
160
|
limit: z.number().int().min(1).max(200).optional(),
|
|
@@ -167,7 +167,6 @@ server.tool(
|
|
|
167
167
|
if (account_id) qs.set("account_id", account_id);
|
|
168
168
|
if (source_id) qs.set("source_id", source_id);
|
|
169
169
|
qs.set("limit", String(limit));
|
|
170
|
-
// bridge /api/events currently supports account_id+limit; filter client-side if source_id
|
|
171
170
|
const data = await bridge(`/api/events?${qs}`);
|
|
172
171
|
let events = data.events || [];
|
|
173
172
|
if (source_id) events = events.filter((e) => String(e.source_id) === String(source_id));
|
|
@@ -179,7 +178,7 @@ server.tool(
|
|
|
179
178
|
);
|
|
180
179
|
|
|
181
180
|
server.tool(
|
|
182
|
-
"
|
|
181
|
+
"abs_zalo_corpus_summary",
|
|
183
182
|
"Corpus inventory: groups, users, member links, messages, last backfill.",
|
|
184
183
|
{
|
|
185
184
|
account_id: z.string().optional(),
|
|
@@ -195,8 +194,8 @@ server.tool(
|
|
|
195
194
|
);
|
|
196
195
|
|
|
197
196
|
server.tool(
|
|
198
|
-
"
|
|
199
|
-
"READ_ONLY backfill: groups/users/members + best-effort old messages.
|
|
197
|
+
"abs_zalo_backfill",
|
|
198
|
+
"READ_ONLY backfill: groups/users/members + best-effort old messages. Does not send messages.",
|
|
200
199
|
{
|
|
201
200
|
history_count: z.number().int().min(1).max(200).optional(),
|
|
202
201
|
max_groups: z.number().int().min(1).max(500).optional(),
|
|
@@ -216,7 +215,7 @@ server.tool(
|
|
|
216
215
|
);
|
|
217
216
|
|
|
218
217
|
server.tool(
|
|
219
|
-
"
|
|
218
|
+
"abs_zalo_refresh_discovery",
|
|
220
219
|
"Re-scan groups after connect: resolve the configured destination + account owner.",
|
|
221
220
|
{
|
|
222
221
|
account_id: z.string().optional(),
|
|
@@ -235,7 +234,308 @@ server.tool(
|
|
|
235
234
|
},
|
|
236
235
|
);
|
|
237
236
|
|
|
238
|
-
//
|
|
237
|
+
// ── Specialized Group Management Tools (ABS Exclusive) ──
|
|
238
|
+
|
|
239
|
+
server.tool(
|
|
240
|
+
"abs_zalo_kick_member",
|
|
241
|
+
"Remove a user/member from a group (Requires Group Admin or Owner rights).",
|
|
242
|
+
{
|
|
243
|
+
group_id: z.string().describe("Target Zalo group id"),
|
|
244
|
+
user_id: z.string().describe("User ID to kick from group"),
|
|
245
|
+
account_id: z.string().optional(),
|
|
246
|
+
},
|
|
247
|
+
async ({ group_id, user_id, account_id }) => {
|
|
248
|
+
try {
|
|
249
|
+
const data = await bridge(`/api/groups/${encodeURIComponent(group_id)}/kick`, {
|
|
250
|
+
method: "POST",
|
|
251
|
+
body: { user_id, account_id },
|
|
252
|
+
});
|
|
253
|
+
return ok(data);
|
|
254
|
+
} catch (e) {
|
|
255
|
+
return fail(e);
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
server.tool(
|
|
261
|
+
"abs_zalo_transfer_owner",
|
|
262
|
+
"Transfer group ownership to another member (Requires Group Owner rights).",
|
|
263
|
+
{
|
|
264
|
+
group_id: z.string().describe("Target Zalo group id"),
|
|
265
|
+
new_owner_id: z.string().describe("User ID of the new group owner"),
|
|
266
|
+
account_id: z.string().optional(),
|
|
267
|
+
},
|
|
268
|
+
async ({ group_id, new_owner_id, account_id }) => {
|
|
269
|
+
try {
|
|
270
|
+
const data = await bridge(`/api/groups/${encodeURIComponent(group_id)}/transfer-owner`, {
|
|
271
|
+
method: "POST",
|
|
272
|
+
body: { new_owner_id, account_id },
|
|
273
|
+
});
|
|
274
|
+
return ok(data);
|
|
275
|
+
} catch (e) {
|
|
276
|
+
return fail(e);
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
server.tool(
|
|
282
|
+
"abs_zalo_add_deputy",
|
|
283
|
+
"Promote a member to Group Deputy / Admin (Requires Group Owner rights).",
|
|
284
|
+
{
|
|
285
|
+
group_id: z.string().describe("Target Zalo group id"),
|
|
286
|
+
user_id: z.string().describe("User ID to promote as deputy"),
|
|
287
|
+
account_id: z.string().optional(),
|
|
288
|
+
},
|
|
289
|
+
async ({ group_id, user_id, account_id }) => {
|
|
290
|
+
try {
|
|
291
|
+
const data = await bridge(`/api/groups/${encodeURIComponent(group_id)}/deputies/add`, {
|
|
292
|
+
method: "POST",
|
|
293
|
+
body: { user_id, account_id },
|
|
294
|
+
});
|
|
295
|
+
return ok(data);
|
|
296
|
+
} catch (e) {
|
|
297
|
+
return fail(e);
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
server.tool(
|
|
303
|
+
"abs_zalo_remove_deputy",
|
|
304
|
+
"Demote a Group Deputy / Admin back to regular member (Requires Group Owner rights).",
|
|
305
|
+
{
|
|
306
|
+
group_id: z.string().describe("Target Zalo group id"),
|
|
307
|
+
user_id: z.string().describe("User ID to demote from deputy"),
|
|
308
|
+
account_id: z.string().optional(),
|
|
309
|
+
},
|
|
310
|
+
async ({ group_id, user_id, account_id }) => {
|
|
311
|
+
try {
|
|
312
|
+
const data = await bridge(`/api/groups/${encodeURIComponent(group_id)}/deputies/remove`, {
|
|
313
|
+
method: "POST",
|
|
314
|
+
body: { user_id, account_id },
|
|
315
|
+
});
|
|
316
|
+
return ok(data);
|
|
317
|
+
} catch (e) {
|
|
318
|
+
return fail(e);
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
server.tool(
|
|
324
|
+
"abs_zalo_invite_member",
|
|
325
|
+
"Invite / add a user into an existing group.",
|
|
326
|
+
{
|
|
327
|
+
group_id: z.string().describe("Target Zalo group id"),
|
|
328
|
+
user_id: z.string().describe("User ID or phone number to invite"),
|
|
329
|
+
account_id: z.string().optional(),
|
|
330
|
+
},
|
|
331
|
+
async ({ group_id, user_id, account_id }) => {
|
|
332
|
+
try {
|
|
333
|
+
const data = await bridge(`/api/groups/${encodeURIComponent(group_id)}/invite`, {
|
|
334
|
+
method: "POST",
|
|
335
|
+
body: { user_id, account_id },
|
|
336
|
+
});
|
|
337
|
+
return ok(data);
|
|
338
|
+
} catch (e) {
|
|
339
|
+
return fail(e);
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
// ── Group Interaction & Message Tools ──
|
|
345
|
+
|
|
346
|
+
server.tool(
|
|
347
|
+
"abs_zalo_create_poll",
|
|
348
|
+
"Create an interactive poll in a Zalo group.",
|
|
349
|
+
{
|
|
350
|
+
group_id: z.string().describe("Target Zalo group id"),
|
|
351
|
+
question: z.string().describe("Poll question text"),
|
|
352
|
+
options: z.array(z.string()).min(2).describe("List of choices/options"),
|
|
353
|
+
expired_time: z.number().optional().describe("Expiration timestamp (ms) or 0 for none"),
|
|
354
|
+
allow_multi: z.boolean().optional().describe("Allow multiple choices"),
|
|
355
|
+
allow_add: z.boolean().optional().describe("Allow members to add new options"),
|
|
356
|
+
is_anon: z.boolean().optional().describe("Anonymous voting"),
|
|
357
|
+
hide_preview: z.boolean().optional().describe("Hide vote count preview"),
|
|
358
|
+
account_id: z.string().optional(),
|
|
359
|
+
},
|
|
360
|
+
async ({ group_id, question, options, expired_time = 0, allow_multi = false, allow_add = false, is_anon = false, hide_preview = false, account_id }) => {
|
|
361
|
+
try {
|
|
362
|
+
const data = await bridge(`/api/groups/${encodeURIComponent(group_id)}/polls`, {
|
|
363
|
+
method: "POST",
|
|
364
|
+
body: {
|
|
365
|
+
question,
|
|
366
|
+
options,
|
|
367
|
+
expired_time,
|
|
368
|
+
allow_multi,
|
|
369
|
+
allow_add,
|
|
370
|
+
is_anon,
|
|
371
|
+
hide_preview,
|
|
372
|
+
account_id,
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
return ok(data);
|
|
376
|
+
} catch (e) {
|
|
377
|
+
return fail(e);
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
server.tool(
|
|
383
|
+
"abs_zalo_lock_poll",
|
|
384
|
+
"Lock / close an active poll in a Zalo group.",
|
|
385
|
+
{
|
|
386
|
+
poll_id: z.string().describe("Poll ID to lock"),
|
|
387
|
+
account_id: z.string().optional(),
|
|
388
|
+
},
|
|
389
|
+
async ({ poll_id, account_id }) => {
|
|
390
|
+
try {
|
|
391
|
+
const data = await bridge(`/api/polls/${encodeURIComponent(poll_id)}/lock`, {
|
|
392
|
+
method: "POST",
|
|
393
|
+
body: { account_id },
|
|
394
|
+
});
|
|
395
|
+
return ok(data);
|
|
396
|
+
} catch (e) {
|
|
397
|
+
return fail(e);
|
|
398
|
+
}
|
|
399
|
+
},
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
server.tool(
|
|
403
|
+
"abs_zalo_react_message",
|
|
404
|
+
"Add an emoji reaction to a Zalo message.",
|
|
405
|
+
{
|
|
406
|
+
icon: z.string().describe("Reaction icon (e.g. /:heart, /:like, /:haha, /:sad, /:angry)"),
|
|
407
|
+
dest: z.string().describe("Destination group/user id"),
|
|
408
|
+
account_id: z.string().optional(),
|
|
409
|
+
},
|
|
410
|
+
async ({ icon, dest, account_id }) => {
|
|
411
|
+
try {
|
|
412
|
+
const data = await bridge("/api/reactions", {
|
|
413
|
+
method: "POST",
|
|
414
|
+
body: { icon, dest, account_id },
|
|
415
|
+
});
|
|
416
|
+
return ok(data);
|
|
417
|
+
} catch (e) {
|
|
418
|
+
return fail(e);
|
|
419
|
+
}
|
|
420
|
+
},
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
server.tool(
|
|
424
|
+
"abs_zalo_undo_message",
|
|
425
|
+
"Undo / recall a sent message on Zalo.",
|
|
426
|
+
{
|
|
427
|
+
dest: z.string().describe("Destination / message context"),
|
|
428
|
+
thread_id: z.string().describe("Thread / group ID"),
|
|
429
|
+
thread_type: z.number().optional().default(1).describe("1 for group, 0 for direct"),
|
|
430
|
+
account_id: z.string().optional(),
|
|
431
|
+
},
|
|
432
|
+
async ({ dest, thread_id, thread_type = 1, account_id }) => {
|
|
433
|
+
try {
|
|
434
|
+
const data = await bridge("/api/messages/undo", {
|
|
435
|
+
method: "POST",
|
|
436
|
+
body: { dest, thread_id, thread_type, account_id },
|
|
437
|
+
});
|
|
438
|
+
return ok(data);
|
|
439
|
+
} catch (e) {
|
|
440
|
+
return fail(e);
|
|
441
|
+
}
|
|
442
|
+
},
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
// ── Discovery & Profile Tools ──
|
|
446
|
+
|
|
447
|
+
server.tool(
|
|
448
|
+
"abs_zalo_get_user_info",
|
|
449
|
+
"Get detailed public profile information of a Zalo user by userId.",
|
|
450
|
+
{
|
|
451
|
+
user_id: z.string().describe("Target Zalo user ID"),
|
|
452
|
+
account_id: z.string().optional(),
|
|
453
|
+
},
|
|
454
|
+
async ({ user_id, account_id }) => {
|
|
455
|
+
try {
|
|
456
|
+
const q = account_id ? `?account_id=${encodeURIComponent(account_id)}` : "";
|
|
457
|
+
const data = await bridge(`/api/user-info/${encodeURIComponent(user_id)}${q}`);
|
|
458
|
+
return ok(data);
|
|
459
|
+
} catch (e) {
|
|
460
|
+
return fail(e);
|
|
461
|
+
}
|
|
462
|
+
},
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
server.tool(
|
|
466
|
+
"abs_zalo_get_group_info",
|
|
467
|
+
"Get detailed metadata and settings of a Zalo group by groupId.",
|
|
468
|
+
{
|
|
469
|
+
group_id: z.string().describe("Target Zalo group ID"),
|
|
470
|
+
account_id: z.string().optional(),
|
|
471
|
+
},
|
|
472
|
+
async ({ group_id, account_id }) => {
|
|
473
|
+
try {
|
|
474
|
+
const q = account_id ? `?account_id=${encodeURIComponent(account_id)}` : "";
|
|
475
|
+
const data = await bridge(`/api/group-info/${encodeURIComponent(group_id)}${q}`);
|
|
476
|
+
return ok(data);
|
|
477
|
+
} catch (e) {
|
|
478
|
+
return fail(e);
|
|
479
|
+
}
|
|
480
|
+
},
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
server.tool(
|
|
484
|
+
"abs_zalo_find_user",
|
|
485
|
+
"Find a Zalo user profile by phone number.",
|
|
486
|
+
{
|
|
487
|
+
phone: z.string().describe("Phone number with country code, e.g. 84901234567"),
|
|
488
|
+
account_id: z.string().optional(),
|
|
489
|
+
},
|
|
490
|
+
async ({ phone, account_id }) => {
|
|
491
|
+
try {
|
|
492
|
+
const qs = new URLSearchParams({ phone });
|
|
493
|
+
if (account_id) qs.set("account_id", account_id);
|
|
494
|
+
const data = await bridge(`/api/find-user?${qs}`);
|
|
495
|
+
return ok(data);
|
|
496
|
+
} catch (e) {
|
|
497
|
+
return fail(e);
|
|
498
|
+
}
|
|
499
|
+
},
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
server.tool(
|
|
503
|
+
"abs_zalo_list_friends",
|
|
504
|
+
"List all friends of the current Zalo account.",
|
|
505
|
+
{
|
|
506
|
+
account_id: z.string().optional(),
|
|
507
|
+
},
|
|
508
|
+
async ({ account_id }) => {
|
|
509
|
+
try {
|
|
510
|
+
const q = account_id ? `?account_id=${encodeURIComponent(account_id)}` : "";
|
|
511
|
+
const data = await bridge(`/api/friends${q}`);
|
|
512
|
+
return ok(data);
|
|
513
|
+
} catch (e) {
|
|
514
|
+
return fail(e);
|
|
515
|
+
}
|
|
516
|
+
},
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
server.tool(
|
|
520
|
+
"abs_zalo_list_all_groups",
|
|
521
|
+
"Fetch all active groups joined by the account from Zalo server.",
|
|
522
|
+
{
|
|
523
|
+
account_id: z.string().optional(),
|
|
524
|
+
},
|
|
525
|
+
async ({ account_id }) => {
|
|
526
|
+
try {
|
|
527
|
+
const q = account_id ? `?account_id=${encodeURIComponent(account_id)}` : "";
|
|
528
|
+
const data = await bridge(`/api/groups-all${q}`);
|
|
529
|
+
return ok(data);
|
|
530
|
+
} catch (e) {
|
|
531
|
+
return fail(e);
|
|
532
|
+
}
|
|
533
|
+
},
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
// Backward-compatibility aliases for older prompts
|
|
537
|
+
server.tool("zalo_status", "Alias for abs_zalo_status", {}, async () => bridge("/api/status").then(ok).catch(fail));
|
|
538
|
+
server.tool("zalo_list_groups", "Alias for abs_zalo_list_groups", { account_id: z.string().optional() }, async ({ account_id }) => bridge(`/api/sources${account_id ? `?account_id=${account_id}` : ""}`).then(ok).catch(fail));
|
|
239
539
|
|
|
240
540
|
async function main() {
|
|
241
541
|
const transport = new StdioServerTransport();
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "abs-zalo-bot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Zalo
|
|
5
|
+
"description": "ABS Zalo Engine for AI Agents & Hermes. Personal QR & Official OA adapters, group administration, polls, reactions, fail-closed policy guard.",
|
|
6
6
|
"author": "teddiesloco",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"keywords": [
|
package/src/server.js
CHANGED
|
@@ -554,6 +554,204 @@ export function createApp({
|
|
|
554
554
|
}
|
|
555
555
|
});
|
|
556
556
|
|
|
557
|
+
// ── Group Ops & Advanced Zalo Endpoints (ABS Specialized Ops) ──
|
|
558
|
+
|
|
559
|
+
app.post("/api/groups/:groupId/kick", async (req, res) => {
|
|
560
|
+
try {
|
|
561
|
+
const accountId = req.body?.account_id || config.default_account_id;
|
|
562
|
+
const memberId = req.body?.user_id || req.body?.member_id;
|
|
563
|
+
if (!memberId) return res.status(400).json({ ok: false, error: "user_id_required" });
|
|
564
|
+
const runtime = hub.getRuntime(accountId);
|
|
565
|
+
if (!runtime.api) return res.status(400).json({ ok: false, error: "not_connected" });
|
|
566
|
+
const result = await runtime.removeUserFromGroup(req.params.groupId, memberId);
|
|
567
|
+
res.json({ ok: true, result });
|
|
568
|
+
} catch (err) {
|
|
569
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
app.post("/api/groups/:groupId/transfer-owner", async (req, res) => {
|
|
574
|
+
try {
|
|
575
|
+
const accountId = req.body?.account_id || config.default_account_id;
|
|
576
|
+
const newOwnerId = req.body?.new_owner_id || req.body?.user_id;
|
|
577
|
+
if (!newOwnerId) return res.status(400).json({ ok: false, error: "new_owner_id_required" });
|
|
578
|
+
const runtime = hub.getRuntime(accountId);
|
|
579
|
+
if (!runtime.api) return res.status(400).json({ ok: false, error: "not_connected" });
|
|
580
|
+
const result = await runtime.changeGroupOwner(req.params.groupId, newOwnerId);
|
|
581
|
+
res.json({ ok: true, result });
|
|
582
|
+
} catch (err) {
|
|
583
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
app.post("/api/groups/:groupId/deputies/add", async (req, res) => {
|
|
588
|
+
try {
|
|
589
|
+
const accountId = req.body?.account_id || config.default_account_id;
|
|
590
|
+
const memberId = req.body?.user_id || req.body?.member_id;
|
|
591
|
+
if (!memberId) return res.status(400).json({ ok: false, error: "user_id_required" });
|
|
592
|
+
const runtime = hub.getRuntime(accountId);
|
|
593
|
+
if (!runtime.api) return res.status(400).json({ ok: false, error: "not_connected" });
|
|
594
|
+
const result = await runtime.addGroupDeputy(req.params.groupId, memberId);
|
|
595
|
+
res.json({ ok: true, result });
|
|
596
|
+
} catch (err) {
|
|
597
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
app.post("/api/groups/:groupId/deputies/remove", async (req, res) => {
|
|
602
|
+
try {
|
|
603
|
+
const accountId = req.body?.account_id || config.default_account_id;
|
|
604
|
+
const memberId = req.body?.user_id || req.body?.member_id;
|
|
605
|
+
if (!memberId) return res.status(400).json({ ok: false, error: "user_id_required" });
|
|
606
|
+
const runtime = hub.getRuntime(accountId);
|
|
607
|
+
if (!runtime.api) return res.status(400).json({ ok: false, error: "not_connected" });
|
|
608
|
+
const result = await runtime.removeGroupDeputy(req.params.groupId, memberId);
|
|
609
|
+
res.json({ ok: true, result });
|
|
610
|
+
} catch (err) {
|
|
611
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
app.post("/api/groups/:groupId/invite", async (req, res) => {
|
|
616
|
+
try {
|
|
617
|
+
const accountId = req.body?.account_id || config.default_account_id;
|
|
618
|
+
const memberId = req.body?.user_id || req.body?.member_id;
|
|
619
|
+
if (!memberId) return res.status(400).json({ ok: false, error: "user_id_required" });
|
|
620
|
+
const runtime = hub.getRuntime(accountId);
|
|
621
|
+
if (!runtime.api) return res.status(400).json({ ok: false, error: "not_connected" });
|
|
622
|
+
const result = await runtime.addUserToGroup(req.params.groupId, memberId);
|
|
623
|
+
res.json({ ok: true, result });
|
|
624
|
+
} catch (err) {
|
|
625
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
app.post("/api/groups/:groupId/polls", async (req, res) => {
|
|
630
|
+
try {
|
|
631
|
+
const accountId = req.body?.account_id || config.default_account_id;
|
|
632
|
+
const { question, options, expired_time, allow_multi, allow_add, is_anon, hide_preview } = req.body || {};
|
|
633
|
+
if (!question || !Array.isArray(options) || options.length < 2) {
|
|
634
|
+
return res.status(400).json({ ok: false, error: "question_and_options_required" });
|
|
635
|
+
}
|
|
636
|
+
const runtime = hub.getRuntime(accountId);
|
|
637
|
+
if (!runtime.api) return res.status(400).json({ ok: false, error: "not_connected" });
|
|
638
|
+
const result = await runtime.createPoll(req.params.groupId, {
|
|
639
|
+
question,
|
|
640
|
+
options,
|
|
641
|
+
expiredTime: expired_time,
|
|
642
|
+
allowMultiChoices: allow_multi,
|
|
643
|
+
allowAddNewOption: allow_add,
|
|
644
|
+
isAnonymous: is_anon,
|
|
645
|
+
hideVotePreview: hide_preview,
|
|
646
|
+
});
|
|
647
|
+
res.json({ ok: true, result });
|
|
648
|
+
} catch (err) {
|
|
649
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
650
|
+
}
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
app.post("/api/polls/:pollId/lock", async (req, res) => {
|
|
654
|
+
try {
|
|
655
|
+
const accountId = req.body?.account_id || config.default_account_id;
|
|
656
|
+
const runtime = hub.getRuntime(accountId);
|
|
657
|
+
if (!runtime.api) return res.status(400).json({ ok: false, error: "not_connected" });
|
|
658
|
+
const result = await runtime.lockPoll(req.params.pollId);
|
|
659
|
+
res.json({ ok: true, result });
|
|
660
|
+
} catch (err) {
|
|
661
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
app.post("/api/reactions", async (req, res) => {
|
|
666
|
+
try {
|
|
667
|
+
const accountId = req.body?.account_id || config.default_account_id;
|
|
668
|
+
const { icon, dest } = req.body || {};
|
|
669
|
+
if (!icon || !dest) return res.status(400).json({ ok: false, error: "icon_and_dest_required" });
|
|
670
|
+
const runtime = hub.getRuntime(accountId);
|
|
671
|
+
if (!runtime.api) return res.status(400).json({ ok: false, error: "not_connected" });
|
|
672
|
+
const result = await runtime.addReaction(icon, dest);
|
|
673
|
+
res.json({ ok: true, result });
|
|
674
|
+
} catch (err) {
|
|
675
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
app.post("/api/messages/undo", async (req, res) => {
|
|
680
|
+
try {
|
|
681
|
+
const accountId = req.body?.account_id || config.default_account_id;
|
|
682
|
+
const { dest, thread_id, thread_type } = req.body || {};
|
|
683
|
+
if (!dest || !thread_id) return res.status(400).json({ ok: false, error: "dest_and_thread_id_required" });
|
|
684
|
+
const runtime = hub.getRuntime(accountId);
|
|
685
|
+
if (!runtime.api) return res.status(400).json({ ok: false, error: "not_connected" });
|
|
686
|
+
const result = await runtime.undoMessage(dest, thread_id, thread_type || 1);
|
|
687
|
+
res.json({ ok: true, result });
|
|
688
|
+
} catch (err) {
|
|
689
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
app.get("/api/user-info/:userId", async (req, res) => {
|
|
694
|
+
try {
|
|
695
|
+
const accountId = req.query.account_id || config.default_account_id;
|
|
696
|
+
const runtime = hub.getRuntime(accountId);
|
|
697
|
+
if (!runtime.api) return res.status(400).json({ ok: false, error: "not_connected" });
|
|
698
|
+
const result = await runtime.getUserInfo(req.params.userId);
|
|
699
|
+
res.json({ ok: true, result });
|
|
700
|
+
} catch (err) {
|
|
701
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
app.get("/api/group-info/:groupId", async (req, res) => {
|
|
706
|
+
try {
|
|
707
|
+
const accountId = req.query.account_id || config.default_account_id;
|
|
708
|
+
const runtime = hub.getRuntime(accountId);
|
|
709
|
+
if (!runtime.api) return res.status(400).json({ ok: false, error: "not_connected" });
|
|
710
|
+
const result = await runtime.getGroupInfo(req.params.groupId);
|
|
711
|
+
res.json({ ok: true, result });
|
|
712
|
+
} catch (err) {
|
|
713
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
714
|
+
}
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
app.get("/api/find-user", async (req, res) => {
|
|
718
|
+
try {
|
|
719
|
+
const phone = req.query.phone;
|
|
720
|
+
if (!phone) return res.status(400).json({ ok: false, error: "phone_required" });
|
|
721
|
+
const accountId = req.query.account_id || config.default_account_id;
|
|
722
|
+
const runtime = hub.getRuntime(accountId);
|
|
723
|
+
if (!runtime.api) return res.status(400).json({ ok: false, error: "not_connected" });
|
|
724
|
+
const result = await runtime.findUser(phone);
|
|
725
|
+
res.json({ ok: true, result });
|
|
726
|
+
} catch (err) {
|
|
727
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
app.get("/api/friends", async (req, res) => {
|
|
732
|
+
try {
|
|
733
|
+
const accountId = req.query.account_id || config.default_account_id;
|
|
734
|
+
const runtime = hub.getRuntime(accountId);
|
|
735
|
+
if (!runtime.api) return res.status(400).json({ ok: false, error: "not_connected" });
|
|
736
|
+
const result = await runtime.getAllFriends();
|
|
737
|
+
res.json({ ok: true, result });
|
|
738
|
+
} catch (err) {
|
|
739
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
app.get("/api/groups-all", async (req, res) => {
|
|
744
|
+
try {
|
|
745
|
+
const accountId = req.query.account_id || config.default_account_id;
|
|
746
|
+
const runtime = hub.getRuntime(accountId);
|
|
747
|
+
if (!runtime.api) return res.status(400).json({ ok: false, error: "not_connected" });
|
|
748
|
+
const result = await runtime.getAllGroups();
|
|
749
|
+
res.json({ ok: true, result });
|
|
750
|
+
} catch (err) {
|
|
751
|
+
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
|
752
|
+
}
|
|
753
|
+
});
|
|
754
|
+
|
|
557
755
|
app.get("/api/battle-ready", async (_req, res) => {
|
|
558
756
|
const accountId = config.default_account_id;
|
|
559
757
|
const acc = store.getAccount(accountId);
|
package/src/zalo_runtime.js
CHANGED
|
@@ -232,6 +232,90 @@ export class AccountRuntime extends EventEmitter {
|
|
|
232
232
|
return this.api.sendMessage(String(text), String(targetId), 1);
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
// ── Group Management & Advanced Ops (ABS Specialized Methods) ──
|
|
236
|
+
|
|
237
|
+
async removeUserFromGroup(groupId, memberId) {
|
|
238
|
+
if (!this.api?.removeUserFromGroup) throw new Error("not_connected");
|
|
239
|
+
const mIds = Array.isArray(memberId) ? memberId : [memberId];
|
|
240
|
+
return this.api.removeUserFromGroup(mIds, String(groupId));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async changeGroupOwner(groupId, newOwnerId) {
|
|
244
|
+
if (!this.api?.changeGroupOwner) throw new Error("not_connected");
|
|
245
|
+
return this.api.changeGroupOwner(String(newOwnerId), String(groupId));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async addGroupDeputy(groupId, memberId) {
|
|
249
|
+
if (!this.api?.addGroupDeputy) throw new Error("not_connected");
|
|
250
|
+
const mIds = Array.isArray(memberId) ? memberId : [memberId];
|
|
251
|
+
return this.api.addGroupDeputy(mIds, String(groupId));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async removeGroupDeputy(groupId, memberId) {
|
|
255
|
+
if (!this.api?.removeGroupDeputy) throw new Error("not_connected");
|
|
256
|
+
const mIds = Array.isArray(memberId) ? memberId : [memberId];
|
|
257
|
+
return this.api.removeGroupDeputy(mIds, String(groupId));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async addUserToGroup(groupId, memberId) {
|
|
261
|
+
if (!this.api?.addUserToGroup) throw new Error("not_connected");
|
|
262
|
+
const mIds = Array.isArray(memberId) ? memberId : [memberId];
|
|
263
|
+
return this.api.addUserToGroup(mIds, String(groupId));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async createPoll(groupId, { question, options, expiredTime = 0, allowMultiChoices = false, allowAddNewOption = false, isAnonymous = false, hideVotePreview = false }) {
|
|
267
|
+
if (!this.api?.createPoll) throw new Error("not_connected");
|
|
268
|
+
return this.api.createPoll({
|
|
269
|
+
question,
|
|
270
|
+
options,
|
|
271
|
+
expiredTime,
|
|
272
|
+
allowMultiChoices,
|
|
273
|
+
allowAddNewOption,
|
|
274
|
+
isAnonymous,
|
|
275
|
+
hideVotePreview,
|
|
276
|
+
}, String(groupId));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async lockPoll(pollId) {
|
|
280
|
+
if (!this.api?.lockPoll) throw new Error("not_connected");
|
|
281
|
+
return this.api.lockPoll(String(pollId));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async addReaction(icon, dest) {
|
|
285
|
+
if (!this.api?.addReaction) throw new Error("not_connected");
|
|
286
|
+
return this.api.addReaction(icon, dest);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async undoMessage(dest, threadId, threadType = 1) {
|
|
290
|
+
if (!this.api?.undo) throw new Error("not_connected");
|
|
291
|
+
return this.api.undo(dest, String(threadId), threadType);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async getGroupInfo(groupId) {
|
|
295
|
+
if (!this.api?.getGroupInfo) throw new Error("not_connected");
|
|
296
|
+
return this.api.getGroupInfo(String(groupId));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async getUserInfo(userId) {
|
|
300
|
+
if (!this.api?.getUserInfo) throw new Error("not_connected");
|
|
301
|
+
return this.api.getUserInfo(String(userId));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async findUser(phoneNumber) {
|
|
305
|
+
if (!this.api?.findUser) throw new Error("not_connected");
|
|
306
|
+
return this.api.findUser(String(phoneNumber));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async getAllFriends() {
|
|
310
|
+
if (!this.api?.getAllFriends) throw new Error("not_connected");
|
|
311
|
+
return this.api.getAllFriends();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async getAllGroups() {
|
|
315
|
+
if (!this.api?.getAllGroups) throw new Error("not_connected");
|
|
316
|
+
return this.api.getAllGroups();
|
|
317
|
+
}
|
|
318
|
+
|
|
235
319
|
async pause() {
|
|
236
320
|
try {
|
|
237
321
|
this.api?.listener?.stop?.();
|