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.
Files changed (40) hide show
  1. package/README.md +10 -10
  2. package/mcp/server.js +1 -1
  3. package/package.json +30 -53
  4. package/src/config.js +10 -0
  5. package/src/hermes_bridge.js +19 -8
  6. package/src/hermes_media.js +107 -4
  7. package/src/inbound_router.js +13 -6
  8. package/src/policy.js +67 -8
  9. package/src/quote_resolver.js +45 -6
  10. package/src/schema.js +39 -2
  11. package/src/store.js +24 -0
  12. package/src/zalo_math.js +122 -0
  13. package/src/zalo_mentions.js +123 -4
  14. package/src/zalo_runtime.js +162 -55
  15. package/src/zalo_styler.js +274 -127
  16. package/.env.example +0 -61
  17. package/Dockerfile +0 -23
  18. package/SECURITY.md +0 -22
  19. package/config/bots.example.json +0 -33
  20. package/config.toml +0 -65
  21. package/docker-compose.yml +0 -15
  22. package/hermes-plugin/README.md +0 -56
  23. package/hermes-plugin/platforms/zalo/adapter.py +0 -222
  24. package/hermes-plugin/platforms/zalo/plugin.yaml +0 -34
  25. package/hermes-plugin/starter-kit/AGENT.md +0 -26
  26. package/hermes-plugin/starter-kit/README.md +0 -43
  27. package/hermes-plugin/starter-kit/SOUL.md +0 -32
  28. package/hermes-plugin/starter-kit/skills/zalo-community-admin/SKILL.md +0 -32
  29. package/hermes-plugin/starter-kit/skills/zalo-customer-care/SKILL.md +0 -34
  30. package/hermes-plugin/zalo-style-guide.md +0 -65
  31. package/install.sh +0 -6
  32. package/scripts/battle-smoke.js +0 -118
  33. package/scripts/corpus-stats.js +0 -23
  34. package/scripts/probe-history.js +0 -76
  35. package/scripts/public-gate.js +0 -216
  36. package/scripts/setup.js +0 -233
  37. package/setup.bat +0 -67
  38. package/setup.ps1 +0 -30
  39. package/setup.sh +0 -12
  40. 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 Bubble Chunker (`splitIntoSafeZaloChunks`)**: Automatically breaks long outputs into sequential bubbles <= 650 chars, permanently eliminating Zalo API Error 118 ("Content too long").
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.7.1
230
+ ## 🆕 What's New in v0.9.2
231
231
 
232
- > **Robustness release** — self-healing listener, safe styled messages, fixed group member API, expanded payload safety.
232
+ > **Rich-message reliability release** — complete long replies, quoted context, and broader media normalization.
233
233
 
234
234
  | Feature | Detail |
235
235
  | :--- | :--- |
236
- | **Listener Auto-Restart** | Catches `closed` event from `zca-js` when the listener shuts down permanently (bot goes "deaf" after network drop). Schedules automatic reconnect with exponential back-off: `5s → 15s → 30s → 60s → 120s → 300s`. No operator restart needed. |
237
- | **Plaintext Fallback for Styled Messages** | When Zalo server rejects a styled (formatted) message with a numeric error code, the bot automatically strips formatting and re-sends as plain text — so the content is never silently lost. Network errors (no error code) are NOT retried to prevent duplicate messages. |
238
- | **Payload Byte Budget** | `MAX_ZALO_PAYLOAD_BYTES = 3000` + `measurePayloadBytes()` in `zalo_styler.js` cap total UTF-8 bytes of text + style JSON, matching real-world Zalo rejection threshold of ~3,448 bytes. |
239
- | **Fix `getGroupMembers` API Order** | New `AccountRuntime.getGroupMembers(groupId)` calls `getGroupInfo` first to obtain member UID list, then passes UIDs to `getGroupMembersInfo` — eliminating "Lỗi không xác định" that occurred when a group ID was passed where member UIDs were expected. |
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
- ## 🔄 Seamless Zero-Downtime Upgrade (For Existing Users)
243
+ ## 🔄 Upgrade (For Existing Users)
244
244
 
245
- If you already installed `abs-zalo-bot`, upgrading to **v0.7.1** takes 5 seconds with **ZERO data loss and NO QR re-scan**:
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
- > *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.*
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 || "reader");
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.8.2",
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
- "model-context-protocol",
19
- "zca-js",
20
- "chatbot",
21
- "lead-generation",
22
- "vietnam",
18
+ "mcp-server",
19
+ "personal-zalo",
23
20
  "official-account",
24
- "personal-qr",
21
+ "automation",
22
+ "lead-generation",
23
+ "customer-support",
24
+ "community-management",
25
25
  "autonomous-agent",
26
- "open-source"
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
- "SECURITY.md",
53
- "LICENSE"
43
+ "LICENSE",
44
+ "package.json"
54
45
  ],
55
- "bin": {
56
- "zalo-personal-mcp": "mcp/server.js"
57
- },
58
46
  "scripts": {
59
- "start": "node src/cli.js serve",
60
- "dev": "node --watch src/cli.js serve",
61
- "self-check": "node src/cli.js self-check",
62
- "status": "node src/cli.js status",
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.30.0",
53
+ "@modelcontextprotocol/sdk": "^1.27.0",
54
+ "better-sqlite3": "^11.8.1",
85
55
  "express": "^4.21.2",
86
- "zca-js": "2.1.2",
87
- "zod": "3.25.76"
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
- "overrides": {
90
- "@hono/node-server": "2.1.0"
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,
@@ -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
- threads: allowedThreads(),
36
- users: allowedUsers(),
39
+ allowAllUsers,
40
+ allowAllThreads,
41
+ threads,
42
+ users,
37
43
  };
38
44
  }
39
45
 
40
46
  function canDeliverInbound(row, settings) {
41
- if (!settings.enabled || !settings.threads.size || !settings.users.size) return false;
42
- if (row.is_self || !settings.threads.has(String(row.source_id)) || !settings.users.has(String(row.sender_id))) return false;
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
- if (!gatewaySettings().enabled || !gatewaySettings().autoReply) throw new Error("gateway_autoreply_disabled");
115
- if (!allowedThreads().has(thread)) throw new Error("thread_not_allowlisted");
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
- if (!gatewaySettings().enabled || !gatewaySettings().autoReply) throw new Error("gateway_autoreply_disabled");
131
- if (!thread || !allowedThreads().has(thread)) throw new Error("thread_not_allowlisted");
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 });
@@ -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 = [value.href, value.url, value.fileUrl, value.downloadUrl, value.voiceUrl, value.videoUrl]
31
- .find((item) => typeof item === "string" && /^https?:\/\//i.test(item));
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/.test(raw) ? "video" : /voice|audio/.test(raw) ? "audio" : /photo|image|thumb/.test(raw) ? "image" : "file";
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
+
@@ -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: dest.group_id,
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: dest.group_id,
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: dest.group_id,
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(dest.group_id, answer, 1);
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: dest.group_id,
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: dest.group_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
- // HARD: only the configured destination. No source replies, no DM replies.
267
- if (String(targetId) !== String(dest.group_id)) {
268
- return { allow: false, reason: "target_not_destination" };
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
 
@@ -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 quoteText = resolveQuoteText(rawQuote);
63
- if (!quoteText) return event.text;
64
- return `[Trích dẫn] ${quoteText}\n${event.text}`;
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
  }