abs-zalo-bot 0.6.0 โ 0.7.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 +80 -3
- package/mcp/server.js +70 -8
- package/package.json +1 -1
- package/src/hermes_media.js +5 -2
- package/src/rate_limiter.js +139 -0
- package/src/server.js +2 -1
- package/src/zalo_runtime.js +1 -1
- package/src/zalo_styler.js +68 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/abs-zalo-bot)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
-
[](test/)
|
|
6
6
|
[](mcp/)
|
|
7
7
|
[](mcp/)
|
|
8
8
|
|
|
@@ -46,7 +46,17 @@ Attach `npx abs-zalo-bot` or `node mcp/server.js` to your Agent configuration:
|
|
|
46
46
|
| | `abs_zalo_list_groups` | List allowlisted source & destination groups |
|
|
47
47
|
| | `abs_zalo_recent_messages` | Read captured message streams with full metadata |
|
|
48
48
|
| | `abs_zalo_corpus_summary` | Get aggregated inventory of users, groups, and logs |
|
|
49
|
-
| **Group Administration** | `
|
|
49
|
+
| **Group Administration** | `abs_zalo_rename_group` | Rename group name (Admin/Leader required) |
|
|
50
|
+
| | `abs_zalo_change_group_avatar` | Change group avatar image from URL or file |
|
|
51
|
+
| | `abs_zalo_create_group_note` | Create and pin announcement/note at the top |
|
|
52
|
+
| | `abs_zalo_get_pending_members` | List members waiting for approval to join |
|
|
53
|
+
| | `abs_zalo_review_pending_member` | Approve or reject pending member requests |
|
|
54
|
+
| | `abs_zalo_block_group_member` | Block a member permanently from the group |
|
|
55
|
+
| | `abs_zalo_unblock_group_member` | Unblock a previously blocked member |
|
|
56
|
+
| | `abs_zalo_get_group_link` | Get public group invite link (URL) |
|
|
57
|
+
| | `abs_zalo_set_group_link` | Enable or disable public group join link |
|
|
58
|
+
| | `abs_zalo_update_group_settings` | Configure group permissions (lock chat, pin, etc.) |
|
|
59
|
+
| | `abs_zalo_kick_member` | Remove a member from a group (Admin/Owner required) |
|
|
50
60
|
| | `abs_zalo_transfer_owner` | Transfer group ownership (Owner required) |
|
|
51
61
|
| | `abs_zalo_add_deputy` | Promote a member to Group Deputy / Admin |
|
|
52
62
|
| | `abs_zalo_remove_deputy` | Demote a Group Deputy back to regular member |
|
|
@@ -152,7 +162,7 @@ Start MCP with `ABS_ZALO_TOOL_PACK=reader` (default). Move deliberately to `oper
|
|
|
152
162
|
|
|
153
163
|
Set `HERMES_ZALO_MEDIA_INGEST=true` only on the private bridge host to stage inbound Zalo attachments for an authenticated Hermes platform plugin. The bridge returns opaque attachment references and serves the staged local file through its authenticated `/v1/hermes/media/:eventId/:attachmentId` endpoint; it never passes provider CDN URLs or Zalo session data to Hermes. Images, documents, audio/voice and video are bounded to 25 MB for images/files and 100 MB for audio/video.
|
|
154
164
|
|
|
155
|
-
### Hermes Zalo Gateway (0.5)
|
|
165
|
+
### Hermes Zalo Gateway (0.5+)
|
|
156
166
|
|
|
157
167
|
`hermes-plugin/platforms/zalo` is an installable Hermes gateway adapter. It polls the authenticated local bridge and converts only normalized, approved Zalo events into Hermes `MessageEvent`s; it never handles QR, cookies, sessions or arbitrary `zca-js` calls.
|
|
158
168
|
|
|
@@ -162,6 +172,73 @@ For profile-aware quality, select `gateway_skill = "your-owner-authored-hermes-s
|
|
|
162
172
|
|
|
163
173
|
---
|
|
164
174
|
|
|
175
|
+
## ๐จ Zalo Rich Text & Auto Styling Engine (Built-in)
|
|
176
|
+
|
|
177
|
+
`abs-zalo-bot` automatically compiles Markdown syntax and brand color tags into native Zalo TextStyle formatting:
|
|
178
|
+
|
|
179
|
+
- `# Heading 1`: Large Header (`f_18`) + Bold (`b`) + Ruby Red (`c_db342e`).
|
|
180
|
+
- `## Heading 2`: Header Bold (`b`) + Emerald Green (`c_15a85f`).
|
|
181
|
+
- `### Heading 3`: Header Bold (`b`) + Amber Orange (`c_f27806`).
|
|
182
|
+
- `**bold**`: Zalo Bold (`b`).
|
|
183
|
+
- `*italic*`: Zalo Italic (`i`).
|
|
184
|
+
- `__underline__`: Zalo Underline (`u`).
|
|
185
|
+
- `~~strikethrough~~`: Zalo StrikeThrough (`s`).
|
|
186
|
+
- Color tags: `[RED]...[/RED]` (Ruby Red), `[GREEN]...[/GREEN]` (Emerald Green), `[ORANGE]...[/ORANGE]` (Amber Orange), `[YELLOW]...[/YELLOW]` (Royal Gold) โ supports Vietnamese equivalents `[ฤแป]`, `[XANH]`, `[CAM]`, `[VรNG]`.
|
|
187
|
+
- **Safe Bubble Chunker (`splitIntoSafeZaloChunks`)**: Automatically breaks long outputs into sequential bubbles <= 650 chars, permanently eliminating Zalo API Error 118 ("Content too long").
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## ๐ง Hermes Agent Starter Kit (`hermes-plugin/starter-kit/`)
|
|
192
|
+
|
|
193
|
+
Ready-to-use "Digital Brain" template for Hermes Agent:
|
|
194
|
+
|
|
195
|
+
1. **`SOUL.md`**: Conversational, warm, Vietnamese executive assistant voice (zero corporate slop, short mobile-optimized sentences, authentic tone).
|
|
196
|
+
2. **`AGENT.md`**: Deterministic AI architecture, safety rules, fail-closed boundaries, mention-only in groups.
|
|
197
|
+
3. **`skills/zalo-customer-care`**: 1-on-1 customer service, empathetic problem diagnosis, and natural lead qualification.
|
|
198
|
+
4. **`skills/zalo-community-admin`**: 24/7 group moderation (new member welcome, FAQ answering, rule pinning, spam defense).
|
|
199
|
+
|
|
200
|
+
To enable, run 1 command:
|
|
201
|
+
```bash
|
|
202
|
+
cp -R hermes-plugin/starter-kit/skills/* ~/.hermes/skills/
|
|
203
|
+
cat hermes-plugin/starter-kit/SOUL.md >> ~/.hermes/SOUL.md
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## ๐ Seamless Zero-Downtime Upgrade (For Existing Users)
|
|
209
|
+
|
|
210
|
+
If you already installed `abs-zalo-bot` (v0.4.0 or v0.5.0), upgrading to **v0.6.0** takes 5 seconds with **ZERO data loss and NO QR re-scan**:
|
|
211
|
+
|
|
212
|
+
- **If installed via NPM:**
|
|
213
|
+
```bash
|
|
214
|
+
npm install -g abs-zalo-bot@latest
|
|
215
|
+
```
|
|
216
|
+
- **If cloned via Git:**
|
|
217
|
+
```bash
|
|
218
|
+
git pull origin main
|
|
219
|
+
npm install
|
|
220
|
+
```
|
|
221
|
+
- **If running via Docker:**
|
|
222
|
+
```bash
|
|
223
|
+
docker compose pull && docker compose up -d
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
> *Your active login sessions (`data/sessions/`), databases (`data/bridge.sqlite3`), and `.env` settings are 100% preserved. The bot seamlessly reconnects without requesting a new QR scan.*
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
## โญ Support & Community Nudge
|
|
231
|
+
|
|
232
|
+
If ABS Zalo Bot helps your operations or business, please consider starring the repository:
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
# Star via GitHub CLI
|
|
236
|
+
gh repo star teddiesloco/abs-zalo-bot
|
|
237
|
+
```
|
|
238
|
+
Or click Star directly at: **[https://github.com/teddiesloco/abs-zalo-bot](https://github.com/teddiesloco/abs-zalo-bot)** โญ
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
165
242
|
## ๐ Security & Policy Boundaries
|
|
166
243
|
|
|
167
244
|
- **Side-effect control**: Every outbound message and administrative action is audited through `PolicyGuard`.
|
package/mcp/server.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
13
13
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
14
14
|
import { z } from "zod";
|
|
15
|
+
import crypto from "node:crypto";
|
|
15
16
|
import {
|
|
16
17
|
capabilityPackSummary,
|
|
17
18
|
canUseToolPack,
|
|
@@ -19,6 +20,47 @@ import {
|
|
|
19
20
|
requiredToolPackForBridgeRequest,
|
|
20
21
|
} from "../src/mcp_capabilities.js";
|
|
21
22
|
|
|
23
|
+
const PENDING_CONFIRMATION_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
24
|
+
const pendingConfirmations = new Map(); // code -> { action, targetKey, expiresAt }
|
|
25
|
+
|
|
26
|
+
function cleanupExpiredConfirmations() {
|
|
27
|
+
const now = Date.now();
|
|
28
|
+
for (const [code, entry] of pendingConfirmations.entries()) {
|
|
29
|
+
if (now > entry.expiresAt) {
|
|
30
|
+
pendingConfirmations.delete(code);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function requestConfirmation(action, targetKey) {
|
|
36
|
+
cleanupExpiredConfirmations();
|
|
37
|
+
const code = crypto.randomBytes(3).toString("hex").toUpperCase();
|
|
38
|
+
pendingConfirmations.set(code, {
|
|
39
|
+
action,
|
|
40
|
+
targetKey,
|
|
41
|
+
expiresAt: Date.now() + PENDING_CONFIRMATION_TTL_MS,
|
|
42
|
+
});
|
|
43
|
+
return {
|
|
44
|
+
status: "confirmation_required",
|
|
45
|
+
requires_confirmation: true,
|
|
46
|
+
action,
|
|
47
|
+
confirmation_code: code,
|
|
48
|
+
expires_in_seconds: 300,
|
|
49
|
+
warning: `โ ๏ธ HรNH ฤแปNG NHแบ Y CแบขM: Thao tรกc nร y cรณ tรญnh phรก hแปงy/แบฃnh hฦฐแปng lแปn. ฤแป thแปฑc thi, vui lรฒng gแปi lแบกi cรดng cแปฅ kรจm tham sแป confirmation_code="${code}" trong vรฒng 5 phรบt (hoแบทc yรชu cแบงu xรกc nhแบญn trong chat).`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function verifyConfirmation(code, action, targetKey) {
|
|
54
|
+
cleanupExpiredConfirmations();
|
|
55
|
+
if (!code || typeof code !== "string") return false;
|
|
56
|
+
const key = code.trim().toUpperCase();
|
|
57
|
+
const entry = pendingConfirmations.get(key);
|
|
58
|
+
if (!entry) return false;
|
|
59
|
+
if (entry.action !== action || entry.targetKey !== targetKey) return false;
|
|
60
|
+
pendingConfirmations.delete(key);
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
22
64
|
const BRIDGE_URL = (process.env.ZALO_BRIDGE_URL || "http://127.0.0.1:3871").replace(/\/$/, "");
|
|
23
65
|
const TOKEN = process.env.DASHBOARD_TOKEN || process.env.ZALO_BRIDGE_TOKEN || "";
|
|
24
66
|
const TOOL_PACK = normalizeToolPack(process.env.ABS_ZALO_TOOL_PACK || "reader");
|
|
@@ -270,13 +312,18 @@ server.tool(
|
|
|
270
312
|
|
|
271
313
|
server.tool(
|
|
272
314
|
"abs_zalo_kick_member",
|
|
273
|
-
"Remove a user/member from a group (Requires Group Admin or Owner rights).",
|
|
315
|
+
"Remove a user/member from a group (Requires Group Admin or Owner rights). Note: Destructive action requires 2-step confirmation.",
|
|
274
316
|
{
|
|
275
317
|
group_id: z.string().describe("Target Zalo group id"),
|
|
276
318
|
user_id: z.string().describe("User ID to kick from group"),
|
|
277
319
|
account_id: z.string().optional(),
|
|
320
|
+
confirmation_code: z.string().optional().describe("6-char OTP confirmation code. Call without code first to generate OTP."),
|
|
278
321
|
},
|
|
279
|
-
async ({ group_id, user_id, account_id }) => {
|
|
322
|
+
async ({ group_id, user_id, account_id, confirmation_code }) => {
|
|
323
|
+
const targetKey = `${group_id}:${user_id}`;
|
|
324
|
+
if (!verifyConfirmation(confirmation_code, "kick_member", targetKey)) {
|
|
325
|
+
return ok(requestConfirmation("kick_member", targetKey));
|
|
326
|
+
}
|
|
280
327
|
try {
|
|
281
328
|
const data = await bridge(`/api/groups/${encodeURIComponent(group_id)}/kick`, {
|
|
282
329
|
method: "POST",
|
|
@@ -291,13 +338,18 @@ server.tool(
|
|
|
291
338
|
|
|
292
339
|
server.tool(
|
|
293
340
|
"abs_zalo_transfer_owner",
|
|
294
|
-
"Transfer group ownership to another member (Requires Group Owner rights).",
|
|
341
|
+
"Transfer group ownership to another member (Requires Group Owner rights). Note: Destructive action requires 2-step confirmation.",
|
|
295
342
|
{
|
|
296
343
|
group_id: z.string().describe("Target Zalo group id"),
|
|
297
344
|
new_owner_id: z.string().describe("User ID of the new group owner"),
|
|
298
345
|
account_id: z.string().optional(),
|
|
346
|
+
confirmation_code: z.string().optional().describe("6-char OTP confirmation code. Call without code first to generate OTP."),
|
|
299
347
|
},
|
|
300
|
-
async ({ group_id, new_owner_id, account_id }) => {
|
|
348
|
+
async ({ group_id, new_owner_id, account_id, confirmation_code }) => {
|
|
349
|
+
const targetKey = `${group_id}:${new_owner_id}`;
|
|
350
|
+
if (!verifyConfirmation(confirmation_code, "transfer_owner", targetKey)) {
|
|
351
|
+
return ok(requestConfirmation("transfer_owner", targetKey));
|
|
352
|
+
}
|
|
301
353
|
try {
|
|
302
354
|
const data = await bridge(`/api/groups/${encodeURIComponent(group_id)}/transfer-owner`, {
|
|
303
355
|
method: "POST",
|
|
@@ -454,14 +506,19 @@ server.tool(
|
|
|
454
506
|
|
|
455
507
|
server.tool(
|
|
456
508
|
"abs_zalo_undo_message",
|
|
457
|
-
"Undo / recall a sent message on Zalo.",
|
|
509
|
+
"Undo / recall a sent message on Zalo. Note: Irreversible action requires 2-step confirmation.",
|
|
458
510
|
{
|
|
459
511
|
dest: z.string().describe("Destination / message context"),
|
|
460
512
|
thread_id: z.string().describe("Thread / group ID"),
|
|
461
513
|
thread_type: z.number().optional().default(1).describe("1 for group, 0 for direct"),
|
|
462
514
|
account_id: z.string().optional(),
|
|
515
|
+
confirmation_code: z.string().optional().describe("6-char OTP confirmation code. Call without code first to generate OTP."),
|
|
463
516
|
},
|
|
464
|
-
async ({ dest, thread_id, thread_type = 1, account_id }) => {
|
|
517
|
+
async ({ dest, thread_id, thread_type = 1, account_id, confirmation_code }) => {
|
|
518
|
+
const targetKey = `${thread_id}:${dest}`;
|
|
519
|
+
if (!verifyConfirmation(confirmation_code, "undo_message", targetKey)) {
|
|
520
|
+
return ok(requestConfirmation("undo_message", targetKey));
|
|
521
|
+
}
|
|
465
522
|
try {
|
|
466
523
|
const data = await bridge("/api/messages/undo", {
|
|
467
524
|
method: "POST",
|
|
@@ -671,13 +728,18 @@ server.tool(
|
|
|
671
728
|
|
|
672
729
|
server.tool(
|
|
673
730
|
"abs_zalo_block_group_member",
|
|
674
|
-
"Block a member permanently from joining or chatting in the group.",
|
|
731
|
+
"Block a member permanently from joining or chatting in the group. Note: Destructive action requires 2-step confirmation.",
|
|
675
732
|
{
|
|
676
733
|
group_id: z.string().describe("Zalo group ID"),
|
|
677
734
|
member_id: z.string().describe("Zalo user ID to block"),
|
|
678
735
|
account_id: z.string().optional(),
|
|
736
|
+
confirmation_code: z.string().optional().describe("6-char OTP confirmation code. Call without code first to generate OTP."),
|
|
679
737
|
},
|
|
680
|
-
async ({ group_id, member_id, account_id }) => {
|
|
738
|
+
async ({ group_id, member_id, account_id, confirmation_code }) => {
|
|
739
|
+
const targetKey = `${group_id}:${member_id}`;
|
|
740
|
+
if (!verifyConfirmation(confirmation_code, "block_group_member", targetKey)) {
|
|
741
|
+
return ok(requestConfirmation("block_group_member", targetKey));
|
|
742
|
+
}
|
|
681
743
|
try {
|
|
682
744
|
const data = await bridge(`/api/groups/${encodeURIComponent(group_id)}/blocked/add`, {
|
|
683
745
|
method: "POST",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "abs-zalo-bot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "ABS Zalo Agent Engine โ Free, Transparent & Autonomous Zalo AI Agent Engine for Hermes, Claude Code & Codex. Dual Personal QR + Official OA, Group Administration, Lead Intel, Polls, Reactions & MCP Server.",
|
|
6
6
|
"author": "teddiesloco",
|
package/src/hermes_media.js
CHANGED
|
@@ -64,7 +64,10 @@ export async function stageHermesMedia(event, { dataDir, fetchImpl = globalThis.
|
|
|
64
64
|
const declared = Number(response.headers.get("content-length") || 0);
|
|
65
65
|
if (declared > limit) throw new Error("attachment_too_large");
|
|
66
66
|
const id = crypto.createHash("sha256").update(`${event.event_id}|${candidate.url}`).digest("hex").slice(0, 24);
|
|
67
|
-
const
|
|
67
|
+
const ext = path.extname(candidate.name) || (candidate.kind === "image" ? ".jpg" : candidate.kind === "audio" ? ".m4a" : candidate.kind === "video" ? ".mp4" : "");
|
|
68
|
+
const baseCandidateName = safeName(candidate.name, "attachment");
|
|
69
|
+
const candidateNameWithExt = ext && !baseCandidateName.toLowerCase().endsWith(ext) ? `${baseCandidateName}${ext}` : baseCandidateName;
|
|
70
|
+
const relative = path.join("media", event.event_id, `${id}-${candidateNameWithExt}`);
|
|
68
71
|
const full = path.resolve(dataDir, relative);
|
|
69
72
|
if (!full.startsWith(`${path.resolve(dataDir, "media")}${path.sep}`)) throw new Error("attachment_path_invalid");
|
|
70
73
|
fs.mkdirSync(path.dirname(full), { recursive: true, mode: 0o700 });
|
|
@@ -84,7 +87,7 @@ export async function stageHermesMedia(event, { dataDir, fetchImpl = globalThis.
|
|
|
84
87
|
});
|
|
85
88
|
}
|
|
86
89
|
if (!bytes) throw new Error("attachment_empty");
|
|
87
|
-
refs.push({ id, path: relative, name:
|
|
90
|
+
refs.push({ id, path: relative, name: candidateNameWithExt, kind: candidate.kind, mime: response.headers.get("content-type")?.split(";")[0] || extensionMime(candidateNameWithExt, candidate.kind), size: bytes });
|
|
88
91
|
} catch (err) {
|
|
89
92
|
refs.push({ id: crypto.randomUUID(), name: safeName(candidate.name, "attachment"), kind: candidate.kind || "file", status: "unavailable", error: String(err?.message || err).slice(0, 80) });
|
|
90
93
|
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ABS Zalo Token Bucket Rate Limiter
|
|
3
|
+
*
|
|
4
|
+
* Smooths outbound messages using a token bucket algorithm to prevent
|
|
5
|
+
* triggering Zalo anti-spam / checkpoint mechanisms while keeping real-time
|
|
6
|
+
* conversational responses instantaneous (zero delay for the first burst).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export class RateLimitedError extends Error {
|
|
10
|
+
constructor(waitMs) {
|
|
11
|
+
super(
|
|
12
|
+
`ฤang bแป giรฃn nhแปp chแปng spam Zalo, cแบงn chแป ~${Math.ceil(waitMs / 1000)}s. ` +
|
|
13
|
+
`Vui lรฒng gแปญi รญt tin hฦกn hoแบทc thแปญ lแบกi sau.`
|
|
14
|
+
);
|
|
15
|
+
this.name = "RateLimitedError";
|
|
16
|
+
this.waitMs = waitMs;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class RateLimiter {
|
|
21
|
+
#tokens;
|
|
22
|
+
#capacity;
|
|
23
|
+
#refillMs;
|
|
24
|
+
#maxWaitMs;
|
|
25
|
+
#last;
|
|
26
|
+
#hi = [];
|
|
27
|
+
#lo = [];
|
|
28
|
+
#timer = null;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {object} opts
|
|
32
|
+
* @param {number} opts.capacity Burst capacity (default 5: burst of 5 messages with 0 delay).
|
|
33
|
+
* @param {number} opts.refillMs Token regeneration time (default 3000ms = sustainable 20 msg/min).
|
|
34
|
+
* @param {number} opts.maxWaitMs Max wait before rejecting with RateLimitedError (default 20000ms).
|
|
35
|
+
*/
|
|
36
|
+
constructor({ capacity = 5, refillMs = 3000, maxWaitMs = 20000 } = {}) {
|
|
37
|
+
this.#capacity = Math.max(1, capacity);
|
|
38
|
+
this.#refillMs = Math.max(1, refillMs);
|
|
39
|
+
this.#maxWaitMs = Math.max(0, maxWaitMs);
|
|
40
|
+
this.#tokens = this.#capacity;
|
|
41
|
+
this.#last = Date.now();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get queued() {
|
|
45
|
+
return this.#hi.length + this.#lo.length;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get available() {
|
|
49
|
+
this.#refill();
|
|
50
|
+
return Math.floor(this.#tokens);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#refill() {
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
const gained = (now - this.#last) / this.#refillMs;
|
|
56
|
+
if (gained <= 0) return;
|
|
57
|
+
this.#tokens = Math.min(this.#capacity, this.#tokens + gained);
|
|
58
|
+
this.#last = now;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Acquire a rate limit slot.
|
|
63
|
+
*
|
|
64
|
+
* @param {'high'|'normal'} priority 'high' for immediate conversational replies, 'normal' for bulk/sync.
|
|
65
|
+
* @returns {Promise<void>} Resolves when granted permission to send.
|
|
66
|
+
* @throws {RateLimitedError} When queue wait time exceeds maxWaitMs.
|
|
67
|
+
*/
|
|
68
|
+
acquire(priority = "normal") {
|
|
69
|
+
this.#refill();
|
|
70
|
+
|
|
71
|
+
// Fast path: tokens available and no existing queue -> immediate dispatch (0 latency)
|
|
72
|
+
if (this.queued === 0 && this.#tokens >= 1) {
|
|
73
|
+
this.#tokens -= 1;
|
|
74
|
+
return Promise.resolve();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const ahead = priority === "high" ? this.#hi.length : this.queued;
|
|
78
|
+
const waitMs = Math.max(0, Math.ceil((ahead + 1 - this.#tokens) * this.#refillMs));
|
|
79
|
+
if (waitMs > this.#maxWaitMs) {
|
|
80
|
+
return Promise.reject(new RateLimitedError(waitMs));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
(priority === "high" ? this.#hi : this.#lo).push(resolve);
|
|
85
|
+
this.#schedule();
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#schedule() {
|
|
90
|
+
if (this.#timer) return;
|
|
91
|
+
const tick = () => {
|
|
92
|
+
this.#timer = null;
|
|
93
|
+
this.#refill();
|
|
94
|
+
while (this.#tokens >= 1 && this.queued > 0) {
|
|
95
|
+
const next = this.#hi.shift() ?? this.#lo.shift();
|
|
96
|
+
this.#tokens -= 1;
|
|
97
|
+
next();
|
|
98
|
+
}
|
|
99
|
+
if (this.queued > 0) {
|
|
100
|
+
const need = Math.ceil((1 - this.#tokens) * this.#refillMs);
|
|
101
|
+
this.#timer = setTimeout(tick, Math.max(25, need));
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
this.#timer = setTimeout(tick, 25);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Stop active timers to permit clean process termination. */
|
|
108
|
+
stop() {
|
|
109
|
+
if (this.#timer) {
|
|
110
|
+
clearTimeout(this.#timer);
|
|
111
|
+
this.#timer = null;
|
|
112
|
+
}
|
|
113
|
+
while (this.#hi.length > 0) {
|
|
114
|
+
const resolve = this.#hi.shift();
|
|
115
|
+
resolve();
|
|
116
|
+
}
|
|
117
|
+
while (this.#lo.length > 0) {
|
|
118
|
+
const resolve = this.#lo.shift();
|
|
119
|
+
resolve();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Methods subject to rate throttling */
|
|
125
|
+
export const THROTTLED_METHODS = new Set([
|
|
126
|
+
"sendMessage",
|
|
127
|
+
"sendVoice",
|
|
128
|
+
"sendVideo",
|
|
129
|
+
"sendSticker",
|
|
130
|
+
"sendLink",
|
|
131
|
+
"sendCard",
|
|
132
|
+
"uploadAttachment",
|
|
133
|
+
"forwardMessage",
|
|
134
|
+
"createPoll",
|
|
135
|
+
"createNote",
|
|
136
|
+
"createReminder",
|
|
137
|
+
"addUserToGroup",
|
|
138
|
+
"inviteUserToGroups",
|
|
139
|
+
]);
|
package/src/server.js
CHANGED
|
@@ -883,7 +883,8 @@ export function createApp({
|
|
|
883
883
|
if (!dest || !thread_id) return res.status(400).json({ ok: false, error: "dest_and_thread_id_required" });
|
|
884
884
|
const runtime = hub.getRuntime(accountId);
|
|
885
885
|
if (!runtime.api) return res.status(400).json({ ok: false, error: "not_connected" });
|
|
886
|
-
const
|
|
886
|
+
const resolvedType = thread_type !== undefined ? Number(thread_type) : 0;
|
|
887
|
+
const result = await runtime.undoMessage(dest, thread_id, resolvedType);
|
|
887
888
|
res.json({ ok: true, result });
|
|
888
889
|
} catch (err) {
|
|
889
890
|
res.status(500).json({ ok: false, error: String(err?.message || err) });
|
package/src/zalo_runtime.js
CHANGED
|
@@ -325,7 +325,7 @@ export class AccountRuntime extends EventEmitter {
|
|
|
325
325
|
return this.api.addReaction(icon, dest);
|
|
326
326
|
}
|
|
327
327
|
|
|
328
|
-
async undoMessage(dest, threadId, threadType =
|
|
328
|
+
async undoMessage(dest, threadId, threadType = 0) {
|
|
329
329
|
if (!this.api?.undo) throw new Error("not_connected");
|
|
330
330
|
return this.api.undo(dest, String(threadId), threadType);
|
|
331
331
|
}
|
package/src/zalo_styler.js
CHANGED
|
@@ -18,6 +18,70 @@ export const ZALO_STYLES = {
|
|
|
18
18
|
Indent: "ind_$",
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
+
export const MAX_ZALO_STYLE_JSON_LENGTH = 250;
|
|
22
|
+
export const MAX_ZALO_UTF16_LENGTH = 2800;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Measure string length in UTF-16 code units (Zalo's internal character counter).
|
|
26
|
+
*/
|
|
27
|
+
export function measureUtf16Length(str) {
|
|
28
|
+
return typeof str === "string" ? str.length : 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Score style priority so essential formatting (Headings, Colors) survives JSON budget cuts.
|
|
33
|
+
*/
|
|
34
|
+
function getStylePriority(st) {
|
|
35
|
+
switch (st) {
|
|
36
|
+
case ZALO_STYLES.HeaderLarge:
|
|
37
|
+
case ZALO_STYLES.HeaderSmall:
|
|
38
|
+
return 100;
|
|
39
|
+
case ZALO_STYLES.RubyRed:
|
|
40
|
+
case ZALO_STYLES.EmeraldGreen:
|
|
41
|
+
case ZALO_STYLES.AmberOrange:
|
|
42
|
+
case ZALO_STYLES.RoyalGold:
|
|
43
|
+
return 80;
|
|
44
|
+
case ZALO_STYLES.Bold:
|
|
45
|
+
return 60;
|
|
46
|
+
case ZALO_STYLES.Underline:
|
|
47
|
+
case ZALO_STYLES.StrikeThrough:
|
|
48
|
+
return 40;
|
|
49
|
+
case ZALO_STYLES.Italic:
|
|
50
|
+
return 20;
|
|
51
|
+
default:
|
|
52
|
+
return 10;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Cap Zalo styles to prevent exceeding Zalo's JSON style payload ceiling (~256 bytes).
|
|
58
|
+
* When over budget, lower-priority styles (italic, bold) are pruned first
|
|
59
|
+
* while preserving high-priority headings and colors.
|
|
60
|
+
*/
|
|
61
|
+
export function capStyles(styles, maxJsonLength = MAX_ZALO_STYLE_JSON_LENGTH) {
|
|
62
|
+
if (!Array.isArray(styles) || styles.length === 0) return [];
|
|
63
|
+
if (JSON.stringify(styles).length <= maxJsonLength) return styles;
|
|
64
|
+
|
|
65
|
+
// Clone and annotate with original index and priority
|
|
66
|
+
const items = styles.map((s, idx) => ({
|
|
67
|
+
style: s,
|
|
68
|
+
priority: getStylePriority(s.st),
|
|
69
|
+
idx,
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
// Sort ascending by priority so lowest priority items are removed first
|
|
73
|
+
items.sort((a, b) => a.priority - b.priority);
|
|
74
|
+
|
|
75
|
+
const retained = new Set(styles);
|
|
76
|
+
while (items.length > 0 && JSON.stringify(Array.from(retained)).length > maxJsonLength) {
|
|
77
|
+
const lowest = items.shift();
|
|
78
|
+
retained.delete(lowest.style);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Restore original ordering by start position
|
|
82
|
+
return styles.filter((s) => retained.has(s));
|
|
83
|
+
}
|
|
84
|
+
|
|
21
85
|
/**
|
|
22
86
|
* Split text into chunks safe for Zalo's message length limits.
|
|
23
87
|
* Default max is 650 chars to avoid error 118 (content too long).
|
|
@@ -178,11 +242,13 @@ export function parseMarkdownStyles(input) {
|
|
|
178
242
|
|
|
179
243
|
/**
|
|
180
244
|
* Format message into ready-to-send Zalo payload with styles.
|
|
245
|
+
* Automatically enforces max JSON budget (~250 bytes) for style payload.
|
|
181
246
|
*/
|
|
182
|
-
export function buildZaloStyledMessage(text) {
|
|
247
|
+
export function buildZaloStyledMessage(text, { maxStylesJsonLength = MAX_ZALO_STYLE_JSON_LENGTH } = {}) {
|
|
183
248
|
const { text: cleanText, styles } = parseMarkdownStyles(text);
|
|
249
|
+
const cappedStyles = capStyles(styles, maxStylesJsonLength);
|
|
184
250
|
return {
|
|
185
251
|
msg: cleanText,
|
|
186
|
-
styles:
|
|
252
|
+
styles: cappedStyles.length > 0 ? cappedStyles : undefined,
|
|
187
253
|
};
|
|
188
254
|
}
|