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 CHANGED
@@ -1,435 +1,82 @@
1
- # ABS Zalo Bot
1
+ # ABS Zalo Bot (Agent Business System)
2
2
 
3
- **Install once · run from a dashboard button or one command · agents read the repo and know how to work in it**
3
+ **The Most Powerful Autonomous Zalo Engine for AI Agents & Hermes**
4
4
 
5
- A local/VPS Zalo bridge for Hermes and coding agents:
5
+ Install once · run from a dashboard button or one command · agents read the repo and know how to operate autonomously.
6
6
 
7
- - **Zalo Personal QR**: listens to allowlisted sources, stores data locally, and posts digests to one destination. This is the *unofficial* path — internal/demo use with a dedicated account only.
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.
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? The agent must read **[AGENTS.md](AGENTS.md)** and the **[Agent handoff](docs/agent-handoff.md)** before running any command.
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
- 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.
23
+ `abs-zalo-bot` hạ tầng kết nối Zalo toàn diện nhất cho AI Agents Hermes:
24
24
 
25
- Ba điều tuyệt đối không làm: không dán secret/OTP/PIN vào chat cho agent, không dùng account Zalo nhân chính để automation, không mở port 3871 thẳng ra Internet.
25
+ 1. **Zalo 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, 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
- Tài liệu còn lại viết bằng tiếng Anh để mọi agent đọc được. Câu lệnh tên biến giữ nguyên, đọc lướt vẫn theo được.
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
- ## Documentation map
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.
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
- ## 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
135
-
136
- If you did not use `--start`:
58
+ ## 🚀 Quickstart & Verification
137
59
 
138
60
  ```bash
139
- npm start
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
- ```bash
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
- 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`
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
- 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
71
+ # 4. Attach MCP Server to Hermes or Claude Desktop
72
+ node mcp/server.js
392
73
  ```
393
74
 
394
- Those local backups must not be committed either.
75
+ ## 🔒 Security & Policy Boundaries
395
76
 
396
- ---
397
-
398
- ## 10. Production checklist
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 Personal MCP — thin MCP facade over hermes-zalo-personal-bridge.
3
+ * ABS Zalo MCP Server Enterprise & Autonomous MCP facade over ABS Zalo Engine.
4
4
  *
5
- * Design learned from minhkhoa0502/zalo-personal-mcp:
6
- * - Own the MCP layer (tool surface + policy)
7
- * - Reuse zca-js only inside the long-lived bridge/daemon
8
- * - History via daemon capture, not REST getGroupChatHistory (often 404)
9
- * - Never log to stdout (MCP stdio protocol)
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-personal-mcp]", ...args);
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-personal-mcp",
61
- version: "0.1.0",
59
+ name: "abs-zalo-mcp",
60
+ version: "0.3.0",
62
61
  });
63
62
 
64
- // --- read tools ---
63
+ // ── Read & Telemetry Tools ──
64
+
65
65
  server.tool(
66
- "zalo_status",
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
- "zalo_list_groups",
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
- "zalo_list_users",
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
- "zalo_group_members",
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
- "zalo_recent_messages",
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
- "zalo_corpus_summary",
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
- "zalo_backfill",
199
- "READ_ONLY backfill: groups/users/members + best-effort old messages. May take minutes. Does not send 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
- "zalo_refresh_discovery",
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
- // Explicitly NOT exposing: free-form send to arbitrary threads, friend mutations, group admin.
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.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
- "description": "Zalo channel adapter for AI agents. Official OA and personal QR kept separate, fail-closed by default.",
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);
@@ -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?.();