abs-zalo-bot 0.2.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/src/config.js ADDED
@@ -0,0 +1,223 @@
1
+ // Fail-closed config loader. TOML via tiny pure parser for our flat schema.
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { normalizeMode, SOURCE_MODES } from "./schema.js";
5
+
6
+ export class ConfigError extends Error {
7
+ constructor(message) {
8
+ super(message);
9
+ this.name = "ConfigError";
10
+ }
11
+ }
12
+
13
+ function parseSimpleToml(text) {
14
+ // Enough for our config shape. Not a general TOML engine.
15
+ const root = {};
16
+ let section = root;
17
+ let arrayKey = null;
18
+ let arrayItem = null;
19
+
20
+ const flushArrayItem = () => {
21
+ if (arrayKey && arrayItem) {
22
+ if (!Array.isArray(root[arrayKey])) root[arrayKey] = [];
23
+ root[arrayKey].push(arrayItem);
24
+ arrayItem = null;
25
+ }
26
+ };
27
+
28
+ for (const rawLine of text.split(/\r?\n/)) {
29
+ const line = rawLine.replace(/#.*$/, "").trim();
30
+ if (!line) continue;
31
+
32
+ const tableArray = line.match(/^\[\[([a-zA-Z0-9_]+)\]\]$/);
33
+ if (tableArray) {
34
+ flushArrayItem();
35
+ arrayKey = tableArray[1];
36
+ arrayItem = {};
37
+ section = arrayItem;
38
+ continue;
39
+ }
40
+
41
+ const table = line.match(/^\[([a-zA-Z0-9_]+)\]$/);
42
+ if (table) {
43
+ flushArrayItem();
44
+ arrayKey = null;
45
+ root[table[1]] = root[table[1]] || {};
46
+ section = root[table[1]];
47
+ continue;
48
+ }
49
+
50
+ const kv = line.match(/^([a-zA-Z0-9_]+)\s*=\s*(.+)$/);
51
+ if (!kv) continue;
52
+ const key = kv[1];
53
+ let value = kv[2].trim();
54
+ if (value === "true") value = true;
55
+ else if (value === "false") value = false;
56
+ else if (value === "[]") value = [];
57
+ else if (/^-?\d+$/.test(value)) value = Number(value);
58
+ else if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
59
+ else if (value.startsWith("[") && value.endsWith("]")) {
60
+ const inner = value.slice(1, -1).trim();
61
+ value = inner
62
+ ? inner.split(",").map((s) => {
63
+ s = s.trim();
64
+ if (s.startsWith('"') && s.endsWith('"')) return s.slice(1, -1);
65
+ if (/^-?\d+$/.test(s)) return Number(s);
66
+ return s;
67
+ })
68
+ : [];
69
+ }
70
+ section[key] = value;
71
+ }
72
+ flushArrayItem();
73
+ return root;
74
+ }
75
+
76
+ export function loadConfig(configPath) {
77
+ const abs = path.resolve(configPath);
78
+ if (!fs.existsSync(abs)) {
79
+ throw new ConfigError(`config missing: ${abs}`);
80
+ }
81
+ const raw = fs.readFileSync(abs, "utf8");
82
+ const data = parseSimpleToml(raw);
83
+
84
+ const defaultAccountId = String(data.default_account_id || "default");
85
+ const retentionDays = Number(data.retention_days ?? 30);
86
+ if (!Number.isFinite(retentionDays) || retentionDays < 1 || retentionDays > 3650) {
87
+ throw new ConfigError("retention_days must be 1..3650");
88
+ }
89
+
90
+ const sources = Array.isArray(data.sources) ? data.sources : [];
91
+ const normalizedSources = sources
92
+ .filter((s) => s && (s.source_id || s.source_id === 0))
93
+ .map((s) => {
94
+ const mode = normalizeMode(s.mode);
95
+ if (!SOURCE_MODES.includes(mode)) throw new ConfigError(`invalid mode: ${s.mode}`);
96
+ return {
97
+ account_id: String(s.account_id || defaultAccountId),
98
+ source_type: String(s.source_type || "group"),
99
+ source_id: String(s.source_id),
100
+ source_name: String(s.source_name || ""),
101
+ mode,
102
+ };
103
+ });
104
+
105
+ const destination = {
106
+ account_id: String(data.destination?.account_id || defaultAccountId),
107
+ group_id: String(data.destination?.group_id || ""),
108
+ group_name: String(data.destination?.group_name || ""),
109
+ };
110
+
111
+ const rateLimit = {
112
+ messages_per_hour: Number(data.rate_limit?.messages_per_hour ?? 20),
113
+ messages_per_day: Number(data.rate_limit?.messages_per_day ?? 120),
114
+ ingest_per_hour: Number(data.rate_limit?.ingest_per_hour ?? 2000),
115
+ destination_per_hour: Number(data.rate_limit?.destination_per_hour ?? 30),
116
+ alert_cooldown_minutes: Number(data.rate_limit?.alert_cooldown_minutes ?? 30),
117
+ };
118
+
119
+ const roles = {
120
+ owner: Array.isArray(data.roles?.owner) ? data.roles.owner.map(String) : [],
121
+ admin: Array.isArray(data.roles?.admin) ? data.roles.admin.map(String) : [],
122
+ operator: Array.isArray(data.roles?.operator) ? data.roles.operator.map(String) : [],
123
+ viewer: Array.isArray(data.roles?.viewer) ? data.roles.viewer.map(String) : [],
124
+ };
125
+
126
+ const hermes = {
127
+ webhook_url: String(data.hermes?.webhook_url || process.env.HERMES_WEBHOOK_URL || ""),
128
+ api_base: String(
129
+ data.hermes?.api_base || process.env.HERMES_API_BASE || "http://127.0.0.1:8642/v1",
130
+ ),
131
+ // Prefer env — never commit API key into git config.
132
+ api_key: String(
133
+ process.env.HERMES_API_SERVER_KEY ||
134
+ process.env.API_SERVER_KEY ||
135
+ data.hermes?.api_key ||
136
+ "",
137
+ ),
138
+ model: String(data.hermes?.model || process.env.HERMES_API_MODEL || "hermes-agent"),
139
+ timeout_ms: Number(data.hermes?.timeout_ms ?? 45000),
140
+ auto_analyze: Boolean(data.hermes?.auto_analyze ?? false),
141
+ };
142
+
143
+ return {
144
+ path: abs,
145
+ default_account_id: defaultAccountId,
146
+ retention_days: retentionDays,
147
+ listen_self: Boolean(data.listen_self ?? false),
148
+ listen_all_groups: Boolean(
149
+ data.listen_all_groups ?? process.env.LISTEN_ALL_GROUPS === "true",
150
+ ),
151
+ listen_dms: Boolean(data.listen_dms ?? false),
152
+ // Hard listener-only default. Outbound is not unlocked by an inbound
153
+ // message, mention, destination selection, or MCP request.
154
+ listener_only: data.listener_only !== false,
155
+ auto_alert: Boolean(data.auto_alert ?? false),
156
+ auto_reply_default: false, // hard safety
157
+ read_only_source: data.read_only_source === false ? false : true,
158
+ mention_reply: false,
159
+ quote_reply: false,
160
+ dm_reply: false,
161
+ phone_label: String(data.phone_label || process.env.ZALO_PHONE_LABEL || ""),
162
+ dashboard_bind: String(data.dashboard_bind || process.env.HOST || "127.0.0.1"),
163
+ dashboard_port: Number(process.env.PORT || data.dashboard_port || 3871),
164
+ sources: normalizedSources,
165
+ destination,
166
+ rate_limit: rateLimit,
167
+ roles,
168
+ hermes,
169
+ };
170
+ }
171
+
172
+ export function sourcePolicy(config, accountId, sourceId) {
173
+ const hit = config.sources.find(
174
+ (s) => s.account_id === String(accountId) && s.source_id === String(sourceId),
175
+ );
176
+ return hit || null;
177
+ }
178
+
179
+ export function canIngest(config, event) {
180
+ if (!event?.account_id || !event?.source_id) return { ok: false, reason: "missing_ids" };
181
+ if (event.is_self && !config.listen_self) return { ok: false, reason: "self_echo" };
182
+
183
+ // Loop prevention: never re-ingest bot posts from destination group.
184
+ if (
185
+ config.destination.group_id &&
186
+ event.source_id === config.destination.group_id &&
187
+ event.is_self
188
+ ) {
189
+ return { ok: false, reason: "destination_loop" };
190
+ }
191
+
192
+ const policy = sourcePolicy(config, event.account_id, event.source_id);
193
+ if (!policy) return { ok: false, reason: "not_allowlisted" };
194
+ if (policy.mode === "off") return { ok: false, reason: "mode_off" };
195
+ return { ok: true, policy };
196
+ }
197
+
198
+ export function canAutoReply(config, event, policy) {
199
+ if (!policy) return false;
200
+ if (policy.mode === "reply_enabled") return true;
201
+ if (policy.mode === "mention_only" && event.is_mention) return true;
202
+ return false;
203
+ }
204
+
205
+ export function shouldDigest(policy) {
206
+ return policy && ["digest_only", "alert_only", "listen_only", "mention_only", "reply_enabled"].includes(policy.mode)
207
+ ? policy.mode !== "listen_only" || false
208
+ : false;
209
+ }
210
+
211
+ // digest_only/alert_only/mention_only/reply_enabled contribute to digest; listen_only stores only.
212
+ export function includeInDigest(policy) {
213
+ if (!policy) return false;
214
+ return ["digest_only", "alert_only", "mention_only", "reply_enabled"].includes(policy.mode);
215
+ }
216
+
217
+ export function includeInAlert(policy, priority) {
218
+ if (!policy) return false;
219
+ if (policy.mode === "alert_only" || policy.mode === "reply_enabled" || policy.mode === "mention_only") {
220
+ return Number(priority || 0) >= 7;
221
+ }
222
+ return false;
223
+ }
package/src/digest.js ADDED
@@ -0,0 +1,330 @@
1
+ // Digest + alert pipelines. Always through Policy Guard + Hermes validation.
2
+ import { sha256, utcNow } from "./schema.js";
3
+ import { buildHermesRequest, callHermes } from "./hermes_client.js";
4
+ import { sanitizeForHermes } from "./privacy.js";
5
+ import { buildOpsReport, scrubTechJargon } from "./ops_report.js";
6
+
7
+ export function buildDigestText({
8
+ events,
9
+ windowLabel = "24 giờ",
10
+ accountId,
11
+ stats = {},
12
+ }) {
13
+ // Manager-facing ops report (not technical log).
14
+ return buildOpsReport({
15
+ title: "Tổng hợp vận hành — destination đã cấu hình",
16
+ windowLabel: String(windowLabel).replace(/h$/i, " giờ"),
17
+ stats: {
18
+ groups: stats.groups ?? stats.sources ?? 0,
19
+ users: stats.users ?? 0,
20
+ member_links: stats.member_links ?? 0,
21
+ messages_total: stats.messages_total ?? stats.messages ?? 0,
22
+ recent_source_msgs: events.length,
23
+ },
24
+ recentEvents: events,
25
+ autoReplyEnabled: false,
26
+ listening: true,
27
+ note: accountId ? "" : "",
28
+ });
29
+ }
30
+
31
+ async function sendWithRetry({ runtime, targetId, text, threadType = 1, maxRetries = 3 }) {
32
+ let lastErr = null;
33
+ for (let i = 1; i <= maxRetries; i++) {
34
+ try {
35
+ await runtime.sendText(targetId, text, threadType);
36
+ return { ok: true, attempts: i };
37
+ } catch (err) {
38
+ lastErr = err;
39
+ await new Promise((r) => setTimeout(r, 200 * i));
40
+ }
41
+ }
42
+ return { ok: false, error: lastErr, attempts: maxRetries };
43
+ }
44
+
45
+ export async function sendDigest({
46
+ config,
47
+ store,
48
+ policy,
49
+ hub,
50
+ accountId,
51
+ hours = 24,
52
+ reportType = "manual",
53
+ fetchImpl,
54
+ }) {
55
+ const id = String(accountId || config.default_account_id);
56
+ // Do this before reading corpus or calling Hermes: listener-only has zero
57
+ // model/tool cost and zero outbound side effect.
58
+ if (config.listener_only !== false) {
59
+ return { ok: false, reason: "outbound_disabled", event_count: 0, skipped_before_analysis: true };
60
+ }
61
+ const periodEnd = utcNow();
62
+ const periodStart = new Date(Date.now() - hours * 3600 * 1000).toISOString();
63
+ const events = store.recentEvents({ accountId: id, limit: 200, sinceIso: periodStart });
64
+
65
+ const eligible = events.filter((e) => {
66
+ const p = store.getSource(e.account_id, e.source_id);
67
+ // Under listen-all, include source messages except destination self-loop.
68
+ if (p && p.mode === "off") return false;
69
+ if (p && p.is_allowed === false) return false;
70
+ const destId = store.getDestination(id).group_id;
71
+ if (destId && e.source_id === destId) return false;
72
+ return true;
73
+ });
74
+
75
+ const stats = {
76
+ groups: store.listSources(id).length,
77
+ users: store.countUsers(id),
78
+ member_links: store.countSourceMembers(id),
79
+ messages_total: store.countEvents(id),
80
+ };
81
+ let text = buildDigestText({
82
+ events: eligible,
83
+ windowLabel: hours === 24 ? "24 giờ" : `${hours} giờ`,
84
+ accountId: id,
85
+ stats,
86
+ });
87
+
88
+ // Prefer local ops format; only accept Hermes rewrite if it stays manager-readable.
89
+ if (eligible.length >= 5) {
90
+ const hermesPayload = buildHermesRequest({
91
+ accountId: id,
92
+ purpose: "ops_report",
93
+ source: { source_type: "multi", source_id: "ops", source_name: "allowlisted" },
94
+ messages: eligible.slice(0, 40),
95
+ });
96
+ const hermes = await callHermes({ config, payload: hermesPayload, fetchImpl });
97
+ if (hermes.ok && hermes.response.action === "send" && hermes.response.message) {
98
+ const safe = sanitizeForHermes(hermes.response.message);
99
+ if (safe.ok) {
100
+ const scrubbed = scrubTechJargon(safe.text);
101
+ if (
102
+ /Trạng thái:|Số liệu hiện có:|Việc tiếp theo:/.test(scrubbed) &&
103
+ !/\b(corpus|digest|READ_ONLY|outbound)\b/i.test(scrubbed)
104
+ ) {
105
+ text = scrubbed;
106
+ }
107
+ }
108
+ } else if (!hermes.ok) {
109
+ store.setHealth("last_hermes_error", hermes.reason || "unknown");
110
+ } else if (hermes.response?.action === "manual_review") {
111
+ const reportId = store.putReport({
112
+ account_id: id,
113
+ destination_source_id: store.getDestination(id).group_id || "",
114
+ report_type: reportType,
115
+ period_start: periodStart,
116
+ period_end: periodEnd,
117
+ content: text,
118
+ sent_status: "manual_review",
119
+ error_message: hermes.response.reason || "manual_review",
120
+ });
121
+ return { ok: false, reason: "manual_review", report_id: reportId, text, event_count: eligible.length };
122
+ }
123
+ }
124
+
125
+ const dest = store.getDestination(id);
126
+ const targetId = dest.group_id;
127
+ const decision = policy.evaluateOutbound({
128
+ accountId: id,
129
+ targetId,
130
+ text,
131
+ kind: "digest",
132
+ });
133
+
134
+ const reportId = store.putReport({
135
+ account_id: id,
136
+ destination_source_id: targetId || "",
137
+ report_type: reportType,
138
+ period_start: periodStart,
139
+ period_end: periodEnd,
140
+ content: text,
141
+ sent_status: "draft",
142
+ });
143
+
144
+ if (!decision.allow) {
145
+ store.logOutbound({
146
+ accountId: id,
147
+ targetId: targetId || "",
148
+ kind: "digest",
149
+ textSha: sha256(text),
150
+ ok: false,
151
+ reason: decision.reason,
152
+ });
153
+ store.updateReport(reportId, {
154
+ sent_status: "blocked",
155
+ error_message: decision.reason,
156
+ });
157
+ return { ok: false, reason: decision.reason, text, event_count: eligible.length, report_id: reportId };
158
+ }
159
+
160
+ const runtime = hub.getRuntime(id);
161
+ if (!runtime.api && process.env.ALLOW_FAKE_SEND !== "true") {
162
+ // dry path: keep draft
163
+ store.updateReport(reportId, { sent_status: "draft", error_message: "not_connected" });
164
+ return {
165
+ ok: false,
166
+ reason: "not_connected",
167
+ text,
168
+ event_count: eligible.length,
169
+ report_id: reportId,
170
+ dry_run: true,
171
+ };
172
+ }
173
+
174
+ if (process.env.ALLOW_FAKE_SEND === "true" && !runtime.api) {
175
+ store.logOutbound({
176
+ accountId: id,
177
+ targetId,
178
+ kind: "digest",
179
+ textSha: decision.textSha || sha256(text),
180
+ ok: true,
181
+ reason: "fake_sent",
182
+ });
183
+ store.updateReport(reportId, { sent_status: "sent", sent_at: utcNow() });
184
+ return { ok: true, reason: "fake_sent", event_count: eligible.length, report_id: reportId, text };
185
+ }
186
+
187
+ const sent = await sendWithRetry({ runtime, targetId, text, threadType: 1, maxRetries: 3 });
188
+ if (!sent.ok) {
189
+ const errs = store.bumpSendError(id, false);
190
+ store.logOutbound({
191
+ accountId: id,
192
+ targetId,
193
+ kind: "digest",
194
+ textSha: decision.textSha || sha256(text),
195
+ ok: false,
196
+ reason: String(sent.error?.message || sent.error || "send_fail"),
197
+ });
198
+ store.updateReport(reportId, {
199
+ sent_status: "error",
200
+ error_message: String(sent.error?.message || sent.error || "send_fail"),
201
+ });
202
+ if (errs >= 5) {
203
+ store.setAccountStatus(id, "paused", { last_error: "consecutive_send_errors" });
204
+ store.setHealth("account_auto_paused", { account_id: id, at: utcNow() });
205
+ }
206
+ return { ok: false, reason: "send_fail", event_count: eligible.length, report_id: reportId };
207
+ }
208
+
209
+ store.bumpSendError(id, true);
210
+ store.logOutbound({
211
+ accountId: id,
212
+ targetId,
213
+ kind: "digest",
214
+ textSha: decision.textSha || sha256(text),
215
+ ok: true,
216
+ reason: "sent",
217
+ });
218
+ store.updateReport(reportId, { sent_status: "sent", sent_at: utcNow() });
219
+ return { ok: true, reason: "sent", event_count: eligible.length, report_id: reportId, text };
220
+ }
221
+
222
+ export async function maybeSendAlert({
223
+ config,
224
+ store,
225
+ policy,
226
+ hub,
227
+ event,
228
+ enrichment,
229
+ fetchImpl,
230
+ }) {
231
+ // A high-priority-looking inbound message must never become a paid model
232
+ // call or destination alert while the bridge is listener-only.
233
+ if (config.listener_only !== false) {
234
+ return { ok: false, reason: "outbound_disabled", skipped_before_analysis: true };
235
+ }
236
+ const priority = enrichment?.priority || "low";
237
+ const lead = Boolean(enrichment?.lead_flag);
238
+ const risk = Boolean(enrichment?.risk_flag);
239
+ const high = priority === "high" || lead || risk;
240
+ if (!high) return { ok: false, reason: "not_high" };
241
+
242
+ const accountId = event.account_id;
243
+ const dest = store.getDestination(accountId);
244
+ const alertKey = `${event.source_id}:${priority}:${lead ? "lead" : ""}:${risk ? "risk" : ""}`;
245
+
246
+ const hermesPayload = buildHermesRequest({
247
+ accountId,
248
+ purpose: "alert",
249
+ source: {
250
+ source_type: event.source_type,
251
+ source_id: event.source_id,
252
+ source_name: event.source_name,
253
+ },
254
+ messages: [event],
255
+ });
256
+ const hermes = await callHermes({ config, payload: hermesPayload, fetchImpl, maxRetries: 3 });
257
+ if (!hermes.ok) {
258
+ store.putReport({
259
+ account_id: accountId,
260
+ destination_source_id: dest.group_id || "",
261
+ report_type: "alert",
262
+ period_start: event.created_at,
263
+ period_end: event.created_at,
264
+ content: event.text || "",
265
+ sent_status: "manual_review",
266
+ error_message: hermes.reason || "hermes_fail",
267
+ });
268
+ return { ok: false, reason: hermes.reason || "hermes_fail", manual_review: true };
269
+ }
270
+ if (hermes.response.action !== "send") {
271
+ return { ok: false, reason: hermes.response.action };
272
+ }
273
+
274
+ const safe = sanitizeForHermes(hermes.response.message);
275
+ if (!safe.ok) return { ok: false, reason: safe.reason };
276
+
277
+ const decision = policy.evaluateOutbound({
278
+ accountId,
279
+ targetId: dest.group_id,
280
+ text: safe.text,
281
+ kind: "alert",
282
+ alertKey,
283
+ });
284
+ if (!decision.allow) return { ok: false, reason: decision.reason };
285
+
286
+ const runtime = hub.getRuntime(accountId);
287
+ if (!runtime.api && process.env.ALLOW_FAKE_SEND !== "true") {
288
+ return { ok: false, reason: "not_connected" };
289
+ }
290
+ if (process.env.ALLOW_FAKE_SEND === "true" && !runtime.api) {
291
+ store.setCooldown(accountId, alertKey);
292
+ store.logOutbound({
293
+ accountId,
294
+ targetId: dest.group_id,
295
+ kind: "alert",
296
+ textSha: decision.textSha || sha256(safe.text),
297
+ ok: true,
298
+ reason: "fake_sent",
299
+ });
300
+ return { ok: true, reason: "fake_sent" };
301
+ }
302
+
303
+ const sent = await sendWithRetry({
304
+ runtime,
305
+ targetId: dest.group_id,
306
+ text: safe.text,
307
+ maxRetries: 3,
308
+ });
309
+ if (!sent.ok) {
310
+ store.logOutbound({
311
+ accountId,
312
+ targetId: dest.group_id,
313
+ kind: "alert",
314
+ textSha: decision.textSha || sha256(safe.text),
315
+ ok: false,
316
+ reason: String(sent.error?.message || "send_fail"),
317
+ });
318
+ return { ok: false, reason: "send_fail" };
319
+ }
320
+ store.setCooldown(accountId, alertKey);
321
+ store.logOutbound({
322
+ accountId,
323
+ targetId: dest.group_id,
324
+ kind: "alert",
325
+ textSha: decision.textSha || sha256(safe.text),
326
+ ok: true,
327
+ reason: "sent",
328
+ });
329
+ return { ok: true, reason: "sent" };
330
+ }