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.
@@ -0,0 +1,135 @@
1
+ // Executive Zalo report format for managers — not technical logs.
2
+ // Never invent operational depth when message history is thin.
3
+
4
+ function num(n) {
5
+ return Number(n || 0).toLocaleString("vi-VN");
6
+ }
7
+
8
+ function cleanLine(s, max = 100) {
9
+ return String(s || "")
10
+ .replace(/\s+/g, " ")
11
+ .trim()
12
+ .slice(0, max);
13
+ }
14
+
15
+ /**
16
+ * Build one professional ops message for the configured destination.
17
+ * @param {object} p
18
+ * @param {string} [p.title]
19
+ * @param {string} [p.windowLabel] e.g. "24 giờ"
20
+ * @param {object} p.stats { groups, users, member_links, messages_total, recent_source_msgs }
21
+ * @param {Array} [p.recentEvents] source messages (not destination)
22
+ * @param {boolean} [p.autoReplyEnabled]
23
+ * @param {boolean} [p.listening]
24
+ * @param {string} [p.note]
25
+ */
26
+ export function buildOpsReport({
27
+ title = "Tổng hợp vận hành — destination đã cấu hình",
28
+ windowLabel = "24 giờ",
29
+ stats = {},
30
+ recentEvents = [],
31
+ autoReplyEnabled = false,
32
+ listening = true,
33
+ note = "",
34
+ } = {}) {
35
+ const groups = stats.groups ?? stats.sources ?? 0;
36
+ const users = stats.users ?? 0;
37
+ const links = stats.member_links ?? 0;
38
+ const msgs = stats.messages_total ?? stats.messages ?? 0;
39
+ const recent = stats.recent_source_msgs ?? recentEvents.length;
40
+
41
+ const statusParts = [];
42
+ statusParts.push(
43
+ listening
44
+ ? "Hệ thống đang theo dõi bình thường"
45
+ : "Hệ thống chưa theo dõi tin mới"
46
+ );
47
+ statusParts.push(
48
+ autoReplyEnabled
49
+ ? "trả lời tự động đang bật"
50
+ : "chưa bật trả lời tự động"
51
+ );
52
+
53
+ const lines = [];
54
+ lines.push(title);
55
+ lines.push("");
56
+ lines.push("Trạng thái:");
57
+ lines.push(statusParts.join(", ") + ".");
58
+ lines.push("");
59
+ lines.push("Số liệu hiện có:");
60
+ lines.push(`${num(groups)} nhóm đã ghi nhận`);
61
+ lines.push(`${num(users)} người dùng`);
62
+ lines.push(`${num(links)} liên kết thành viên-nhóm`);
63
+ lines.push(`${num(msgs)} tin nhắn đã lưu`);
64
+ lines.push("");
65
+ lines.push("Hoạt động gần đây:");
66
+
67
+ if (recent > 0 && recentEvents.length) {
68
+ lines.push(`Có ${num(recent)} tin từ nhóm nguồn trong ${windowLabel}.`);
69
+ const bySource = new Map();
70
+ for (const e of recentEvents.slice(0, 40)) {
71
+ const name = e.source_name || e.source_id || "nhóm";
72
+ bySource.set(name, (bySource.get(name) || 0) + 1);
73
+ }
74
+ const top = [...bySource.entries()]
75
+ .sort((a, b) => b[1] - a[1])
76
+ .slice(0, 5);
77
+ for (const [name, count] of top) {
78
+ lines.push(`• ${cleanLine(name, 60)}: ${count} tin`);
79
+ }
80
+ // Only surface issues when we actually have message text.
81
+ const samples = recentEvents
82
+ .map((e) => cleanLine(e.text, 90))
83
+ .filter(Boolean)
84
+ .slice(0, 3);
85
+ if (samples.length) {
86
+ lines.push("Ghi nhận gần đây:");
87
+ for (const s of samples) lines.push(`- ${s}`);
88
+ }
89
+ } else if (recent > 0) {
90
+ lines.push(`Có ${num(recent)} tin từ nhóm nguồn trong ${windowLabel}.`);
91
+ } else {
92
+ lines.push(`Chưa ghi nhận thêm tin mới trong ${windowLabel} qua.`);
93
+ }
94
+
95
+ lines.push("");
96
+ lines.push("Nhận xét:");
97
+ if (msgs < 10) {
98
+ lines.push(
99
+ "Dữ liệu nhóm và thành viên đã đủ để map vận hành ban đầu. Lịch sử tin nhắn cũ còn hạn chế, nên phần tổng hợp nội dung hiện chưa sâu."
100
+ );
101
+ } else if (recent === 0) {
102
+ lines.push(
103
+ "Đã có kho tin nhắn tích lũy, nhưng cửa sổ gần đây yên. Chưa đủ tín hiệu để đánh giá xu hướng vận hành."
104
+ );
105
+ } else {
106
+ lines.push(
107
+ "Đã có tín hiệu tin gần đây. Có thể theo dõi vấn đề nổi bật và việc cần follow-up khi số lượng tin đủ dày hơn."
108
+ );
109
+ }
110
+ if (note) lines.push(cleanLine(note, 220));
111
+
112
+ lines.push("");
113
+ lines.push("Việc tiếp theo:");
114
+ if (msgs < 10) {
115
+ lines.push(
116
+ "Tiếp tục theo dõi tin mới realtime. Khi dữ liệu đủ hơn, báo cáo sẽ đánh giá được xu hướng, vấn đề nổi bật và việc cần xử lý."
117
+ );
118
+ } else {
119
+ lines.push(
120
+ "Giữ theo dõi tin mới. Ưu tiên các nhóm có nhiều tin bất thường hoặc cần follow-up."
121
+ );
122
+ }
123
+
124
+ // Hard cap for Zalo single message readability.
125
+ return lines.join("\n").slice(0, 3200);
126
+ }
127
+
128
+ /** Strip leftover technical jargon if an upstream model returns it. */
129
+ export function scrubTechJargon(text) {
130
+ return String(text || "")
131
+ .replace(/\b(corpus|digest|outbound|READ_ONLY(?:_SOURCE)?|bridge|ask_reply|event_id)\b/gi, "")
132
+ .replace(/[ \t]{2,}/g, " ")
133
+ .replace(/\n{3,}/g, "\n\n")
134
+ .trim();
135
+ }
package/src/policy.js ADDED
@@ -0,0 +1,320 @@
1
+ // Policy Guard — hard READ_ONLY_SOURCE for shared personal Zalo.
2
+ // Phase 1 inbound brain: destination + owner + "bot ..." prefix only.
3
+ import { SOURCE_MODES, sha256 } from "./schema.js";
4
+ import { containsSecretPattern } from "./privacy.js";
5
+ import { isBotCommand } from "./inbound_router.js";
6
+
7
+ const ROLE_ORDER = ["viewer", "operator", "admin", "owner"];
8
+
9
+ const COMMAND_MIN_ROLE = {
10
+ status: "viewer",
11
+ help: "viewer",
12
+ whoami: "viewer",
13
+ digest: "operator",
14
+ pause: "admin",
15
+ resume: "admin",
16
+ set: "admin",
17
+ allow: "admin",
18
+ mode: "admin",
19
+ mute: "admin",
20
+ add: "owner",
21
+ remove: "owner",
22
+ };
23
+
24
+ // Bot/report fingerprints — never re-ingest as asks.
25
+ const BOT_REPORT_HINT =
26
+ /^(Tổng hợp (?:hôm nay|vận hành)\s*[—\-–]|Cảnh báo —|Zalo Intelligence Digest|Zalo Bridge digest|Phản hồi on-demand|Tóm tắt \(|DRAFT —|account=|status=|paused=|global_kill=|Trạng thái:)/i
27
+
28
+ export class PolicyGuard {
29
+ constructor({ config, store }) {
30
+ this.config = config;
31
+ this.store = store;
32
+ this.ingestPerHour = Number(config.rate_limit?.ingest_per_hour || 2000);
33
+ this.alertCooldownMinutes = Number(config.rate_limit?.alert_cooldown_minutes || 30);
34
+ this.destPerHour = Number(config.rate_limit?.destination_per_hour || 30);
35
+ }
36
+
37
+ get readOnlySource() {
38
+ if (this.config.read_only_source === false) return false;
39
+ return true;
40
+ }
41
+
42
+ roleOf(accountId, userId) {
43
+ return this.store.roleOf(accountId, userId) || null;
44
+ }
45
+
46
+ requireRole(accountId, userId, minRole) {
47
+ const role = this.roleOf(accountId, userId);
48
+ if (!role) return false;
49
+ return ROLE_ORDER.indexOf(role) >= ROLE_ORDER.indexOf(minRole);
50
+ }
51
+
52
+ getSourcePolicy(accountId, sourceId) {
53
+ const row = this.store.getSource(accountId, sourceId);
54
+ if (!row || !row.is_allowed || row.mode === "off") return null;
55
+ return {
56
+ account_id: row.account_id,
57
+ source_id: row.source_id,
58
+ source_type: row.source_type,
59
+ source_name: row.source_name,
60
+ mode: row.mode,
61
+ muted: Boolean(row.muted),
62
+ };
63
+ }
64
+
65
+ safetyFlags() {
66
+ const dest = this.store.getDestination(this.config.default_account_id);
67
+ return {
68
+ READ_ONLY_SOURCE: this.readOnlySource,
69
+ destination: dest,
70
+ auto_reply_disabled: true,
71
+ dm_reply_disabled: true,
72
+ mention_reply_disabled: true,
73
+ quote_reply_disabled: true,
74
+ global_inbound_action_disabled: true,
75
+ listener_only: this.config.listener_only !== false,
76
+ outbound_disabled: this.config.listener_only !== false,
77
+ bot_prefix_required: false,
78
+ auto_alert: false,
79
+ listen_all_groups: Boolean(this.config.listen_all_groups),
80
+ listen_dms: Boolean(this.config.listen_dms),
81
+ outbound_allowlist: dest.group_id
82
+ ? [dest.group_id]
83
+ : dest.group_name
84
+ ? [`name:${dest.group_name}`]
85
+ : [],
86
+ };
87
+ }
88
+
89
+ evaluateInbound(event) {
90
+ if (!event?.account_id || !event?.source_id) {
91
+ return { allow: false, reason: "missing_ids", policy: null, actions: [] };
92
+ }
93
+
94
+ const dest = this.store.getDestination(event.account_id);
95
+ const inDestination =
96
+ Boolean(dest.group_id) && String(event.source_id) === String(dest.group_id);
97
+ const text = String(event.text || "").trim();
98
+ const isSlash = text.startsWith("/");
99
+ const hasBotCmd = !isSlash && isBotCommand(text);
100
+
101
+ // Never re-ingest bot/report messages (loop prevention), including destination.
102
+ if (event.is_self) {
103
+ if (BOT_REPORT_HINT.test(text)) {
104
+ return { allow: false, reason: "self_bot_report", policy: null, actions: [] };
105
+ }
106
+ try {
107
+ const sha = sha256(text);
108
+ if (this.store.recentOutboundDuplicate(event.account_id, sha, 30)) {
109
+ return { allow: false, reason: "self_outbound_echo", policy: null, actions: [] };
110
+ }
111
+ } catch {
112
+ /* ignore */
113
+ }
114
+ }
115
+
116
+ // Outside destination: absolute silence on self messages.
117
+ if (event.is_self && !inDestination) {
118
+ return { allow: false, reason: "self_echo", policy: null, actions: [] };
119
+ }
120
+
121
+ // DM: store optional only if explicitly enabled; never reply / never brain.
122
+ if (event.source_type === "dm") {
123
+ if (!this.config.listen_dms) {
124
+ return { allow: false, reason: "dm_disabled", policy: null, actions: [] };
125
+ }
126
+ return {
127
+ allow: true,
128
+ reason: "dm_store_only",
129
+ policy: { mode: "listen_only", muted: true },
130
+ actions: ["store"],
131
+ };
132
+ }
133
+
134
+ // Listener-only is intentionally before every command/brain route.
135
+ // Untrusted text — including prompt-injection-looking "bot" messages and
136
+ // mentions — can only be stored. It cannot invoke skills, MCP, a model,
137
+ // or an outbound action.
138
+ if (this.config.listener_only !== false) {
139
+ return {
140
+ allow: true,
141
+ reason: "listener_only_store",
142
+ policy: { mode: "listen_only", muted: true },
143
+ actions: ["store"],
144
+ };
145
+ }
146
+
147
+ // Slash commands: ONLY valid inside destination group.
148
+ if (isSlash) {
149
+ if (!inDestination) {
150
+ return { allow: false, reason: "command_outside_destination", policy: null, actions: [] };
151
+ }
152
+ if (event.is_self || this.roleOf(event.account_id, event.sender_id)) {
153
+ return {
154
+ allow: true,
155
+ reason: "destination_command",
156
+ policy: null,
157
+ actions: ["command"],
158
+ };
159
+ }
160
+ return { allow: false, reason: "unauthorized_silent", policy: null, actions: [] };
161
+ }
162
+
163
+ // Destination group: Phase 1 brain path = "bot ..." prefix only.
164
+ if (inDestination) {
165
+ if (!hasBotCmd) {
166
+ return {
167
+ allow: true,
168
+ reason: "destination_store_only",
169
+ policy: null,
170
+ actions: ["store"],
171
+ };
172
+ }
173
+ // Auth check deferred to router for silent unauthorized.
174
+ return {
175
+ allow: true,
176
+ reason: "destination_bot_cmd",
177
+ policy: null,
178
+ actions: ["destination_ask"],
179
+ };
180
+ }
181
+
182
+ // Source groups: READ_ONLY — store only, never reply even if tagged/quoted.
183
+ if (this.config.listen_all_groups && event.source_type === "group") {
184
+ const ingestCount = this.store.countInboundRecent(event.account_id, 60);
185
+ if (ingestCount >= this.ingestPerHour) {
186
+ return { allow: false, reason: "ingest_rate", policy: null, actions: [] };
187
+ }
188
+ return {
189
+ allow: true,
190
+ reason: "read_only_source",
191
+ policy: {
192
+ account_id: event.account_id,
193
+ source_id: event.source_id,
194
+ source_type: "group",
195
+ source_name: event.source_name || "",
196
+ mode: "listen_only",
197
+ muted: true,
198
+ },
199
+ actions: ["store"],
200
+ };
201
+ }
202
+
203
+ const policy = this.getSourcePolicy(event.account_id, event.source_id);
204
+ if (!policy) return { allow: false, reason: "not_allowlisted", policy: null, actions: [] };
205
+
206
+ const ingestCount = this.store.countInboundRecent(event.account_id, 60);
207
+ if (ingestCount >= this.ingestPerHour) {
208
+ return { allow: false, reason: "ingest_rate", policy, actions: [] };
209
+ }
210
+
211
+ const actions = ["store"];
212
+ if (
213
+ !this.readOnlySource &&
214
+ ["digest_only", "alert_only", "mention_only", "reply_enabled"].includes(policy.mode)
215
+ ) {
216
+ actions.push("digest_candidate");
217
+ }
218
+ if (!this.readOnlySource && this.config.auto_alert) {
219
+ if (["alert_only", "mention_only", "reply_enabled"].includes(policy.mode)) {
220
+ actions.push("alert_candidate");
221
+ }
222
+ }
223
+ return { allow: true, reason: "ok_store_only", policy: { ...policy, mode: "listen_only" }, actions };
224
+ }
225
+
226
+ evaluateCommand({ accountId, senderId, command, sourceId = null }) {
227
+ if (!senderId) return { allow: false, reason: "missing_sender", silent: true };
228
+ const dest = this.store.getDestination(accountId);
229
+ if (!dest.group_id) return { allow: false, reason: "destination_unset", silent: true };
230
+ if (sourceId && String(sourceId) !== String(dest.group_id)) {
231
+ return { allow: false, reason: "command_outside_destination", silent: true };
232
+ }
233
+ const minRole = COMMAND_MIN_ROLE[command] || "owner";
234
+ const account = this.store.getAccount(accountId);
235
+ const isSelfOwner =
236
+ account?.zalo_user_id && String(account.zalo_user_id) === String(senderId);
237
+ if (isSelfOwner) return { allow: true, reason: "self_owner", minRole };
238
+ if (!this.requireRole(accountId, senderId, minRole)) {
239
+ return { allow: false, reason: "unauthorized_silent", silent: true };
240
+ }
241
+ return { allow: true, reason: "ok", minRole };
242
+ }
243
+
244
+ evaluateOutbound({ accountId, targetId, text, kind = "digest", alertKey = null }) {
245
+ // Master hard gate. Future RBAC must replace this only together with an
246
+ // explicit owner approval, numeric-identity checks, scoped capabilities,
247
+ // a quota budget, and its own regression suite.
248
+ if (this.config.listener_only !== false) return { allow: false, reason: "outbound_disabled" };
249
+ if (this.store.isGlobalPaused()) return { allow: false, reason: "bridge_paused" };
250
+
251
+ const account = this.store.getAccount(accountId);
252
+ if (!account) return { allow: false, reason: "unknown_account" };
253
+ if (account.status === "paused" || account.paused) return { allow: false, reason: "account_paused" };
254
+ if (account.status !== "connected" && process.env.POLICY_REQUIRE_CONNECTED !== "false") {
255
+ if (process.env.NODE_ENV !== "test" && process.env.POLICY_REQUIRE_CONNECTED !== "false") {
256
+ return { allow: false, reason: "account_not_connected" };
257
+ }
258
+ }
259
+
260
+ if (!targetId) return { allow: false, reason: "missing_target" };
261
+ if (!text || !String(text).trim()) return { allow: false, reason: "empty_text" };
262
+
263
+ const dest = this.store.getDestination(accountId);
264
+ if (!dest.group_id) return { allow: false, reason: "destination_unset" };
265
+
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" };
269
+ }
270
+ if (String(dest.account_id) && String(dest.account_id) !== String(accountId)) {
271
+ return { allow: false, reason: "destination_account_mismatch" };
272
+ }
273
+
274
+ if (this.readOnlySource && (kind === "reply" || kind === "mention" || kind === "quote")) {
275
+ return { allow: false, reason: "read_only_source_blocks_reply" };
276
+ }
277
+
278
+ if (!["digest", "alert", "ask_reply", "command_reply", "report"].includes(String(kind))) {
279
+ if (kind !== "digest") return { allow: false, reason: "kind_blocked" };
280
+ }
281
+
282
+ const hourCount = this.store.countOutbound(accountId, 60);
283
+ const dayCount = this.store.countOutbound(accountId, 60 * 24);
284
+ const destHour = this.store.countOutbound(accountId, 60, targetId);
285
+ if (hourCount >= (this.config.rate_limit?.messages_per_hour || 20)) {
286
+ return { allow: false, reason: "rate_hour" };
287
+ }
288
+ if (dayCount >= (this.config.rate_limit?.messages_per_day || 120)) {
289
+ return { allow: false, reason: "rate_day" };
290
+ }
291
+ if (destHour >= this.destPerHour) return { allow: false, reason: "rate_destination_hour" };
292
+
293
+ const body = String(text);
294
+ if (body.length > 3500) return { allow: false, reason: "too_long" };
295
+ if (containsSecretPattern(body)) return { allow: false, reason: "secret_leak_pattern" };
296
+
297
+ if (kind === "alert" && alertKey) {
298
+ const cd = this.store.getCooldown(accountId, alertKey);
299
+ if (cd?.last_sent_at) {
300
+ const ageMin = (Date.now() - Date.parse(cd.last_sent_at)) / 60000;
301
+ if (ageMin < this.alertCooldownMinutes) {
302
+ return { allow: false, reason: "alert_cooldown" };
303
+ }
304
+ }
305
+ }
306
+
307
+ const textSha = sha256(body);
308
+ if (this.store.recentOutboundDuplicate(accountId, textSha, 120)) {
309
+ return { allow: false, reason: "duplicate_recent" };
310
+ }
311
+
312
+ return { allow: true, reason: "ok", textSha };
313
+ }
314
+ }
315
+
316
+ export function isValidMode(mode) {
317
+ return SOURCE_MODES.includes(String(mode || ""));
318
+ }
319
+
320
+ export { COMMAND_MIN_ROLE, ROLE_ORDER, BOT_REPORT_HINT };
package/src/privacy.js ADDED
@@ -0,0 +1,38 @@
1
+ // Privacy helpers: redaction before Hermes/report; raw retention optional.
2
+ const PHONE_RE = /(?:\+?84|0)\d{8,10}\b/g;
3
+ const EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
4
+ const SECRET_RE = /(cookie|imei|password|api[_-]?key|authorization\s*:\s*bearer|session[_-]?token)/i;
5
+
6
+ export function hashSender(senderId, salt = process.env.HERMES_ZALO_PSEUDONYM_SALT || "local-only") {
7
+ // lazy import-free hash via WebCrypto-less sha256 from schema
8
+ return null; // filled by callers using sha256 from schema to keep one hash util
9
+ }
10
+
11
+ export function redactPII(text, { phones = true, emails = true } = {}) {
12
+ let out = String(text ?? "");
13
+ if (phones) out = out.replace(PHONE_RE, "[phone]");
14
+ if (emails) out = out.replace(EMAIL_RE, "[email]");
15
+ return out;
16
+ }
17
+
18
+ export function containsSecretPattern(text) {
19
+ return SECRET_RE.test(String(text ?? ""));
20
+ }
21
+
22
+ export function sanitizeForHermes(text, opts = {}) {
23
+ const redacted = redactPII(text, opts);
24
+ if (containsSecretPattern(redacted)) {
25
+ return { ok: false, text: "", reason: "secret_pattern" };
26
+ }
27
+ return { ok: true, text: redacted.slice(0, 2000) };
28
+ }
29
+
30
+ export function stripSensitiveMetadata(meta = {}) {
31
+ const out = { ...meta };
32
+ for (const k of Object.keys(out)) {
33
+ if (/cookie|imei|token|password|session|secret|authorization/i.test(k)) {
34
+ delete out[k];
35
+ }
36
+ }
37
+ return out;
38
+ }
package/src/schema.js ADDED
@@ -0,0 +1,163 @@
1
+ // Shared constants + pure helpers. No secrets. No network.
2
+ import crypto from "node:crypto";
3
+
4
+ export const ACCOUNT_STATUSES = Object.freeze([
5
+ "disconnected",
6
+ "need_scan",
7
+ "reconnecting",
8
+ "connected",
9
+ "paused",
10
+ ]);
11
+
12
+ export const SOURCE_MODES = Object.freeze([
13
+ "off",
14
+ "listen_only",
15
+ "digest_only",
16
+ "alert_only",
17
+ "mention_only",
18
+ "reply_enabled",
19
+ ]);
20
+
21
+ export const ROLES = Object.freeze(["owner", "admin", "operator", "viewer"]);
22
+
23
+ export const MESSAGE_TYPES = Object.freeze([
24
+ "text",
25
+ "image",
26
+ "file",
27
+ "audio",
28
+ "video",
29
+ "sticker",
30
+ "unknown",
31
+ ]);
32
+
33
+ export function utcNow() {
34
+ return new Date().toISOString();
35
+ }
36
+
37
+ export function sha256(text) {
38
+ return crypto.createHash("sha256").update(String(text ?? ""), "utf8").digest("hex");
39
+ }
40
+
41
+ export function stableEventId(parts) {
42
+ return sha256(parts.filter((p) => p != null && p !== "").join("|")).slice(0, 32);
43
+ }
44
+
45
+ export function safeJsonParse(raw, fallback = null) {
46
+ try {
47
+ return JSON.parse(raw);
48
+ } catch {
49
+ return fallback;
50
+ }
51
+ }
52
+
53
+ const SECRET_KEY =
54
+ /^(cookie|cookies|imei|password|secret|authorization|access_token|refresh_token|dashboard_token|user_agent)$/i;
55
+ // "session" alone is too broad (has_session, sessions_dir). Only raw session blobs.
56
+ const SECRET_SESSION_KEY = /^(session|session_data|session_blob|credentials)$/i;
57
+
58
+ export function redactSecrets(value) {
59
+ if (value == null) return value;
60
+ if (typeof value === "string") {
61
+ // Avoid redacting ordinary short labels; only long secret-looking payloads.
62
+ if (/(eyJ|cookie=|imei=)/i.test(value) && value.length > 40) return "[redacted]";
63
+ return value;
64
+ }
65
+ if (Array.isArray(value)) return value.map(redactSecrets);
66
+ if (typeof value === "object") {
67
+ const out = {};
68
+ for (const [k, v] of Object.entries(value)) {
69
+ if (SECRET_KEY.test(k) || SECRET_SESSION_KEY.test(k)) {
70
+ out[k] = "[redacted]";
71
+ continue;
72
+ }
73
+ out[k] = redactSecrets(v);
74
+ }
75
+ return out;
76
+ }
77
+ return value;
78
+ }
79
+
80
+ export function normalizeMode(mode) {
81
+ const m = String(mode || "off").trim().toLowerCase();
82
+ return SOURCE_MODES.includes(m) ? m : "off";
83
+ }
84
+
85
+ export function classifyMessageType(content) {
86
+ if (typeof content === "string") return "text";
87
+ if (!content || typeof content !== "object") return "unknown";
88
+ if (content.href || content.thumb || content.title) return "image";
89
+ if (content.fileUrl || content.fileName) return "file";
90
+ if (content.voiceUrl || content.duration != null && content.fileSize) return "audio";
91
+ if (content.videoUrl) return "video";
92
+ if (content.id && content.cateId != null) return "sticker";
93
+ return "unknown";
94
+ }
95
+
96
+ export function extractText(content) {
97
+ if (typeof content === "string") return content;
98
+ if (!content || typeof content !== "object") return "";
99
+ if (typeof content.title === "string") return content.title;
100
+ if (typeof content.description === "string") return content.description;
101
+ if (typeof content.fileName === "string") return content.fileName;
102
+ return "";
103
+ }
104
+
105
+ /**
106
+ * Normalize a zca-js style message into the internal event schema.
107
+ * account_id is required so multi-session never collides.
108
+ */
109
+ export function normalizeInboundMessage({ accountId, message, sourceName = "" }) {
110
+ if (!accountId) throw new Error("account_id required");
111
+ if (!message) throw new Error("message required");
112
+
113
+ const isGroup = Number(message.type) === 1 || message.type === "group";
114
+ const sourceType = isGroup ? "group" : "dm";
115
+ const sourceId = String(message.threadId ?? message.thread_id ?? "");
116
+ const data = message.data || {};
117
+ const messageId = String(data.msgId ?? data.messageId ?? data.cliMsgId ?? message.messageId ?? "");
118
+ const senderId = String(data.uidFrom ?? data.senderId ?? message.senderId ?? "");
119
+ const senderName = String(data.dName ?? data.displayName ?? data.senderName ?? "");
120
+ const content = data.content;
121
+ const messageType = classifyMessageType(content);
122
+ const text = extractText(content);
123
+ const createdAt = data.ts
124
+ ? new Date(Number(data.ts) || data.ts).toISOString()
125
+ : utcNow();
126
+ const isSelf = Boolean(message.isSelf);
127
+ const isMention =
128
+ Boolean(data.mentions?.length) ||
129
+ Boolean(data.quote) ||
130
+ /@/.test(text);
131
+
132
+ const eventId = stableEventId([
133
+ accountId,
134
+ sourceType,
135
+ sourceId,
136
+ messageId,
137
+ senderId,
138
+ createdAt,
139
+ text.slice(0, 80),
140
+ ]);
141
+
142
+ return {
143
+ event_id: eventId,
144
+ account_id: String(accountId),
145
+ source_type: sourceType,
146
+ source_id: sourceId,
147
+ source_name: sourceName || String(data.threadName || ""),
148
+ sender_id: senderId,
149
+ sender_name: senderName,
150
+ message_id: messageId || eventId,
151
+ message_type: messageType,
152
+ text,
153
+ is_self: isSelf,
154
+ is_mention: isMention,
155
+ raw_metadata: {
156
+ thread_type: message.type,
157
+ cli_msg_id: data.cliMsgId ?? null,
158
+ has_quote: Boolean(data.quote),
159
+ mention_count: Array.isArray(data.mentions) ? data.mentions.length : 0,
160
+ },
161
+ created_at: createdAt,
162
+ };
163
+ }