abs-zalo-bot 0.8.2 → 0.10.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 +10 -10
- package/mcp/server.js +1 -1
- package/package.json +30 -53
- package/src/config.js +10 -0
- package/src/hermes_bridge.js +19 -8
- package/src/hermes_media.js +107 -4
- package/src/inbound_router.js +13 -6
- package/src/policy.js +67 -8
- package/src/quote_resolver.js +45 -6
- package/src/schema.js +39 -2
- package/src/store.js +24 -0
- package/src/zalo_math.js +122 -0
- package/src/zalo_mentions.js +123 -4
- package/src/zalo_runtime.js +162 -55
- package/src/zalo_styler.js +274 -127
- package/.env.example +0 -61
- package/Dockerfile +0 -23
- package/SECURITY.md +0 -22
- package/config/bots.example.json +0 -33
- package/config.toml +0 -65
- package/docker-compose.yml +0 -15
- package/hermes-plugin/README.md +0 -56
- package/hermes-plugin/platforms/zalo/adapter.py +0 -222
- package/hermes-plugin/platforms/zalo/plugin.yaml +0 -34
- package/hermes-plugin/starter-kit/AGENT.md +0 -26
- package/hermes-plugin/starter-kit/README.md +0 -43
- package/hermes-plugin/starter-kit/SOUL.md +0 -32
- package/hermes-plugin/starter-kit/skills/zalo-community-admin/SKILL.md +0 -32
- package/hermes-plugin/starter-kit/skills/zalo-customer-care/SKILL.md +0 -34
- package/hermes-plugin/zalo-style-guide.md +0 -65
- package/install.sh +0 -6
- package/scripts/battle-smoke.js +0 -118
- package/scripts/corpus-stats.js +0 -23
- package/scripts/probe-history.js +0 -76
- package/scripts/public-gate.js +0 -216
- package/scripts/setup.js +0 -233
- package/setup.bat +0 -67
- package/setup.ps1 +0 -30
- package/setup.sh +0 -12
- package/start.bat +0 -22
package/README.md
CHANGED
|
@@ -184,7 +184,7 @@ For profile-aware quality, select `gateway_skill = "your-owner-authored-hermes-s
|
|
|
184
184
|
- `__underline__`: Zalo Underline (`u`).
|
|
185
185
|
- `~~strikethrough~~`: Zalo StrikeThrough (`s`).
|
|
186
186
|
- Color tags: `[RED]...[/RED]` (Ruby Red), `[GREEN]...[/GREEN]` (Emerald Green), `[ORANGE]...[/ORANGE]` (Amber Orange), `[YELLOW]...[/YELLOW]` (Royal Gold) — supports Vietnamese equivalents `[ĐỎ]`, `[XANH]`, `[CAM]`, `[VÀNG]`.
|
|
187
|
-
- **Safe
|
|
187
|
+
- **Safe Styled Chunker (`formatAndChunkZaloMarkdown`)**: Splits long output without dropping characters or breaking UTF-16 surrogate pairs. Every bubble is capped at 2,000 UTF-16 code units, 40 styles, and 3,000 encoded bytes; styles are clipped and rebased per bubble.
|
|
188
188
|
|
|
189
189
|
---
|
|
190
190
|
|
|
@@ -227,22 +227,22 @@ cat hermes-plugin/starter-kit/SOUL.md >> ~/.hermes/SOUL.md
|
|
|
227
227
|
|
|
228
228
|
---
|
|
229
229
|
|
|
230
|
-
## 🆕 What's New in v0.
|
|
230
|
+
## 🆕 What's New in v0.9.2
|
|
231
231
|
|
|
232
|
-
> **
|
|
232
|
+
> **Rich-message reliability release** — complete long replies, quoted context, and broader media normalization.
|
|
233
233
|
|
|
234
234
|
| Feature | Detail |
|
|
235
235
|
| :--- | :--- |
|
|
236
|
-
| **
|
|
237
|
-
| **
|
|
238
|
-
| **
|
|
239
|
-
| **
|
|
236
|
+
| **Lossless Styled Chunking** | Long Markdown replies are rendered once, split on readable boundaries, then sent sequentially. Each bubble stays within 2,000 UTF-16 code units, 40 styles, and 3,000 encoded bytes. Styles are clipped and rebased instead of discarded. |
|
|
237
|
+
| **Safe Delivery Semantics** | A quote appears only on the first bubble; an attachment only on the last. Plain-text retry occurs only after a numeric provider rejection. Ambiguous network failures are not retried, preventing likely duplicate sends. |
|
|
238
|
+
| **Quoted Context** | Quoted text and media become normalized Hermes context. Provider URLs remain staging inputs and are excluded from model-facing raw metadata. |
|
|
239
|
+
| **Media Normalization** | Additional Zalo media URL fields are classified and staged through the existing jail and SSRF protections. |
|
|
240
240
|
|
|
241
241
|
---
|
|
242
242
|
|
|
243
|
-
## 🔄
|
|
243
|
+
## 🔄 Upgrade (For Existing Users)
|
|
244
244
|
|
|
245
|
-
If you already installed `abs-zalo-bot`,
|
|
245
|
+
If you already installed `abs-zalo-bot`, upgrade to **v0.9.2** after backing up your runtime data:
|
|
246
246
|
|
|
247
247
|
- **If installed via NPM:**
|
|
248
248
|
```bash
|
|
@@ -258,7 +258,7 @@ If you already installed `abs-zalo-bot`, upgrading to **v0.7.1** takes 5 seconds
|
|
|
258
258
|
docker compose pull && docker compose up -d
|
|
259
259
|
```
|
|
260
260
|
|
|
261
|
-
>
|
|
261
|
+
> The upgrade does not intentionally modify `data/sessions/`, `data/bridge.sqlite3`, or `.env`. Back them up before upgrading; reconnection behavior still depends on the active Zalo session.
|
|
262
262
|
|
|
263
263
|
---
|
|
264
264
|
|
package/mcp/server.js
CHANGED
|
@@ -63,7 +63,7 @@ function verifyConfirmation(code, action, targetKey) {
|
|
|
63
63
|
|
|
64
64
|
const BRIDGE_URL = (process.env.ZALO_BRIDGE_URL || "http://127.0.0.1:3871").replace(/\/$/, "");
|
|
65
65
|
const TOKEN = process.env.DASHBOARD_TOKEN || process.env.ZALO_BRIDGE_TOKEN || "";
|
|
66
|
-
const TOOL_PACK = normalizeToolPack(process.env.ABS_ZALO_TOOL_PACK || "
|
|
66
|
+
const TOOL_PACK = normalizeToolPack(process.env.ABS_ZALO_TOOL_PACK || "admin");
|
|
67
67
|
|
|
68
68
|
function log(...args) {
|
|
69
69
|
console.error("[abs-zalo-mcp]", ...args);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "abs-zalo-bot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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",
|
|
@@ -15,15 +15,15 @@
|
|
|
15
15
|
"claude-code",
|
|
16
16
|
"codex",
|
|
17
17
|
"mcp",
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"chatbot",
|
|
21
|
-
"lead-generation",
|
|
22
|
-
"vietnam",
|
|
18
|
+
"mcp-server",
|
|
19
|
+
"personal-zalo",
|
|
23
20
|
"official-account",
|
|
24
|
-
"
|
|
21
|
+
"automation",
|
|
22
|
+
"lead-generation",
|
|
23
|
+
"customer-support",
|
|
24
|
+
"community-management",
|
|
25
25
|
"autonomous-agent",
|
|
26
|
-
"
|
|
26
|
+
"abs-engine"
|
|
27
27
|
],
|
|
28
28
|
"homepage": "https://github.com/teddiesloco/abs-zalo-bot#readme",
|
|
29
29
|
"repository": {
|
|
@@ -33,60 +33,37 @@
|
|
|
33
33
|
"bugs": {
|
|
34
34
|
"url": "https://github.com/teddiesloco/abs-zalo-bot/issues"
|
|
35
35
|
},
|
|
36
|
+
"bin": {
|
|
37
|
+
"zalo-personal-mcp": "mcp/server.js"
|
|
38
|
+
},
|
|
36
39
|
"files": [
|
|
37
40
|
"src/",
|
|
38
41
|
"mcp/",
|
|
39
|
-
"scripts/",
|
|
40
|
-
"hermes-plugin/",
|
|
41
|
-
"config/bots.example.json",
|
|
42
|
-
"config.toml",
|
|
43
|
-
".env.example",
|
|
44
|
-
"setup.bat",
|
|
45
|
-
"start.bat",
|
|
46
|
-
"setup.ps1",
|
|
47
|
-
"install.sh",
|
|
48
|
-
"setup.sh",
|
|
49
|
-
"Dockerfile",
|
|
50
|
-
"docker-compose.yml",
|
|
51
42
|
"README.md",
|
|
52
|
-
"
|
|
53
|
-
"
|
|
43
|
+
"LICENSE",
|
|
44
|
+
"package.json"
|
|
54
45
|
],
|
|
55
|
-
"bin": {
|
|
56
|
-
"zalo-personal-mcp": "mcp/server.js"
|
|
57
|
-
},
|
|
58
46
|
"scripts": {
|
|
59
|
-
"start": "node src/
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"qr": "node src/cli.js qr",
|
|
64
|
-
"onboarding": "node src/cli.js onboarding",
|
|
65
|
-
"digest": "node src/cli.js digest",
|
|
66
|
-
"backfill": "node src/cli.js backfill",
|
|
67
|
-
"mcp": "node mcp/server.js",
|
|
68
|
-
"setup": "node scripts/setup.js setup",
|
|
69
|
-
"install:locked": "npm ci",
|
|
70
|
-
"doctor": "node scripts/setup.js doctor",
|
|
71
|
-
"dashboard-info": "node scripts/setup.js dashboard-info",
|
|
72
|
-
"smoke": "node scripts/battle-smoke.js",
|
|
73
|
-
"battle": "node scripts/battle-smoke.js",
|
|
74
|
-
"validate-config": "node scripts/public-gate.js validate-config",
|
|
75
|
-
"secret-scan": "node scripts/public-gate.js secret-scan",
|
|
76
|
-
"syntax-check": "node scripts/public-gate.js syntax-check",
|
|
77
|
-
"test": "node --test test/*.test.js",
|
|
78
|
-
"cold": "npm test && npm run validate-config && npm run secret-scan && npm run syntax-check && npm run self-check && npm run smoke"
|
|
79
|
-
},
|
|
80
|
-
"engines": {
|
|
81
|
-
"node": ">=22.5"
|
|
47
|
+
"start": "node src/server.js",
|
|
48
|
+
"test": "node --test",
|
|
49
|
+
"test:coverage": "node --test --experimental-test-coverage",
|
|
50
|
+
"lint": "node --check src/*.js test/*.js"
|
|
82
51
|
},
|
|
83
52
|
"dependencies": {
|
|
84
|
-
"@modelcontextprotocol/sdk": "1.
|
|
53
|
+
"@modelcontextprotocol/sdk": "^1.27.0",
|
|
54
|
+
"better-sqlite3": "^11.8.1",
|
|
85
55
|
"express": "^4.21.2",
|
|
86
|
-
"
|
|
87
|
-
"
|
|
56
|
+
"qrcode-terminal": "^0.12.0",
|
|
57
|
+
"ws": "^8.18.0",
|
|
58
|
+
"zca-js": "^1.7.0"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@types/node": "^22.13.9"
|
|
62
|
+
},
|
|
63
|
+
"engines": {
|
|
64
|
+
"node": ">=20.0.0"
|
|
88
65
|
},
|
|
89
|
-
"
|
|
90
|
-
"
|
|
66
|
+
"publishConfig": {
|
|
67
|
+
"access": "public"
|
|
91
68
|
}
|
|
92
69
|
}
|
package/src/config.js
CHANGED
|
@@ -124,6 +124,15 @@ export function loadConfig(configPath) {
|
|
|
124
124
|
viewer: Array.isArray(data.roles?.viewer) ? data.roles.viewer.map(String) : [],
|
|
125
125
|
};
|
|
126
126
|
|
|
127
|
+
const ownerOnlyGroups = new Set(
|
|
128
|
+
(Array.isArray(data.owner_only_groups)
|
|
129
|
+
? data.owner_only_groups.map(String)
|
|
130
|
+
: String(process.env.ZALO_OWNER_ONLY_GROUPS || data.owner_only_groups || "").split(",")
|
|
131
|
+
)
|
|
132
|
+
.map((s) => s.trim())
|
|
133
|
+
.filter(Boolean)
|
|
134
|
+
);
|
|
135
|
+
|
|
127
136
|
let agentProfiles;
|
|
128
137
|
try {
|
|
129
138
|
agentProfiles = normalizeAgentProfiles(data.agent_profiles, defaultAccountId);
|
|
@@ -171,6 +180,7 @@ export function loadConfig(configPath) {
|
|
|
171
180
|
dashboard_port: Number(process.env.PORT || data.dashboard_port || 3871),
|
|
172
181
|
sources: normalizedSources,
|
|
173
182
|
destination,
|
|
183
|
+
owner_only_groups: ownerOnlyGroups,
|
|
174
184
|
rate_limit: rateLimit,
|
|
175
185
|
roles,
|
|
176
186
|
hermes,
|
package/src/hermes_bridge.js
CHANGED
|
@@ -28,18 +28,26 @@ function allowedUsers() {
|
|
|
28
28
|
|
|
29
29
|
function gatewaySettings() {
|
|
30
30
|
const groupMode = String(process.env.HERMES_ZALO_GROUP_MODE || "mention").trim();
|
|
31
|
+
const allowAllUsers = process.env.HERMES_ZALO_ALLOW_ALL === "true" || process.env.HERMES_ZALO_ALLOW_ALL_USERS === "true";
|
|
32
|
+
const allowAllThreads = process.env.HERMES_ZALO_ALLOW_ALL === "true" || process.env.HERMES_ZALO_ALLOW_ALL_THREADS === "true";
|
|
33
|
+
const threads = allowedThreads();
|
|
34
|
+
const users = allowedUsers();
|
|
31
35
|
return {
|
|
32
36
|
enabled: process.env.HERMES_ZALO_GATEWAY_ENABLED === "true",
|
|
33
37
|
autoReply: process.env.HERMES_ZALO_ALLOW_AUTOREPLY === "true",
|
|
34
38
|
groupMode: ["off", "mention", "all"].includes(groupMode) ? groupMode : "mention",
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
allowAllUsers,
|
|
40
|
+
allowAllThreads,
|
|
41
|
+
threads,
|
|
42
|
+
users,
|
|
37
43
|
};
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
function canDeliverInbound(row, settings) {
|
|
41
|
-
if (!settings.enabled
|
|
42
|
-
if (
|
|
47
|
+
if (!settings.enabled) return false;
|
|
48
|
+
if (!settings.allowAllThreads && (!settings.threads.size || !settings.threads.has(String(row.source_id)))) return false;
|
|
49
|
+
if (!settings.allowAllUsers && (!settings.users.size || !settings.users.has(String(row.sender_id)))) return false;
|
|
50
|
+
if (row.is_self) return false;
|
|
43
51
|
if (row.source_type !== "group") return true;
|
|
44
52
|
if (settings.groupMode === "all") return true;
|
|
45
53
|
return settings.groupMode === "mention" && Boolean(row.is_mention);
|
|
@@ -111,8 +119,9 @@ export function createHermesBridge({ config, store, hub }) {
|
|
|
111
119
|
const thread = String(threadId || "").trim();
|
|
112
120
|
const body = String(text || "").trim();
|
|
113
121
|
if (!thread || !body || body.length > 4000) throw new Error("invalid_message");
|
|
114
|
-
|
|
115
|
-
if (!
|
|
122
|
+
const settings = gatewaySettings();
|
|
123
|
+
if (!settings.enabled || !settings.autoReply) throw new Error("gateway_autoreply_disabled");
|
|
124
|
+
if (!settings.allowAllThreads && !allowedThreads().has(thread)) throw new Error("thread_not_allowlisted");
|
|
116
125
|
const runtime = hub.getRuntime(accountId());
|
|
117
126
|
const source = store.listSources(accountId()).find((item) => String(item.source_id) === thread);
|
|
118
127
|
const resolvedThreadType = Number(threadType) === 0 ? 0 : source?.source_type === "dm" ? 0 : 1;
|
|
@@ -120,6 +129,7 @@ export function createHermesBridge({ config, store, hub }) {
|
|
|
120
129
|
thread_id: thread,
|
|
121
130
|
thread_type: resolvedThreadType,
|
|
122
131
|
text: body,
|
|
132
|
+
parse_markdown: true,
|
|
123
133
|
quote: replyTo ? { msgId: String(replyTo) } : undefined,
|
|
124
134
|
});
|
|
125
135
|
store.audit({ accountId: accountId(), actorId: "hermes-zalo-plugin", action: "hermes_bridge_send", detail: `thread=${sha256(thread).slice(0, 12)}` });
|
|
@@ -127,8 +137,9 @@ export function createHermesBridge({ config, store, hub }) {
|
|
|
127
137
|
},
|
|
128
138
|
async typing({ threadId, threadType = null } = {}) {
|
|
129
139
|
const thread = String(threadId || "").trim();
|
|
130
|
-
|
|
131
|
-
if (!
|
|
140
|
+
const settings = gatewaySettings();
|
|
141
|
+
if (!settings.enabled || !settings.autoReply) throw new Error("gateway_autoreply_disabled");
|
|
142
|
+
if (!thread || (!settings.allowAllThreads && !allowedThreads().has(thread))) throw new Error("thread_not_allowlisted");
|
|
132
143
|
const source = store.listSources(accountId()).find((item) => String(item.source_id) === thread);
|
|
133
144
|
const resolvedThreadType = Number(threadType) === 0 ? 0 : source?.source_type === "dm" ? 0 : 1;
|
|
134
145
|
await hub.getRuntime(accountId()).performPersonalAction("typing", { thread_id: thread, thread_type: resolvedThreadType });
|
package/src/hermes_media.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import crypto from "node:crypto";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
6
8
|
|
|
7
9
|
const MAX_IMAGE_OR_FILE = 25 * 1024 * 1024;
|
|
8
10
|
const MAX_AUDIO_OR_VIDEO = 100 * 1024 * 1024;
|
|
@@ -27,13 +29,15 @@ export function extractAttachmentCandidates(content) {
|
|
|
27
29
|
const visit = (value, depth = 0) => {
|
|
28
30
|
if (!value || depth > 5 || typeof value !== "object") return;
|
|
29
31
|
if (Array.isArray(value)) return value.forEach((item) => visit(item, depth + 1));
|
|
30
|
-
const url = [
|
|
31
|
-
.
|
|
32
|
+
const url = [
|
|
33
|
+
value.href, value.url, value.fileUrl, value.downloadUrl, value.voiceUrl, value.videoUrl,
|
|
34
|
+
value.oriUrl, value.hdUrl, value.normalUrl, value.thumb, value.thumbUrl, value.previewThumb, value.rawUrl,
|
|
35
|
+
].find((item) => typeof item === "string" && /^https?:\/\//i.test(item));
|
|
32
36
|
if (url && !seen.has(url)) {
|
|
33
37
|
seen.add(url);
|
|
34
38
|
const name = safeName(value.fileName || value.name || value.title || path.basename(new URL(url).pathname), "attachment");
|
|
35
|
-
const raw = `${value.type || ""} ${value.action || ""} ${name}`.toLowerCase();
|
|
36
|
-
const kind = /video
|
|
39
|
+
const raw = `${value.type || ""} ${value.action || ""} ${name} ${new URL(url).hostname}`.toLowerCase();
|
|
40
|
+
const kind = /video|\.(?:mp4|mov|webm)$/u.test(raw) ? "video" : /voice|audio|\.(?:mp3|m4a|wav|aac)$/u.test(raw) ? "audio" : /photo|image|thumb|\.(?:jpe?g|png|webp|gif)$/u.test(raw) ? "image" : "file";
|
|
37
41
|
results.push({ url, name, kind });
|
|
38
42
|
}
|
|
39
43
|
for (const child of Object.values(value)) visit(child, depth + 1);
|
|
@@ -105,3 +109,102 @@ export function resolveStagedMedia(dataDir, eventId, attachmentId, metadata) {
|
|
|
105
109
|
if (!full.startsWith(`${root}${path.sep}`) || !fs.existsSync(full)) return null;
|
|
106
110
|
return { ...ref, full };
|
|
107
111
|
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Đóng gói âm thanh thành M4A (AAC mono 44.1 kHz, 64k) với cờ +faststart
|
|
115
|
+
* để iPhone (AVPlayer) và Zalo PC (Chromium) phát mượt mà.
|
|
116
|
+
*/
|
|
117
|
+
export function transcodeToM4a(audioPath) {
|
|
118
|
+
if (!audioPath || !fs.existsSync(audioPath)) return null;
|
|
119
|
+
const ffmpeg = "ffmpeg";
|
|
120
|
+
const outputPath = path.join(os.tmpdir(), `zalo_voice_${crypto.randomBytes(6).toString("hex")}.m4a`);
|
|
121
|
+
try {
|
|
122
|
+
const res = spawnSync(
|
|
123
|
+
ffmpeg,
|
|
124
|
+
[
|
|
125
|
+
"-v", "error", "-y", "-i", audioPath,
|
|
126
|
+
"-vn", "-ac", "1", "-ar", "44100", "-c:a", "aac", "-b:a", "64k",
|
|
127
|
+
"-movflags", "+faststart",
|
|
128
|
+
outputPath,
|
|
129
|
+
],
|
|
130
|
+
{ timeout: 60000 }
|
|
131
|
+
);
|
|
132
|
+
if (res.status === 0 && fs.existsSync(outputPath) && fs.statSync(outputPath).size > 0) {
|
|
133
|
+
return outputPath;
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
/* ffmpeg failed or not found */
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
|
|
140
|
+
} catch {
|
|
141
|
+
/* ignore */
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Nối đuôi tệp (.m4a) vào link CDN Zalo nếu chưa có đuôi âm thanh.
|
|
148
|
+
* Zalo CDN bỏ qua đuôi này và trả đúng tệp, giúp iOS/PC detect MIME chuẩn.
|
|
149
|
+
*/
|
|
150
|
+
export function withAudioExtension(url, extension = ".m4a") {
|
|
151
|
+
if (!url || typeof url !== "string") return url;
|
|
152
|
+
try {
|
|
153
|
+
const parsed = new URL(url);
|
|
154
|
+
const pathname = parsed.pathname || "";
|
|
155
|
+
if (/\.(m4a|aac|mp3|wav|ogg)$/i.test(pathname)) {
|
|
156
|
+
return url;
|
|
157
|
+
}
|
|
158
|
+
const ext = extension.startsWith(".") ? extension : `.${extension}`;
|
|
159
|
+
parsed.pathname = `${pathname}${ext}`;
|
|
160
|
+
return parsed.toString();
|
|
161
|
+
} catch {
|
|
162
|
+
return url;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Guard chống gửi lặp tin thoại vào cùng một chat trong cửa sổ thời gian (mặc định 10 phút).
|
|
168
|
+
*/
|
|
169
|
+
export function createVoiceDedupGuard({ windowMs = 10 * 60 * 1000, now = Date.now } = {}) {
|
|
170
|
+
const sentVoices = new Map();
|
|
171
|
+
return {
|
|
172
|
+
isDuplicate(chatId, filePath) {
|
|
173
|
+
if (!filePath || !fs.existsSync(filePath)) return false;
|
|
174
|
+
const currentTime = now();
|
|
175
|
+
for (const [k, v] of sentVoices.entries()) {
|
|
176
|
+
if (currentTime - v.at > windowMs) sentVoices.delete(k);
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
const stat = fs.statSync(filePath);
|
|
180
|
+
const key = `${chatId}:${path.resolve(filePath)}:${stat.size}:${stat.mtimeMs}`;
|
|
181
|
+
return sentVoices.has(key);
|
|
182
|
+
} catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
record(chatId, filePath, result = { ok: true }) {
|
|
187
|
+
if (!filePath || !fs.existsSync(filePath)) return;
|
|
188
|
+
try {
|
|
189
|
+
const stat = fs.statSync(filePath);
|
|
190
|
+
const key = `${chatId}:${path.resolve(filePath)}:${stat.size}:${stat.mtimeMs}`;
|
|
191
|
+
sentVoices.set(key, { at: now(), result });
|
|
192
|
+
} catch {
|
|
193
|
+
/* ignore */
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
get(chatId, filePath) {
|
|
197
|
+
try {
|
|
198
|
+
const stat = fs.statSync(filePath);
|
|
199
|
+
const key = `${chatId}:${path.resolve(filePath)}:${stat.size}:${stat.mtimeMs}`;
|
|
200
|
+
return sentVoices.get(key)?.result || null;
|
|
201
|
+
} catch {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
clear() {
|
|
206
|
+
sentVoices.clear();
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
package/src/inbound_router.js
CHANGED
|
@@ -209,16 +209,19 @@ export async function handleBotBrainCommand({
|
|
|
209
209
|
brainMeta = { used: false, reason: brain.reason || "brain_fail", manual_review: brain.manual_review };
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
const targetId = event.source_id || dest.group_id;
|
|
213
|
+
const threadType = event.source_type === "dm" ? 0 : 1;
|
|
212
214
|
const decision = policy.evaluateOutbound({
|
|
213
215
|
accountId,
|
|
214
|
-
targetId
|
|
216
|
+
targetId,
|
|
215
217
|
text: answer,
|
|
216
218
|
kind: "ask_reply",
|
|
219
|
+
allowSource: true,
|
|
217
220
|
});
|
|
218
221
|
if (!decision.allow) {
|
|
219
222
|
store.logOutbound({
|
|
220
223
|
accountId,
|
|
221
|
-
targetId
|
|
224
|
+
targetId,
|
|
222
225
|
kind: "ask_reply",
|
|
223
226
|
textSha: sha256(answer),
|
|
224
227
|
ok: false,
|
|
@@ -240,7 +243,7 @@ export async function handleBotBrainCommand({
|
|
|
240
243
|
if (process.env.ALLOW_FAKE_SEND === "true" && !runtime.api) {
|
|
241
244
|
store.logOutbound({
|
|
242
245
|
accountId,
|
|
243
|
-
targetId
|
|
246
|
+
targetId,
|
|
244
247
|
kind: "ask_reply",
|
|
245
248
|
textSha: decision.textSha || sha256(answer),
|
|
246
249
|
ok: true,
|
|
@@ -256,10 +259,14 @@ export async function handleBotBrainCommand({
|
|
|
256
259
|
}
|
|
257
260
|
|
|
258
261
|
try {
|
|
259
|
-
await runtime.sendText(
|
|
262
|
+
await runtime.sendText(targetId, answer, threadType, {
|
|
263
|
+
quote: event.quote?.id ? { msgId: event.quote.id, ownerId: event.quote.authorId } : undefined,
|
|
264
|
+
styled: true,
|
|
265
|
+
allow_source: true,
|
|
266
|
+
});
|
|
260
267
|
store.logOutbound({
|
|
261
268
|
accountId,
|
|
262
|
-
targetId
|
|
269
|
+
targetId,
|
|
263
270
|
kind: "ask_reply",
|
|
264
271
|
textSha: decision.textSha || sha256(answer),
|
|
265
272
|
ok: true,
|
|
@@ -267,7 +274,7 @@ export async function handleBotBrainCommand({
|
|
|
267
274
|
});
|
|
268
275
|
store.putReport({
|
|
269
276
|
account_id: accountId,
|
|
270
|
-
destination_source_id:
|
|
277
|
+
destination_source_id: targetId,
|
|
271
278
|
report_type: "bot_cmd",
|
|
272
279
|
period_start: since,
|
|
273
280
|
period_end: utcNow(),
|
package/src/policy.js
CHANGED
|
@@ -118,11 +118,40 @@ export class PolicyGuard {
|
|
|
118
118
|
return { allow: false, reason: "self_echo", policy: null, actions: [] };
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
const isOwner = event.is_self ||
|
|
122
|
+
this.roleOf(event.account_id, event.sender_id) === "owner" ||
|
|
123
|
+
Boolean(this.config.owner_uid && String(event.sender_id) === String(this.config.owner_uid));
|
|
124
|
+
|
|
125
|
+
// Owner Fast-Path for group links and undo actions
|
|
126
|
+
const isLinkJoin = /(?:https?:\/\/)?zalo\.me\/g\/([a-zA-Z0-9_-]+)/i.test(text);
|
|
127
|
+
const isUndo = /^(?:thu\s*hồi|xóa\s*tin|thuhoi|undo|\/undo)(?:\s+|$)/i.test(text);
|
|
128
|
+
|
|
129
|
+
if (isOwner && !inDestination && event.source_type === "group") {
|
|
130
|
+
if (isLinkJoin) {
|
|
131
|
+
return { allow: true, reason: "owner_join_link", policy: null, actions: ["owner_action"] };
|
|
132
|
+
}
|
|
133
|
+
if (isUndo) {
|
|
134
|
+
return { allow: true, reason: "owner_undo", policy: null, actions: ["owner_action"] };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
121
138
|
// DM: store optional only if explicitly enabled; never reply / never brain.
|
|
122
139
|
if (event.source_type === "dm") {
|
|
123
140
|
if (!this.config.listen_dms) {
|
|
124
141
|
return { allow: false, reason: "dm_disabled", policy: null, actions: [] };
|
|
125
142
|
}
|
|
143
|
+
if (isOwner) {
|
|
144
|
+
if (isLinkJoin) {
|
|
145
|
+
return { allow: true, reason: "owner_join_link", policy: null, actions: ["owner_action"] };
|
|
146
|
+
}
|
|
147
|
+
if (isUndo) {
|
|
148
|
+
return { allow: true, reason: "owner_undo", policy: null, actions: ["owner_action"] };
|
|
149
|
+
}
|
|
150
|
+
if (isSlash) {
|
|
151
|
+
return { allow: true, reason: "owner_command", policy: null, actions: ["command"] };
|
|
152
|
+
}
|
|
153
|
+
return { allow: true, reason: "owner_dm_ask", policy: null, actions: ["destination_ask"] };
|
|
154
|
+
}
|
|
126
155
|
return {
|
|
127
156
|
allow: true,
|
|
128
157
|
reason: "dm_store_only",
|
|
@@ -179,6 +208,34 @@ export class PolicyGuard {
|
|
|
179
208
|
};
|
|
180
209
|
}
|
|
181
210
|
|
|
211
|
+
// Owner-only groups: Bot stays completely silent unless the sender is the owner.
|
|
212
|
+
// Non-owner messages are silently stored for context/history without triggering replies.
|
|
213
|
+
if (
|
|
214
|
+
event.source_type === "group" &&
|
|
215
|
+
this.config.owner_only_groups?.has(event.source_id)
|
|
216
|
+
) {
|
|
217
|
+
const isOwner = event.is_self || this.roleOf(event.account_id, event.sender_id) === "owner";
|
|
218
|
+
if (!isOwner) {
|
|
219
|
+
const ingestCount = this.store.countInboundRecent(event.account_id, 60);
|
|
220
|
+
if (ingestCount >= this.ingestPerHour) {
|
|
221
|
+
return { allow: false, reason: "ingest_rate", policy: null, actions: [] };
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
allow: true,
|
|
225
|
+
reason: "owner_only_group_silent_store",
|
|
226
|
+
policy: {
|
|
227
|
+
account_id: event.account_id,
|
|
228
|
+
source_id: event.source_id,
|
|
229
|
+
source_type: "group",
|
|
230
|
+
source_name: event.source_name || "",
|
|
231
|
+
mode: "listen_only",
|
|
232
|
+
muted: true,
|
|
233
|
+
},
|
|
234
|
+
actions: ["store"],
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
182
239
|
// Source groups: READ_ONLY — store only, never reply even if tagged/quoted.
|
|
183
240
|
if (this.config.listen_all_groups && event.source_type === "group") {
|
|
184
241
|
const ingestCount = this.store.countInboundRecent(event.account_id, 60);
|
|
@@ -241,7 +298,7 @@ export class PolicyGuard {
|
|
|
241
298
|
return { allow: true, reason: "ok", minRole };
|
|
242
299
|
}
|
|
243
300
|
|
|
244
|
-
evaluateOutbound({ accountId, targetId, text, kind = "digest", alertKey = null }) {
|
|
301
|
+
evaluateOutbound({ accountId, targetId, text, kind = "digest", alertKey = null, allowSource = false }) {
|
|
245
302
|
// Master hard gate. Future RBAC must replace this only together with an
|
|
246
303
|
// explicit owner approval, numeric-identity checks, scoped capabilities,
|
|
247
304
|
// a quota budget, and its own regression suite.
|
|
@@ -261,21 +318,23 @@ export class PolicyGuard {
|
|
|
261
318
|
if (!text || !String(text).trim()) return { allow: false, reason: "empty_text" };
|
|
262
319
|
|
|
263
320
|
const dest = this.store.getDestination(accountId);
|
|
264
|
-
if (!dest.group_id) return { allow: false, reason: "destination_unset" };
|
|
321
|
+
if (!allowSource && !dest.group_id) return { allow: false, reason: "destination_unset" };
|
|
265
322
|
|
|
266
|
-
//
|
|
267
|
-
if (
|
|
268
|
-
|
|
323
|
+
// Destination gate: enforce strictly if readOnlySource is active and source reply not allowed
|
|
324
|
+
if (this.readOnlySource && !allowSource) {
|
|
325
|
+
if (String(targetId) !== String(dest.group_id)) {
|
|
326
|
+
return { allow: false, reason: "target_not_destination" };
|
|
327
|
+
}
|
|
269
328
|
}
|
|
270
|
-
if (String(dest.account_id) && String(dest.account_id) !== String(accountId)) {
|
|
329
|
+
if (dest.group_id && String(targetId) === String(dest.group_id) && String(dest.account_id) && String(dest.account_id) !== String(accountId)) {
|
|
271
330
|
return { allow: false, reason: "destination_account_mismatch" };
|
|
272
331
|
}
|
|
273
332
|
|
|
274
|
-
if (this.readOnlySource && (kind === "reply" || kind === "mention" || kind === "quote")) {
|
|
333
|
+
if (this.readOnlySource && !allowSource && (kind === "reply" || kind === "mention" || kind === "quote")) {
|
|
275
334
|
return { allow: false, reason: "read_only_source_blocks_reply" };
|
|
276
335
|
}
|
|
277
336
|
|
|
278
|
-
if (!["digest", "alert", "ask_reply", "command_reply", "report"].includes(String(kind))) {
|
|
337
|
+
if (!["digest", "alert", "ask_reply", "command_reply", "report", "runtime_send"].includes(String(kind))) {
|
|
279
338
|
if (kind !== "digest") return { allow: false, reason: "kind_blocked" };
|
|
280
339
|
}
|
|
281
340
|
|
package/src/quote_resolver.js
CHANGED
|
@@ -6,11 +6,46 @@
|
|
|
6
6
|
* Nếu Agent chỉ đọc text hiện tại, nó sẽ hỏi ngược "Cái nào ạ?" — trải nghiệm tệ.
|
|
7
7
|
*
|
|
8
8
|
* Module này extract nội dung quoted và dán vào context prompt dưới dạng:
|
|
9
|
-
* [Trích dẫn] <nội dung tin gốc>
|
|
9
|
+
* [Trích dẫn từ @Tên] <nội dung tin gốc>
|
|
10
|
+
* Hoặc [Trích dẫn] <nội dung tin gốc>
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import { extractText } from "./schema.js";
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Trích xuất toàn bộ thông tin có cấu trúc của tin nhắn được quote.
|
|
17
|
+
* @param {object|undefined} rawQuote - data.quote từ raw Zalo message
|
|
18
|
+
* @returns {object|null}
|
|
19
|
+
*/
|
|
20
|
+
export function extractQuote(rawQuote) {
|
|
21
|
+
if (!rawQuote || typeof rawQuote !== "object") return null;
|
|
22
|
+
const quote = rawQuote;
|
|
23
|
+
let attachment = quote.attach;
|
|
24
|
+
if (typeof attachment === "string" && attachment.trim()) {
|
|
25
|
+
try {
|
|
26
|
+
attachment = JSON.parse(attachment);
|
|
27
|
+
} catch {
|
|
28
|
+
attachment = null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const authorId = quote.ownerId ? String(quote.ownerId) : (quote.fromUid ? String(quote.fromUid) : null);
|
|
33
|
+
const authorName = quote.ownerName || quote.dName || quote.fromD || quote.senderName || "";
|
|
34
|
+
const text = resolveQuoteText(quote);
|
|
35
|
+
const id = quote.msgId ? String(quote.msgId) : (quote.globalMsgId ? String(quote.globalMsgId) : null);
|
|
36
|
+
const cliMsgId = quote.cliMsgId ? String(quote.cliMsgId) : null;
|
|
37
|
+
const msgType = quote.msgType || quote.cliMsgType || "";
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
id,
|
|
41
|
+
cliMsgId,
|
|
42
|
+
authorId,
|
|
43
|
+
authorName,
|
|
44
|
+
text,
|
|
45
|
+
msgType,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
14
49
|
/**
|
|
15
50
|
* Giải mã payload quote từ Zalo message data.
|
|
16
51
|
* @param {object|undefined} quote - data.quote từ raw Zalo message
|
|
@@ -53,13 +88,17 @@ export function resolveQuoteText(quote) {
|
|
|
53
88
|
}
|
|
54
89
|
|
|
55
90
|
/**
|
|
56
|
-
* Nếu event có quoted content, gắn thêm prefix [Trích dẫn] vào text để Agent hiểu ngữ cảnh.
|
|
91
|
+
* Nếu event có quoted content, gắn thêm prefix [Trích dẫn từ @Tên] hoặc [Trích dẫn] vào text để Agent hiểu ngữ cảnh.
|
|
57
92
|
* @param {object} event - Normalized event từ schema.js
|
|
58
|
-
* @param {object|undefined} rawQuote - data.quote từ raw Zalo message
|
|
93
|
+
* @param {object|undefined} rawQuote - data.quote từ raw Zalo message hoặc extracted quote object
|
|
59
94
|
* @returns {string} - text đã kèm ngữ cảnh trích dẫn
|
|
60
95
|
*/
|
|
61
96
|
export function enrichTextWithQuote(event, rawQuote) {
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
97
|
+
const quote = typeof rawQuote === "object" && rawQuote !== null && "authorName" in rawQuote
|
|
98
|
+
? rawQuote
|
|
99
|
+
: extractQuote(rawQuote);
|
|
100
|
+
if (!quote || !quote.text) return event?.text || "";
|
|
101
|
+
|
|
102
|
+
const header = quote.authorName ? `[Trích dẫn từ @${quote.authorName}]` : `[Trích dẫn]`;
|
|
103
|
+
return `${header} ${quote.text}\n${event?.text || ""}`;
|
|
65
104
|
}
|