abs-zalo-bot 0.2.0 → 0.3.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 +65 -401
- package/mcp/server.js +324 -24
- package/package.json +15 -7
- package/src/server.js +198 -0
- package/src/zalo_runtime.js +84 -0
package/README.md
CHANGED
|
@@ -1,435 +1,99 @@
|
|
|
1
|
-
# ABS Zalo
|
|
1
|
+
# ABS Zalo Agent Engine 🚀 (Agent Business System)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/abs-zalo-bot)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](test/)
|
|
6
|
+
[](mcp/)
|
|
7
|
+
[](mcp/)
|
|
4
8
|
|
|
5
|
-
|
|
9
|
+
**The Free, Transparent & Autonomous Zalo AI Agent Engine for Developers, Hermes, Claude Code, and Multi-Agent Frameworks.**
|
|
6
10
|
|
|
7
|
-
|
|
8
|
-
- **Zalo OA**: the official adapter, kept separate from Personal QR, and the right choice for a customer-facing bot.
|
|
9
|
-
- **Policy Guard**: fail-closed by default. Nothing is sent until it is configured.
|
|
10
|
-
- **Dashboard**: a non-coder can connect the QR, pick sources, pick a destination, and flip the kill switch.
|
|
11
|
-
- **MCP**: agents can read status, groups, users and corpus, and ask for the destination through a safe contract.
|
|
12
|
-
|
|
13
|
-
> Just want it running for the owner? Read the **[Non-coder quickstart](docs/quickstart-non-coder.md)**.
|
|
14
|
-
>
|
|
15
|
-
> Handing this repo to Claude Code, Codex, Hermes or any other agent? The agent must read **[AGENTS.md](AGENTS.md)** and the **[Agent handoff](docs/agent-handoff.md)** before running any command.
|
|
11
|
+
Install once · run with 1 command or browser QR · AI Agents connect via Model Context Protocol (MCP) to manage Zalo autonomously, safely, and transparently.
|
|
16
12
|
|
|
17
13
|
---
|
|
18
14
|
|
|
19
|
-
##
|
|
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ả.
|
|
22
|
-
|
|
23
|
-
Cài nhanh: `bash setup.sh` rồi `npm start`, mở `http://127.0.0.1:3871`. Bắt đầu ở chế độ `listen_only`, kiểm tra dữ liệu đúng rồi mới mở thêm.
|
|
15
|
+
## 🌟 Why ABS Zalo Agent Engine?
|
|
24
16
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
17
|
+
| Core Advantage | ABS Zalo Agent Engine | Conventional Bots & Scrapers |
|
|
18
|
+
| :--- | :--- | :--- |
|
|
19
|
+
| **Pricing & Freedom** | **100% Free & Open-Source (MIT)** | Paid licenses / Black-box scripts |
|
|
20
|
+
| **Architecture** | **Dual-Adapter: Personal QR + Official OA (Webhook)** | Single unofficial scraping adapter |
|
|
21
|
+
| **Safety & Privacy** | **Fail-Closed PolicyGuard + Secret Redaction** | No guardrails (high ban/checkpoint risk) |
|
|
22
|
+
| **AI Integration** | **Native Model Context Protocol (MCP Stdio Server)** | Raw HTTP webhooks / Manual glue code |
|
|
23
|
+
| **Code Quality** | **68/68 Automated Unit & Integration Tests** | Little to no test coverage |
|
|
24
|
+
| **Multi-Agent Ready** | **Hermes Agent, Claude Code, OpenAI Codex, Cursor** | Single-system or standalone CLI only |
|
|
28
25
|
|
|
29
26
|
---
|
|
30
27
|
|
|
31
|
-
##
|
|
32
|
-
|
|
33
|
-
Start here, in this order:
|
|
34
|
-
|
|
35
|
-
| File | What it is |
|
|
36
|
-
|---|---|
|
|
37
|
-
| `README.md` | This file — install, run, and what the project does |
|
|
38
|
-
| `docs/quickstart-non-coder.md` | Fastest path if you do not write code |
|
|
39
|
-
| `AGENTS.md` | Safety boundary every AI agent must follow |
|
|
40
|
-
| `docs/install.md` · `docs/configuration.md` | Setup and environment variables |
|
|
41
|
-
| `docs/personal-qr.md` · `docs/official-oa.md` | The two Zalo adapters |
|
|
42
|
-
| `docs/operations.md` · `docs/troubleshooting.md` | Running it day to day |
|
|
43
|
-
| `SECURITY.md` · `CONTRIBUTING.md` | Reporting issues and sending changes |
|
|
44
|
-
| `docs/internal/` | Design, spec, plan and verification notes kept for maintainers |
|
|
45
|
-
|
|
46
|
-
## Works with any AI coding tool
|
|
47
|
-
|
|
48
|
-
This repo follows the `AGENTS.md` convention that Codex, Cursor, Zed and other agents already read.
|
|
49
|
-
Each tool has its own entrypoint file; they all point at the same rule set:
|
|
50
|
-
|
|
51
|
-
| Tool | Entrypoint | How to start |
|
|
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.
|
|
79
|
-
|
|
80
|
-
---
|
|
81
|
-
|
|
82
|
-
## 2. Quick install — no coding required
|
|
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
|
|
28
|
+
## 🇻🇳 Tóm tắt tiếng Việt
|
|
135
29
|
|
|
136
|
-
|
|
30
|
+
`abs-zalo-bot` là hạ tầng **Zalo AI Agent** mã nguồn mở miễn phí, an toàn và minh bạch nhất cho các nhà phát triển và doanh nghiệp:
|
|
137
31
|
|
|
138
|
-
|
|
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.
|
|
32
|
+
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 Phó nhóm), tạo & khoá bình chọn (Polls), thả reaction emoji, thu hồi tin nhắn (Recall/Undo), và tự động ghi nhận ngữ cảnh (Corpus Listener).
|
|
33
|
+
2. **Zalo Official Account (OA Doanh nghiệp)**: Webhook 2 chiều chuẩn bảo mật HMAC, tự động tiếp nhận khách hàng, hỗ trợ phân loại Lead Generation & CSKH 24/7.
|
|
34
|
+
3. **Bảo mật & Minh bạch (Fail-Closed Policy Guard)**: Tự động che giấu OTP/thông tin nhạy cảm, chống spam, bảo vệ an toàn tài khoản Zalo.
|
|
35
|
+
4. **Chuẩn Quốc Tế MCP (Model Context Protocol)**: Kết nối trực tiếp và cấp quyền cho AI Agents (Hermes, Claude Code, Codex, Cursor...) làm việc tự chủ mà không cần viết thêm API wrapper.
|
|
149
36
|
|
|
150
37
|
---
|
|
151
38
|
|
|
152
|
-
##
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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.
|
|
39
|
+
## ⚡ MCP Tool Surface for AI Agents (`abs-zalo-mcp`)
|
|
40
|
+
|
|
41
|
+
Attach `npx abs-zalo-bot` or `node mcp/server.js` to your Agent configuration:
|
|
42
|
+
|
|
43
|
+
| Category | Tool Name | Description |
|
|
44
|
+
| :--- | :--- | :--- |
|
|
45
|
+
| **Telemetry & Health** | `abs_zalo_status` | Check bridge status, safety flags, and message corpus count |
|
|
46
|
+
| | `abs_zalo_list_groups` | List allowlisted source & destination groups |
|
|
47
|
+
| | `abs_zalo_recent_messages` | Read captured message streams with full metadata |
|
|
48
|
+
| | `abs_zalo_corpus_summary` | Get aggregated inventory of users, groups, and logs |
|
|
49
|
+
| **Group Administration** | `abs_zalo_kick_member` | Remove a member from a group (Admin/Owner required) |
|
|
50
|
+
| | `abs_zalo_transfer_owner` | Transfer group ownership (Owner required) |
|
|
51
|
+
| | `abs_zalo_add_deputy` | Promote a member to Group Deputy / Admin |
|
|
52
|
+
| | `abs_zalo_remove_deputy` | Demote a Group Deputy back to regular member |
|
|
53
|
+
| | `abs_zalo_invite_member` | Invite / add a user into a group |
|
|
54
|
+
| **Interaction & Polls** | `abs_zalo_create_poll` | Create interactive polls with custom options |
|
|
55
|
+
| | `abs_zalo_lock_poll` | Lock / close an active voting poll |
|
|
56
|
+
| | `abs_zalo_react_message` | Send emoji reactions to messages (`/:heart`, `/:like`, etc.) |
|
|
57
|
+
| | `abs_zalo_undo_message` | Recall / undo a previously sent message |
|
|
58
|
+
| **Discovery & Intel** | `abs_zalo_get_user_info` | Fetch public user profile by userId |
|
|
59
|
+
| | `abs_zalo_get_group_info` | Fetch group settings and metadata |
|
|
60
|
+
| | `abs_zalo_find_user` | Lookup user profile by phone number |
|
|
61
|
+
| | `abs_zalo_list_friends` | List all friends of the account |
|
|
62
|
+
| | `abs_zalo_list_all_groups`| Fetch all joined groups from Zalo server |
|
|
177
63
|
|
|
178
64
|
---
|
|
179
65
|
|
|
180
|
-
##
|
|
181
|
-
|
|
182
|
-
None of these send a real message:
|
|
66
|
+
## 🚀 Quickstart
|
|
183
67
|
|
|
68
|
+
### 1. Global Installation (via npm)
|
|
184
69
|
```bash
|
|
185
|
-
npm
|
|
186
|
-
npm run status
|
|
187
|
-
npm test
|
|
188
|
-
npm run validate-config
|
|
189
|
-
npm run secret-scan
|
|
190
|
-
npm run syntax-check
|
|
191
|
-
npm run self-check
|
|
70
|
+
npm install -g abs-zalo-bot
|
|
192
71
|
```
|
|
193
72
|
|
|
194
|
-
|
|
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
|
-
|
|
73
|
+
### 2. Run with Node / NPM
|
|
217
74
|
```bash
|
|
218
|
-
|
|
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
|
-
|
|
223
|
-
Put credentials in `.env` or a local secret manager — **never in chat, issues, the README, or an agent prompt**. Only the variable names declared under `credential` are used:
|
|
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`
|
|
75
|
+
# Clone repository
|
|
76
|
+
git clone https://github.com/teddiesloco/abs-zalo-bot.git
|
|
77
|
+
cd abs-zalo-bot
|
|
229
78
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
79
|
+
# Install & Run tests
|
|
80
|
+
npm ci
|
|
81
|
+
npm test
|
|
334
82
|
|
|
335
|
-
|
|
336
|
-
npm run doctor
|
|
83
|
+
# Start the daemon
|
|
337
84
|
npm start
|
|
338
85
|
```
|
|
339
86
|
|
|
340
|
-
|
|
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
|
-
|
|
383
|
-
Stop a foreground run with `Ctrl+C`.
|
|
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
|
|
392
|
-
```
|
|
393
|
-
|
|
394
|
-
Those local backups must not be committed either.
|
|
87
|
+
### 3. Open Control Dashboard
|
|
88
|
+
Open `http://127.0.0.1:3871` in your browser to scan QR code, configure group policies, and manage your AI Agent bridge.
|
|
395
89
|
|
|
396
90
|
---
|
|
397
91
|
|
|
398
|
-
##
|
|
399
|
-
|
|
400
|
-
An open dashboard is not production. You need all of:
|
|
92
|
+
## 🔒 Security & Policy Boundaries
|
|
401
93
|
|
|
402
|
-
-
|
|
403
|
-
-
|
|
404
|
-
-
|
|
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.
|
|
94
|
+
- **Side-effect control**: Every outbound message and administrative action is audited through `PolicyGuard`.
|
|
95
|
+
- **Credential isolation**: Session cookies and tokens are kept in private local storage; never exposed over prompts or logs.
|
|
96
|
+
- **Fail-closed default**: Inbound events are listener-only until explicitly allowlisted.
|
|
414
97
|
|
|
415
98
|
---
|
|
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.
|
|
99
|
+
*Built with ❤️ by ABS (Agent Business System) for the Global & Vietnamese AI Agent Community.*
|
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,21 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "abs-zalo-bot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Zalo
|
|
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",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"keywords": [
|
|
9
9
|
"zalo",
|
|
10
|
+
"zalo-agent",
|
|
10
11
|
"zalo-bot",
|
|
11
|
-
"zca-js",
|
|
12
|
-
"chatbot",
|
|
13
|
-
"messaging",
|
|
14
12
|
"ai-agent",
|
|
13
|
+
"ai-agents",
|
|
14
|
+
"hermes-agent",
|
|
15
|
+
"claude-code",
|
|
16
|
+
"codex",
|
|
15
17
|
"mcp",
|
|
18
|
+
"model-context-protocol",
|
|
19
|
+
"zca-js",
|
|
20
|
+
"chatbot",
|
|
21
|
+
"lead-generation",
|
|
16
22
|
"vietnam",
|
|
17
23
|
"official-account",
|
|
18
|
-
"personal-qr"
|
|
24
|
+
"personal-qr",
|
|
25
|
+
"autonomous-agent",
|
|
26
|
+
"open-source"
|
|
19
27
|
],
|
|
20
28
|
"homepage": "https://github.com/teddiesloco/abs-zalo-bot#readme",
|
|
21
29
|
"repository": {
|
|
@@ -37,7 +45,7 @@
|
|
|
37
45
|
"LICENSE"
|
|
38
46
|
],
|
|
39
47
|
"bin": {
|
|
40
|
-
"zalo-personal-mcp": "
|
|
48
|
+
"zalo-personal-mcp": "mcp/server.js"
|
|
41
49
|
},
|
|
42
50
|
"scripts": {
|
|
43
51
|
"start": "node src/cli.js serve",
|
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?.();
|