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/.env.example +48 -0
- package/LICENSE +21 -0
- package/README.md +435 -0
- package/SECURITY.md +22 -0
- package/config/bots.example.json +33 -0
- package/config.toml +48 -0
- package/mcp/run.sh +7 -0
- package/mcp/server.js +255 -0
- package/package.json +76 -0
- package/scripts/battle-smoke.js +118 -0
- package/scripts/corpus-stats.js +23 -0
- package/scripts/probe-history.js +76 -0
- package/scripts/public-gate.js +216 -0
- package/scripts/setup.js +233 -0
- package/src/abs_telemetry.js +60 -0
- package/src/ask.js +7 -0
- package/src/backfill.js +364 -0
- package/src/bot_registry.js +180 -0
- package/src/brand.js +31 -0
- package/src/cli.js +268 -0
- package/src/commands.js +265 -0
- package/src/config.js +223 -0
- package/src/digest.js +330 -0
- package/src/discovery.js +185 -0
- package/src/hermes_client.js +302 -0
- package/src/inbound_router.js +295 -0
- package/src/keepalive.js +375 -0
- package/src/oa_adapter.js +284 -0
- package/src/oa_auto_reply.js +75 -0
- package/src/oa_policy.js +32 -0
- package/src/oa_webhook.js +95 -0
- package/src/onboarding.js +102 -0
- package/src/ops_report.js +135 -0
- package/src/policy.js +320 -0
- package/src/privacy.js +38 -0
- package/src/schema.js +163 -0
- package/src/server.js +604 -0
- package/src/store.js +972 -0
- package/src/zalo_runtime.js +480 -0
package/src/store.js
ADDED
|
@@ -0,0 +1,972 @@
|
|
|
1
|
+
// SQLite store — company schema with account_id isolation.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
import { utcNow, sha256 } from "./schema.js";
|
|
6
|
+
import { redactPII, stripSensitiveMetadata } from "./privacy.js";
|
|
7
|
+
|
|
8
|
+
export class Store {
|
|
9
|
+
constructor(dataDir, options = {}) {
|
|
10
|
+
this.dataDir = path.resolve(dataDir);
|
|
11
|
+
this.retainRawText = options.retainRawText !== false;
|
|
12
|
+
this.redactPhoneEmail = options.redactPhoneEmail !== false;
|
|
13
|
+
fs.mkdirSync(this.dataDir, { recursive: true, mode: 0o700 });
|
|
14
|
+
this.sessionsDir = path.join(this.dataDir, "sessions");
|
|
15
|
+
fs.mkdirSync(this.sessionsDir, { recursive: true, mode: 0o700 });
|
|
16
|
+
this.dbPath = path.join(this.dataDir, "bridge.sqlite3");
|
|
17
|
+
this.db = new DatabaseSync(this.dbPath);
|
|
18
|
+
this.db.exec("PRAGMA journal_mode=WAL;");
|
|
19
|
+
this.db.exec("PRAGMA foreign_keys=ON;");
|
|
20
|
+
this.#migrate();
|
|
21
|
+
try {
|
|
22
|
+
fs.chmodSync(this.dbPath, 0o600);
|
|
23
|
+
} catch {
|
|
24
|
+
/* ignore */
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
#migrate() {
|
|
29
|
+
this.db.exec(`
|
|
30
|
+
CREATE TABLE IF NOT EXISTS zalo_accounts (
|
|
31
|
+
id TEXT PRIMARY KEY,
|
|
32
|
+
display_name TEXT NOT NULL DEFAULT '',
|
|
33
|
+
status TEXT NOT NULL DEFAULT 'disconnected',
|
|
34
|
+
zalo_user_id TEXT NOT NULL DEFAULT '',
|
|
35
|
+
session_ref TEXT NOT NULL DEFAULT '',
|
|
36
|
+
last_error TEXT NOT NULL DEFAULT '',
|
|
37
|
+
last_seen_at TEXT,
|
|
38
|
+
connected_at TEXT,
|
|
39
|
+
created_at TEXT NOT NULL,
|
|
40
|
+
updated_at TEXT NOT NULL,
|
|
41
|
+
paused INTEGER NOT NULL DEFAULT 0,
|
|
42
|
+
consecutive_send_errors INTEGER NOT NULL DEFAULT 0
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
CREATE TABLE IF NOT EXISTS zalo_sources (
|
|
46
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
47
|
+
account_id TEXT NOT NULL,
|
|
48
|
+
source_type TEXT NOT NULL DEFAULT 'group',
|
|
49
|
+
source_id TEXT NOT NULL,
|
|
50
|
+
source_name TEXT NOT NULL DEFAULT '',
|
|
51
|
+
mode TEXT NOT NULL DEFAULT 'off',
|
|
52
|
+
is_allowed INTEGER NOT NULL DEFAULT 0,
|
|
53
|
+
muted INTEGER NOT NULL DEFAULT 0,
|
|
54
|
+
created_at TEXT NOT NULL,
|
|
55
|
+
updated_at TEXT NOT NULL,
|
|
56
|
+
UNIQUE(account_id, source_id)
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
CREATE TABLE IF NOT EXISTS zalo_messages (
|
|
60
|
+
id TEXT PRIMARY KEY,
|
|
61
|
+
account_id TEXT NOT NULL,
|
|
62
|
+
source_id TEXT NOT NULL,
|
|
63
|
+
source_type TEXT NOT NULL,
|
|
64
|
+
source_name TEXT NOT NULL DEFAULT '',
|
|
65
|
+
sender_hash TEXT NOT NULL DEFAULT '',
|
|
66
|
+
sender_display_name TEXT NOT NULL DEFAULT '',
|
|
67
|
+
message_id TEXT NOT NULL,
|
|
68
|
+
message_type TEXT NOT NULL DEFAULT 'text',
|
|
69
|
+
text_redacted TEXT NOT NULL DEFAULT '',
|
|
70
|
+
text_raw_ref TEXT,
|
|
71
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
72
|
+
is_self INTEGER NOT NULL DEFAULT 0,
|
|
73
|
+
is_mention INTEGER NOT NULL DEFAULT 0,
|
|
74
|
+
priority INTEGER NOT NULL DEFAULT 0,
|
|
75
|
+
ingested_at TEXT NOT NULL,
|
|
76
|
+
created_at TEXT NOT NULL,
|
|
77
|
+
dedupe_hash TEXT NOT NULL,
|
|
78
|
+
UNIQUE(account_id, source_id, message_id)
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
CREATE TABLE IF NOT EXISTS zalo_enrichments (
|
|
82
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
83
|
+
message_id TEXT NOT NULL,
|
|
84
|
+
account_id TEXT NOT NULL,
|
|
85
|
+
topic TEXT NOT NULL DEFAULT '',
|
|
86
|
+
intent TEXT NOT NULL DEFAULT '',
|
|
87
|
+
sentiment TEXT NOT NULL DEFAULT '',
|
|
88
|
+
priority TEXT NOT NULL DEFAULT 'low',
|
|
89
|
+
lead_flag INTEGER NOT NULL DEFAULT 0,
|
|
90
|
+
risk_flag INTEGER NOT NULL DEFAULT 0,
|
|
91
|
+
action_items_json TEXT NOT NULL DEFAULT '[]',
|
|
92
|
+
summary TEXT NOT NULL DEFAULT '',
|
|
93
|
+
created_at TEXT NOT NULL
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
CREATE TABLE IF NOT EXISTS zalo_reports (
|
|
97
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
98
|
+
account_id TEXT NOT NULL,
|
|
99
|
+
destination_source_id TEXT NOT NULL DEFAULT '',
|
|
100
|
+
report_type TEXT NOT NULL,
|
|
101
|
+
period_start TEXT NOT NULL,
|
|
102
|
+
period_end TEXT NOT NULL,
|
|
103
|
+
content TEXT NOT NULL DEFAULT '',
|
|
104
|
+
sent_status TEXT NOT NULL DEFAULT 'draft',
|
|
105
|
+
sent_at TEXT,
|
|
106
|
+
error_message TEXT NOT NULL DEFAULT '',
|
|
107
|
+
created_at TEXT NOT NULL
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
CREATE TABLE IF NOT EXISTS zalo_permissions (
|
|
111
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
112
|
+
account_id TEXT NOT NULL,
|
|
113
|
+
user_id TEXT NOT NULL,
|
|
114
|
+
display_name TEXT NOT NULL DEFAULT '',
|
|
115
|
+
role TEXT NOT NULL,
|
|
116
|
+
created_at TEXT NOT NULL,
|
|
117
|
+
updated_at TEXT NOT NULL,
|
|
118
|
+
UNIQUE(account_id, user_id)
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
CREATE TABLE IF NOT EXISTS outbound_log (
|
|
122
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
123
|
+
account_id TEXT NOT NULL,
|
|
124
|
+
target_id TEXT NOT NULL,
|
|
125
|
+
kind TEXT NOT NULL,
|
|
126
|
+
text_sha256 TEXT NOT NULL,
|
|
127
|
+
created_at TEXT NOT NULL,
|
|
128
|
+
ok INTEGER NOT NULL,
|
|
129
|
+
reason TEXT NOT NULL DEFAULT ''
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
CREATE TABLE IF NOT EXISTS alert_cooldowns (
|
|
133
|
+
account_id TEXT NOT NULL,
|
|
134
|
+
alert_key TEXT NOT NULL,
|
|
135
|
+
last_sent_at TEXT NOT NULL,
|
|
136
|
+
PRIMARY KEY(account_id, alert_key)
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
140
|
+
key TEXT PRIMARY KEY,
|
|
141
|
+
value TEXT NOT NULL
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
CREATE TABLE IF NOT EXISTS health (
|
|
145
|
+
key TEXT PRIMARY KEY,
|
|
146
|
+
value TEXT NOT NULL
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
150
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
151
|
+
account_id TEXT NOT NULL DEFAULT '',
|
|
152
|
+
actor_id TEXT NOT NULL DEFAULT '',
|
|
153
|
+
action TEXT NOT NULL,
|
|
154
|
+
detail TEXT NOT NULL DEFAULT '',
|
|
155
|
+
created_at TEXT NOT NULL
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
CREATE TABLE IF NOT EXISTS zalo_users (
|
|
159
|
+
account_id TEXT NOT NULL,
|
|
160
|
+
user_id TEXT NOT NULL,
|
|
161
|
+
display_name TEXT NOT NULL DEFAULT '',
|
|
162
|
+
avatar TEXT NOT NULL DEFAULT '',
|
|
163
|
+
global_id TEXT NOT NULL DEFAULT '',
|
|
164
|
+
account_status INTEGER,
|
|
165
|
+
first_seen_at TEXT NOT NULL,
|
|
166
|
+
last_seen_at TEXT NOT NULL,
|
|
167
|
+
raw_json TEXT NOT NULL DEFAULT '{}',
|
|
168
|
+
PRIMARY KEY(account_id, user_id)
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
CREATE TABLE IF NOT EXISTS zalo_source_members (
|
|
172
|
+
account_id TEXT NOT NULL,
|
|
173
|
+
source_id TEXT NOT NULL,
|
|
174
|
+
user_id TEXT NOT NULL,
|
|
175
|
+
role TEXT NOT NULL DEFAULT 'member',
|
|
176
|
+
first_seen_at TEXT NOT NULL,
|
|
177
|
+
last_seen_at TEXT NOT NULL,
|
|
178
|
+
PRIMARY KEY(account_id, source_id, user_id)
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
CREATE INDEX IF NOT EXISTS idx_users_account_name ON zalo_users(account_id, display_name);
|
|
182
|
+
CREATE INDEX IF NOT EXISTS idx_members_source ON zalo_source_members(account_id, source_id);
|
|
183
|
+
CREATE INDEX IF NOT EXISTS idx_msg_account_created ON zalo_messages(account_id, created_at);
|
|
184
|
+
CREATE INDEX IF NOT EXISTS idx_msg_dedupe ON zalo_messages(dedupe_hash);
|
|
185
|
+
CREATE INDEX IF NOT EXISTS idx_out_account_created ON outbound_log(account_id, created_at);
|
|
186
|
+
CREATE INDEX IF NOT EXISTS idx_sources_account ON zalo_sources(account_id);
|
|
187
|
+
`);
|
|
188
|
+
|
|
189
|
+
// legacy alias table for older code paths during transition
|
|
190
|
+
this.db.exec(`
|
|
191
|
+
CREATE TABLE IF NOT EXISTS accounts (
|
|
192
|
+
account_id TEXT PRIMARY KEY,
|
|
193
|
+
display_name TEXT NOT NULL DEFAULT '',
|
|
194
|
+
status TEXT NOT NULL DEFAULT 'disconnected',
|
|
195
|
+
zalo_user_id TEXT NOT NULL DEFAULT '',
|
|
196
|
+
last_error TEXT NOT NULL DEFAULT '',
|
|
197
|
+
connected_at TEXT,
|
|
198
|
+
updated_at TEXT NOT NULL,
|
|
199
|
+
paused INTEGER NOT NULL DEFAULT 0
|
|
200
|
+
);
|
|
201
|
+
`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// --- settings / kill switch ---
|
|
205
|
+
getSetting(key, fallback = null) {
|
|
206
|
+
const row = this.db.prepare(`SELECT value FROM settings WHERE key = ?`).get(String(key));
|
|
207
|
+
if (!row) return fallback;
|
|
208
|
+
try {
|
|
209
|
+
return JSON.parse(row.value);
|
|
210
|
+
} catch {
|
|
211
|
+
return row.value;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
setSetting(key, value) {
|
|
216
|
+
const raw = typeof value === "string" ? value : JSON.stringify(value);
|
|
217
|
+
this.db
|
|
218
|
+
.prepare(
|
|
219
|
+
`INSERT INTO settings(key, value) VALUES(?, ?)
|
|
220
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
|
221
|
+
)
|
|
222
|
+
.run(String(key), raw);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
isGlobalPaused() {
|
|
226
|
+
if (process.env.BRIDGE_PAUSED === "true") return true;
|
|
227
|
+
return Boolean(this.getSetting("global_kill_switch", false));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
setGlobalPaused(paused) {
|
|
231
|
+
this.setSetting("global_kill_switch", Boolean(paused));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
getDestination(accountId) {
|
|
235
|
+
const all = this.getSetting("destinations", {});
|
|
236
|
+
const hit = all[String(accountId)];
|
|
237
|
+
if (hit && (hit.group_id || hit.group_name)) {
|
|
238
|
+
return {
|
|
239
|
+
account_id: String(hit.account_id || accountId),
|
|
240
|
+
group_id: String(hit.group_id || ""),
|
|
241
|
+
group_name: String(hit.group_name || ""),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
return { account_id: String(accountId), group_id: "", group_name: "" };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
setDestination(accountId, groupId, groupName = "") {
|
|
248
|
+
const all = this.getSetting("destinations", {}) || {};
|
|
249
|
+
all[String(accountId)] = {
|
|
250
|
+
account_id: String(accountId),
|
|
251
|
+
group_id: String(groupId || ""),
|
|
252
|
+
group_name: String(groupName || ""),
|
|
253
|
+
};
|
|
254
|
+
this.setSetting("destinations", all);
|
|
255
|
+
return all[String(accountId)];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// --- accounts ---
|
|
259
|
+
ensureAccount(accountId, displayName = "") {
|
|
260
|
+
const id = String(accountId);
|
|
261
|
+
const now = utcNow();
|
|
262
|
+
this.db
|
|
263
|
+
.prepare(
|
|
264
|
+
`INSERT INTO zalo_accounts(id, display_name, status, session_ref, created_at, updated_at)
|
|
265
|
+
VALUES(?, ?, 'disconnected', ?, ?, ?)
|
|
266
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
267
|
+
display_name=CASE WHEN excluded.display_name != '' THEN excluded.display_name ELSE zalo_accounts.display_name END,
|
|
268
|
+
updated_at=excluded.updated_at`,
|
|
269
|
+
)
|
|
270
|
+
.run(id, displayName || id, this.sessionPath(id), now, now);
|
|
271
|
+
// keep legacy row in sync
|
|
272
|
+
this.db
|
|
273
|
+
.prepare(
|
|
274
|
+
`INSERT INTO accounts(account_id, display_name, status, updated_at)
|
|
275
|
+
VALUES(?, ?, 'disconnected', ?)
|
|
276
|
+
ON CONFLICT(account_id) DO UPDATE SET updated_at=excluded.updated_at`,
|
|
277
|
+
)
|
|
278
|
+
.run(id, displayName || id, now);
|
|
279
|
+
return this.getAccount(id);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
getAccount(accountId) {
|
|
283
|
+
const row = this.db.prepare(`SELECT * FROM zalo_accounts WHERE id = ?`).get(String(accountId));
|
|
284
|
+
if (!row) return null;
|
|
285
|
+
return {
|
|
286
|
+
account_id: row.id,
|
|
287
|
+
display_name: row.display_name,
|
|
288
|
+
status: row.status,
|
|
289
|
+
zalo_user_id: row.zalo_user_id,
|
|
290
|
+
last_error: row.last_error,
|
|
291
|
+
connected_at: row.connected_at,
|
|
292
|
+
updated_at: row.updated_at,
|
|
293
|
+
last_seen_at: row.last_seen_at,
|
|
294
|
+
paused: row.paused,
|
|
295
|
+
consecutive_send_errors: row.consecutive_send_errors,
|
|
296
|
+
session_ref: row.session_ref,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
listAccounts() {
|
|
301
|
+
return this.db
|
|
302
|
+
.prepare(`SELECT * FROM zalo_accounts ORDER BY id`)
|
|
303
|
+
.all()
|
|
304
|
+
.map((row) => this.getAccount(row.id));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
setAccountStatus(accountId, status, extra = {}) {
|
|
308
|
+
const now = utcNow();
|
|
309
|
+
this.ensureAccount(accountId);
|
|
310
|
+
this.db
|
|
311
|
+
.prepare(
|
|
312
|
+
`UPDATE zalo_accounts SET
|
|
313
|
+
status = ?,
|
|
314
|
+
display_name = COALESCE(?, display_name),
|
|
315
|
+
zalo_user_id = COALESCE(?, zalo_user_id),
|
|
316
|
+
last_error = COALESCE(?, last_error),
|
|
317
|
+
connected_at = CASE WHEN ? = 'connected' THEN COALESCE(connected_at, ?) ELSE connected_at END,
|
|
318
|
+
last_seen_at = CASE WHEN ? = 'connected' THEN ? ELSE last_seen_at END,
|
|
319
|
+
paused = CASE WHEN ? = 'paused' THEN 1 WHEN ? IN ('connected','need_scan','reconnecting','disconnected') THEN 0 ELSE paused END,
|
|
320
|
+
updated_at = ?
|
|
321
|
+
WHERE id = ?`,
|
|
322
|
+
)
|
|
323
|
+
.run(
|
|
324
|
+
status,
|
|
325
|
+
extra.display_name ?? null,
|
|
326
|
+
extra.zalo_user_id ?? null,
|
|
327
|
+
extra.last_error ?? null,
|
|
328
|
+
status,
|
|
329
|
+
now,
|
|
330
|
+
status,
|
|
331
|
+
now,
|
|
332
|
+
status,
|
|
333
|
+
status,
|
|
334
|
+
now,
|
|
335
|
+
String(accountId),
|
|
336
|
+
);
|
|
337
|
+
this.db
|
|
338
|
+
.prepare(
|
|
339
|
+
`UPDATE accounts SET status=?, display_name=COALESCE(?,display_name), zalo_user_id=COALESCE(?,zalo_user_id),
|
|
340
|
+
last_error=COALESCE(?,last_error), updated_at=?, paused=CASE WHEN ?= 'paused' THEN 1 ELSE paused END
|
|
341
|
+
WHERE account_id=?`,
|
|
342
|
+
)
|
|
343
|
+
.run(
|
|
344
|
+
status,
|
|
345
|
+
extra.display_name ?? null,
|
|
346
|
+
extra.zalo_user_id ?? null,
|
|
347
|
+
extra.last_error ?? null,
|
|
348
|
+
now,
|
|
349
|
+
status,
|
|
350
|
+
String(accountId),
|
|
351
|
+
);
|
|
352
|
+
return this.getAccount(accountId);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
bumpSendError(accountId, ok) {
|
|
356
|
+
if (ok) {
|
|
357
|
+
this.db
|
|
358
|
+
.prepare(`UPDATE zalo_accounts SET consecutive_send_errors=0, updated_at=? WHERE id=?`)
|
|
359
|
+
.run(utcNow(), String(accountId));
|
|
360
|
+
return 0;
|
|
361
|
+
}
|
|
362
|
+
this.db
|
|
363
|
+
.prepare(
|
|
364
|
+
`UPDATE zalo_accounts SET consecutive_send_errors=consecutive_send_errors+1, updated_at=? WHERE id=?`,
|
|
365
|
+
)
|
|
366
|
+
.run(utcNow(), String(accountId));
|
|
367
|
+
return this.getAccount(accountId)?.consecutive_send_errors || 0;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
sessionPath(accountId) {
|
|
371
|
+
return path.join(this.sessionsDir, `${String(accountId)}.json`);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
saveSession(accountId, credentials) {
|
|
375
|
+
const p = this.sessionPath(accountId);
|
|
376
|
+
const tmp = `${p}.tmp`;
|
|
377
|
+
fs.writeFileSync(tmp, JSON.stringify(credentials), { mode: 0o600 });
|
|
378
|
+
fs.renameSync(tmp, p);
|
|
379
|
+
fs.chmodSync(p, 0o600);
|
|
380
|
+
this.db
|
|
381
|
+
.prepare(`UPDATE zalo_accounts SET session_ref=?, updated_at=? WHERE id=?`)
|
|
382
|
+
.run(p, utcNow(), String(accountId));
|
|
383
|
+
return p;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
loadSession(accountId) {
|
|
387
|
+
const p = this.sessionPath(accountId);
|
|
388
|
+
if (!fs.existsSync(p)) return null;
|
|
389
|
+
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
hasSession(accountId) {
|
|
393
|
+
return fs.existsSync(this.sessionPath(accountId));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
deleteSession(accountId) {
|
|
397
|
+
const p = this.sessionPath(accountId);
|
|
398
|
+
if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// --- sources ---
|
|
402
|
+
listSources(accountId = null) {
|
|
403
|
+
if (accountId) {
|
|
404
|
+
return this.db
|
|
405
|
+
.prepare(`SELECT * FROM zalo_sources WHERE account_id=? ORDER BY source_id`)
|
|
406
|
+
.all(String(accountId));
|
|
407
|
+
}
|
|
408
|
+
return this.db.prepare(`SELECT * FROM zalo_sources ORDER BY account_id, source_id`).all();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
getSource(accountId, sourceId) {
|
|
412
|
+
return (
|
|
413
|
+
this.db
|
|
414
|
+
.prepare(`SELECT * FROM zalo_sources WHERE account_id=? AND source_id=?`)
|
|
415
|
+
.get(String(accountId), String(sourceId)) || null
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
upsertSource({ accountId, sourceId, sourceType = "group", sourceName = "", mode = "off", isAllowed = null, muted = null }) {
|
|
420
|
+
const now = utcNow();
|
|
421
|
+
const existing = this.getSource(accountId, sourceId);
|
|
422
|
+
const allowed =
|
|
423
|
+
isAllowed == null ? (existing ? existing.is_allowed : mode !== "off" ? 1 : 0) : isAllowed ? 1 : 0;
|
|
424
|
+
const mute = muted == null ? (existing ? existing.muted : 0) : muted ? 1 : 0;
|
|
425
|
+
const finalMode = mode || existing?.mode || "off";
|
|
426
|
+
this.db
|
|
427
|
+
.prepare(
|
|
428
|
+
`INSERT INTO zalo_sources(account_id, source_type, source_id, source_name, mode, is_allowed, muted, created_at, updated_at)
|
|
429
|
+
VALUES(?,?,?,?,?,?,?,?,?)
|
|
430
|
+
ON CONFLICT(account_id, source_id) DO UPDATE SET
|
|
431
|
+
source_type=excluded.source_type,
|
|
432
|
+
source_name=CASE WHEN excluded.source_name != '' THEN excluded.source_name ELSE zalo_sources.source_name END,
|
|
433
|
+
mode=excluded.mode,
|
|
434
|
+
is_allowed=excluded.is_allowed,
|
|
435
|
+
muted=excluded.muted,
|
|
436
|
+
updated_at=excluded.updated_at`,
|
|
437
|
+
)
|
|
438
|
+
.run(
|
|
439
|
+
String(accountId),
|
|
440
|
+
String(sourceType),
|
|
441
|
+
String(sourceId),
|
|
442
|
+
String(sourceName || ""),
|
|
443
|
+
String(finalMode),
|
|
444
|
+
allowed,
|
|
445
|
+
mute,
|
|
446
|
+
now,
|
|
447
|
+
now,
|
|
448
|
+
);
|
|
449
|
+
return this.getSource(accountId, sourceId);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
setSourceMode(accountId, sourceId, mode) {
|
|
453
|
+
return this.upsertSource({
|
|
454
|
+
accountId,
|
|
455
|
+
sourceId,
|
|
456
|
+
mode,
|
|
457
|
+
isAllowed: mode !== "off",
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
muteSource(accountId, sourceId, muted = true) {
|
|
462
|
+
const src = this.getSource(accountId, sourceId);
|
|
463
|
+
if (!src) {
|
|
464
|
+
return this.upsertSource({ accountId, sourceId, mode: "off", isAllowed: false, muted });
|
|
465
|
+
}
|
|
466
|
+
return this.upsertSource({
|
|
467
|
+
accountId,
|
|
468
|
+
sourceId,
|
|
469
|
+
sourceType: src.source_type,
|
|
470
|
+
sourceName: src.source_name,
|
|
471
|
+
mode: src.mode,
|
|
472
|
+
isAllowed: src.is_allowed,
|
|
473
|
+
muted,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// --- permissions ---
|
|
478
|
+
listPermissions(accountId = null) {
|
|
479
|
+
if (accountId) {
|
|
480
|
+
return this.db
|
|
481
|
+
.prepare(`SELECT * FROM zalo_permissions WHERE account_id=? ORDER BY role, user_id`)
|
|
482
|
+
.all(String(accountId));
|
|
483
|
+
}
|
|
484
|
+
return this.db.prepare(`SELECT * FROM zalo_permissions ORDER BY account_id, role, user_id`).all();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
getPermission(accountId, userId) {
|
|
488
|
+
return (
|
|
489
|
+
this.db
|
|
490
|
+
.prepare(`SELECT * FROM zalo_permissions WHERE account_id=? AND user_id=?`)
|
|
491
|
+
.get(String(accountId), String(userId)) || null
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
upsertPermission({ accountId, userId, role, displayName = "" }) {
|
|
496
|
+
const now = utcNow();
|
|
497
|
+
this.db
|
|
498
|
+
.prepare(
|
|
499
|
+
`INSERT INTO zalo_permissions(account_id, user_id, display_name, role, created_at, updated_at)
|
|
500
|
+
VALUES(?,?,?,?,?,?)
|
|
501
|
+
ON CONFLICT(account_id, user_id) DO UPDATE SET
|
|
502
|
+
role=excluded.role,
|
|
503
|
+
display_name=CASE WHEN excluded.display_name != '' THEN excluded.display_name ELSE zalo_permissions.display_name END,
|
|
504
|
+
updated_at=excluded.updated_at`,
|
|
505
|
+
)
|
|
506
|
+
.run(String(accountId), String(userId), String(displayName || ""), String(role), now, now);
|
|
507
|
+
return this.getPermission(accountId, userId);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
removePermission(accountId, userId) {
|
|
511
|
+
this.db
|
|
512
|
+
.prepare(`DELETE FROM zalo_permissions WHERE account_id=? AND user_id=?`)
|
|
513
|
+
.run(String(accountId), String(userId));
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
roleOf(accountId, userId) {
|
|
517
|
+
const row = this.getPermission(accountId, userId);
|
|
518
|
+
return row?.role || null;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// --- users / members corpus ---
|
|
522
|
+
upsertUser({
|
|
523
|
+
accountId,
|
|
524
|
+
userId,
|
|
525
|
+
displayName = "",
|
|
526
|
+
avatar = "",
|
|
527
|
+
globalId = "",
|
|
528
|
+
accountStatus = null,
|
|
529
|
+
raw = {},
|
|
530
|
+
}) {
|
|
531
|
+
if (!accountId || !userId) return null;
|
|
532
|
+
const now = utcNow();
|
|
533
|
+
this.db
|
|
534
|
+
.prepare(
|
|
535
|
+
`INSERT INTO zalo_users(account_id, user_id, display_name, avatar, global_id, account_status, first_seen_at, last_seen_at, raw_json)
|
|
536
|
+
VALUES(?,?,?,?,?,?,?,?,?)
|
|
537
|
+
ON CONFLICT(account_id, user_id) DO UPDATE SET
|
|
538
|
+
display_name=CASE WHEN excluded.display_name != '' THEN excluded.display_name ELSE zalo_users.display_name END,
|
|
539
|
+
avatar=CASE WHEN excluded.avatar != '' THEN excluded.avatar ELSE zalo_users.avatar END,
|
|
540
|
+
global_id=CASE WHEN excluded.global_id != '' THEN excluded.global_id ELSE zalo_users.global_id END,
|
|
541
|
+
account_status=COALESCE(excluded.account_status, zalo_users.account_status),
|
|
542
|
+
last_seen_at=excluded.last_seen_at,
|
|
543
|
+
raw_json=CASE WHEN excluded.raw_json != '{}' THEN excluded.raw_json ELSE zalo_users.raw_json END`,
|
|
544
|
+
)
|
|
545
|
+
.run(
|
|
546
|
+
String(accountId),
|
|
547
|
+
String(userId),
|
|
548
|
+
String(displayName || ""),
|
|
549
|
+
String(avatar || ""),
|
|
550
|
+
String(globalId || ""),
|
|
551
|
+
accountStatus,
|
|
552
|
+
now,
|
|
553
|
+
now,
|
|
554
|
+
JSON.stringify(raw || {}),
|
|
555
|
+
);
|
|
556
|
+
return this.getUser(accountId, userId);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
getUser(accountId, userId) {
|
|
560
|
+
return (
|
|
561
|
+
this.db
|
|
562
|
+
.prepare(`SELECT * FROM zalo_users WHERE account_id=? AND user_id=?`)
|
|
563
|
+
.get(String(accountId), String(userId)) || null
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
listUsers(accountId, limit = 500) {
|
|
568
|
+
return this.db
|
|
569
|
+
.prepare(
|
|
570
|
+
`SELECT account_id, user_id, display_name, avatar, global_id, account_status, first_seen_at, last_seen_at
|
|
571
|
+
FROM zalo_users WHERE account_id=? ORDER BY last_seen_at DESC LIMIT ?`,
|
|
572
|
+
)
|
|
573
|
+
.all(String(accountId), Math.min(Number(limit) || 500, 5000));
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
countUsers(accountId = null) {
|
|
577
|
+
if (accountId) {
|
|
578
|
+
return this.db
|
|
579
|
+
.prepare(`SELECT COUNT(*) AS c FROM zalo_users WHERE account_id=?`)
|
|
580
|
+
.get(String(accountId)).c;
|
|
581
|
+
}
|
|
582
|
+
return this.db.prepare(`SELECT COUNT(*) AS c FROM zalo_users`).get().c;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
upsertSourceMember({ accountId, sourceId, userId, role = "member" }) {
|
|
586
|
+
if (!accountId || !sourceId || !userId) return null;
|
|
587
|
+
const now = utcNow();
|
|
588
|
+
this.db
|
|
589
|
+
.prepare(
|
|
590
|
+
`INSERT INTO zalo_source_members(account_id, source_id, user_id, role, first_seen_at, last_seen_at)
|
|
591
|
+
VALUES(?,?,?,?,?,?)
|
|
592
|
+
ON CONFLICT(account_id, source_id, user_id) DO UPDATE SET
|
|
593
|
+
role=CASE WHEN excluded.role != 'member' THEN excluded.role ELSE zalo_source_members.role END,
|
|
594
|
+
last_seen_at=excluded.last_seen_at`,
|
|
595
|
+
)
|
|
596
|
+
.run(String(accountId), String(sourceId), String(userId), String(role || "member"), now, now);
|
|
597
|
+
return true;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
listSourceMembers(accountId, sourceId, limit = 500) {
|
|
601
|
+
return this.db
|
|
602
|
+
.prepare(
|
|
603
|
+
`SELECT m.*, u.display_name, u.avatar
|
|
604
|
+
FROM zalo_source_members m
|
|
605
|
+
LEFT JOIN zalo_users u
|
|
606
|
+
ON u.account_id=m.account_id AND u.user_id=m.user_id
|
|
607
|
+
WHERE m.account_id=? AND m.source_id=?
|
|
608
|
+
ORDER BY m.role DESC, u.display_name ASC
|
|
609
|
+
LIMIT ?`,
|
|
610
|
+
)
|
|
611
|
+
.all(String(accountId), String(sourceId), Math.min(Number(limit) || 500, 5000));
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
countSourceMembers(accountId = null) {
|
|
615
|
+
if (accountId) {
|
|
616
|
+
return this.db
|
|
617
|
+
.prepare(`SELECT COUNT(*) AS c FROM zalo_source_members WHERE account_id=?`)
|
|
618
|
+
.get(String(accountId)).c;
|
|
619
|
+
}
|
|
620
|
+
return this.db.prepare(`SELECT COUNT(*) AS c FROM zalo_source_members`).get().c;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// --- messages ---
|
|
624
|
+
putEvent(event) {
|
|
625
|
+
const now = utcNow();
|
|
626
|
+
const rawText = String(event.text || "");
|
|
627
|
+
const textRedacted = this.redactPhoneEmail
|
|
628
|
+
? redactPII(rawText, { phones: true, emails: true })
|
|
629
|
+
: rawText;
|
|
630
|
+
const senderUserId = String(event.sender_id || event.raw_metadata?.sender_user_id || "");
|
|
631
|
+
const senderHash = sha256(`${event.account_id}:${senderUserId || "unknown"}`).slice(0, 24);
|
|
632
|
+
const dedupeHash =
|
|
633
|
+
event.event_id ||
|
|
634
|
+
sha256([event.account_id, event.source_id, event.message_id, textRedacted.slice(0, 80)].join("|"));
|
|
635
|
+
const meta = stripSensitiveMetadata({
|
|
636
|
+
...(event.raw_metadata || {}),
|
|
637
|
+
sender_user_id: senderUserId || null,
|
|
638
|
+
});
|
|
639
|
+
const textRawRef = this.retainRawText ? rawText : null;
|
|
640
|
+
|
|
641
|
+
if (senderUserId) {
|
|
642
|
+
this.upsertUser({
|
|
643
|
+
accountId: event.account_id,
|
|
644
|
+
userId: senderUserId,
|
|
645
|
+
displayName: event.sender_name || "",
|
|
646
|
+
});
|
|
647
|
+
if (event.source_type === "group" && event.source_id) {
|
|
648
|
+
this.upsertSourceMember({
|
|
649
|
+
accountId: event.account_id,
|
|
650
|
+
sourceId: event.source_id,
|
|
651
|
+
userId: senderUserId,
|
|
652
|
+
role: "member",
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const result = this.db
|
|
658
|
+
.prepare(
|
|
659
|
+
`INSERT OR IGNORE INTO zalo_messages(
|
|
660
|
+
id, account_id, source_id, source_type, source_name, sender_hash, sender_display_name,
|
|
661
|
+
message_id, message_type, text_redacted, text_raw_ref, metadata_json, is_self, is_mention,
|
|
662
|
+
priority, ingested_at, created_at, dedupe_hash
|
|
663
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
664
|
+
)
|
|
665
|
+
.run(
|
|
666
|
+
event.event_id || dedupeHash,
|
|
667
|
+
event.account_id,
|
|
668
|
+
event.source_id,
|
|
669
|
+
event.source_type,
|
|
670
|
+
event.source_name || "",
|
|
671
|
+
senderHash,
|
|
672
|
+
event.sender_name || "",
|
|
673
|
+
event.message_id,
|
|
674
|
+
event.message_type || "unknown",
|
|
675
|
+
textRedacted,
|
|
676
|
+
textRawRef,
|
|
677
|
+
JSON.stringify(meta),
|
|
678
|
+
event.is_self ? 1 : 0,
|
|
679
|
+
event.is_mention ? 1 : 0,
|
|
680
|
+
Number(event.priority || 0),
|
|
681
|
+
now,
|
|
682
|
+
event.created_at || now,
|
|
683
|
+
dedupeHash,
|
|
684
|
+
);
|
|
685
|
+
return result.changes === 1;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
recentEvents({ accountId = null, limit = 50, sinceIso = null, sourceId = null } = {}) {
|
|
689
|
+
const lim = Math.min(Math.max(Number(limit) || 50, 1), 500);
|
|
690
|
+
let sql = `SELECT id as event_id, account_id, source_type, source_id, source_name,
|
|
691
|
+
sender_hash as sender_id, sender_display_name as sender_name, message_id, message_type,
|
|
692
|
+
text_redacted as text, is_self, is_mention, priority, created_at, ingested_at as collected_at,
|
|
693
|
+
dedupe_hash
|
|
694
|
+
FROM zalo_messages WHERE 1=1`;
|
|
695
|
+
const params = [];
|
|
696
|
+
if (accountId) {
|
|
697
|
+
sql += ` AND account_id=?`;
|
|
698
|
+
params.push(String(accountId));
|
|
699
|
+
}
|
|
700
|
+
if (sourceId) {
|
|
701
|
+
sql += ` AND source_id=?`;
|
|
702
|
+
params.push(String(sourceId));
|
|
703
|
+
}
|
|
704
|
+
if (sinceIso) {
|
|
705
|
+
sql += ` AND created_at >= ?`;
|
|
706
|
+
params.push(sinceIso);
|
|
707
|
+
}
|
|
708
|
+
sql += ` ORDER BY created_at DESC LIMIT ?`;
|
|
709
|
+
params.push(lim);
|
|
710
|
+
return this.db.prepare(sql).all(...params);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
countEvents(accountId = null) {
|
|
714
|
+
if (accountId) {
|
|
715
|
+
return this.db
|
|
716
|
+
.prepare(`SELECT COUNT(*) AS c FROM zalo_messages WHERE account_id=?`)
|
|
717
|
+
.get(String(accountId)).c;
|
|
718
|
+
}
|
|
719
|
+
return this.db.prepare(`SELECT COUNT(*) AS c FROM zalo_messages`).get().c;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
countInboundRecent(accountId, minutes) {
|
|
723
|
+
const since = new Date(Date.now() - minutes * 60 * 1000).toISOString();
|
|
724
|
+
return this.db
|
|
725
|
+
.prepare(`SELECT COUNT(*) AS c FROM zalo_messages WHERE account_id=? AND ingested_at >= ?`)
|
|
726
|
+
.get(String(accountId), since).c;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
putEnrichment(row) {
|
|
730
|
+
this.db
|
|
731
|
+
.prepare(
|
|
732
|
+
`INSERT INTO zalo_enrichments(
|
|
733
|
+
message_id, account_id, topic, intent, sentiment, priority, lead_flag, risk_flag,
|
|
734
|
+
action_items_json, summary, created_at
|
|
735
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
|
|
736
|
+
)
|
|
737
|
+
.run(
|
|
738
|
+
row.message_id,
|
|
739
|
+
row.account_id,
|
|
740
|
+
row.topic || "",
|
|
741
|
+
row.intent || "",
|
|
742
|
+
row.sentiment || "",
|
|
743
|
+
row.priority || "low",
|
|
744
|
+
row.lead_flag ? 1 : 0,
|
|
745
|
+
row.risk_flag ? 1 : 0,
|
|
746
|
+
JSON.stringify(row.action_items || []),
|
|
747
|
+
row.summary || "",
|
|
748
|
+
utcNow(),
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
putReport(row) {
|
|
753
|
+
const r = this.db
|
|
754
|
+
.prepare(
|
|
755
|
+
`INSERT INTO zalo_reports(
|
|
756
|
+
account_id, destination_source_id, report_type, period_start, period_end,
|
|
757
|
+
content, sent_status, sent_at, error_message, created_at
|
|
758
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
|
759
|
+
)
|
|
760
|
+
.run(
|
|
761
|
+
row.account_id,
|
|
762
|
+
row.destination_source_id || "",
|
|
763
|
+
row.report_type,
|
|
764
|
+
row.period_start,
|
|
765
|
+
row.period_end,
|
|
766
|
+
row.content || "",
|
|
767
|
+
row.sent_status || "draft",
|
|
768
|
+
row.sent_at || null,
|
|
769
|
+
row.error_message || "",
|
|
770
|
+
utcNow(),
|
|
771
|
+
);
|
|
772
|
+
return Number(r.lastInsertRowid);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
updateReport(id, patch) {
|
|
776
|
+
const cur = this.db.prepare(`SELECT * FROM zalo_reports WHERE id=?`).get(id);
|
|
777
|
+
if (!cur) return null;
|
|
778
|
+
this.db
|
|
779
|
+
.prepare(
|
|
780
|
+
`UPDATE zalo_reports SET sent_status=?, sent_at=?, error_message=?, content=COALESCE(?, content) WHERE id=?`,
|
|
781
|
+
)
|
|
782
|
+
.run(
|
|
783
|
+
patch.sent_status ?? cur.sent_status,
|
|
784
|
+
patch.sent_at ?? cur.sent_at,
|
|
785
|
+
patch.error_message ?? cur.error_message,
|
|
786
|
+
patch.content ?? null,
|
|
787
|
+
id,
|
|
788
|
+
);
|
|
789
|
+
return this.db.prepare(`SELECT * FROM zalo_reports WHERE id=?`).get(id);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
listReports({ accountId = null, limit = 20 } = {}) {
|
|
793
|
+
const lim = Math.min(Number(limit) || 20, 100);
|
|
794
|
+
if (accountId) {
|
|
795
|
+
return this.db
|
|
796
|
+
.prepare(`SELECT * FROM zalo_reports WHERE account_id=? ORDER BY id DESC LIMIT ?`)
|
|
797
|
+
.all(String(accountId), lim);
|
|
798
|
+
}
|
|
799
|
+
return this.db.prepare(`SELECT * FROM zalo_reports ORDER BY id DESC LIMIT ?`).all(lim);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
logOutbound({ accountId, targetId, kind, textSha, ok, reason = "" }) {
|
|
803
|
+
this.db
|
|
804
|
+
.prepare(
|
|
805
|
+
`INSERT INTO outbound_log(account_id, target_id, kind, text_sha256, created_at, ok, reason)
|
|
806
|
+
VALUES(?,?,?,?,?,?,?)`,
|
|
807
|
+
)
|
|
808
|
+
.run(String(accountId), String(targetId), String(kind), String(textSha), utcNow(), ok ? 1 : 0, reason);
|
|
809
|
+
this.bumpSendError(accountId, ok);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
countOutbound(accountId, minutes, targetId = null) {
|
|
813
|
+
const since = new Date(Date.now() - minutes * 60 * 1000).toISOString();
|
|
814
|
+
if (targetId) {
|
|
815
|
+
return this.db
|
|
816
|
+
.prepare(
|
|
817
|
+
`SELECT COUNT(*) AS c FROM outbound_log
|
|
818
|
+
WHERE account_id=? AND target_id=? AND ok=1 AND created_at >= ?`,
|
|
819
|
+
)
|
|
820
|
+
.get(String(accountId), String(targetId), since).c;
|
|
821
|
+
}
|
|
822
|
+
return this.db
|
|
823
|
+
.prepare(
|
|
824
|
+
`SELECT COUNT(*) AS c FROM outbound_log WHERE account_id=? AND ok=1 AND created_at >= ?`,
|
|
825
|
+
)
|
|
826
|
+
.get(String(accountId), since).c;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
recentOutboundDuplicate(accountId, textSha, withinMinutes = 120) {
|
|
830
|
+
const since = new Date(Date.now() - withinMinutes * 60 * 1000).toISOString();
|
|
831
|
+
const row = this.db
|
|
832
|
+
.prepare(
|
|
833
|
+
`SELECT id FROM outbound_log
|
|
834
|
+
WHERE account_id=? AND text_sha256=? AND ok=1 AND created_at >= ? LIMIT 1`,
|
|
835
|
+
)
|
|
836
|
+
.get(String(accountId), String(textSha), since);
|
|
837
|
+
return Boolean(row);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
getCooldown(accountId, alertKey) {
|
|
841
|
+
return (
|
|
842
|
+
this.db
|
|
843
|
+
.prepare(`SELECT * FROM alert_cooldowns WHERE account_id=? AND alert_key=?`)
|
|
844
|
+
.get(String(accountId), String(alertKey)) || null
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
setCooldown(accountId, alertKey) {
|
|
849
|
+
this.db
|
|
850
|
+
.prepare(
|
|
851
|
+
`INSERT INTO alert_cooldowns(account_id, alert_key, last_sent_at) VALUES(?,?,?)
|
|
852
|
+
ON CONFLICT(account_id, alert_key) DO UPDATE SET last_sent_at=excluded.last_sent_at`,
|
|
853
|
+
)
|
|
854
|
+
.run(String(accountId), String(alertKey), utcNow());
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
audit({ accountId = "", actorId = "", action, detail = "" }) {
|
|
858
|
+
this.db
|
|
859
|
+
.prepare(
|
|
860
|
+
`INSERT INTO audit_log(account_id, actor_id, action, detail, created_at) VALUES(?,?,?,?,?)`,
|
|
861
|
+
)
|
|
862
|
+
.run(String(accountId), String(actorId), String(action), String(detail).slice(0, 1000), utcNow());
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
recentAudit(limit = 50) {
|
|
866
|
+
return this.db
|
|
867
|
+
.prepare(`SELECT * FROM audit_log ORDER BY id DESC LIMIT ?`)
|
|
868
|
+
.all(Math.min(Number(limit) || 50, 200));
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
retain(days) {
|
|
872
|
+
const cutoff = new Date(Date.now() - days * 86400000).toISOString();
|
|
873
|
+
const r = this.db.prepare(`DELETE FROM zalo_messages WHERE ingested_at < ?`).run(cutoff);
|
|
874
|
+
return r.changes;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
setHealth(key, value) {
|
|
878
|
+
this.db
|
|
879
|
+
.prepare(
|
|
880
|
+
`INSERT INTO health(key, value) VALUES(?, ?)
|
|
881
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
|
882
|
+
)
|
|
883
|
+
.run(String(key), typeof value === "string" ? value : JSON.stringify(value));
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
getHealth(key) {
|
|
887
|
+
const row = this.db.prepare(`SELECT value FROM health WHERE key = ?`).get(String(key));
|
|
888
|
+
return row ? row.value : null;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
snapshot() {
|
|
892
|
+
return {
|
|
893
|
+
accounts: this.listAccounts().map((a) => ({
|
|
894
|
+
account_id: a.account_id,
|
|
895
|
+
status: a.status,
|
|
896
|
+
display_name: a.display_name,
|
|
897
|
+
has_session: this.hasSession(a.account_id),
|
|
898
|
+
paused: a.paused,
|
|
899
|
+
updated_at: a.updated_at,
|
|
900
|
+
})),
|
|
901
|
+
source_count: this.listSources().length,
|
|
902
|
+
event_count: this.countEvents(),
|
|
903
|
+
user_count: this.countUsers(),
|
|
904
|
+
member_links: this.countSourceMembers(),
|
|
905
|
+
global_paused: this.isGlobalPaused(),
|
|
906
|
+
db_path: this.dbPath,
|
|
907
|
+
sessions_dir: this.sessionsDir,
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
seedFromConfig(config) {
|
|
912
|
+
this.ensureAccount(config.default_account_id);
|
|
913
|
+
if (config.phone_label) {
|
|
914
|
+
this.setSetting("phone_label", config.phone_label);
|
|
915
|
+
}
|
|
916
|
+
const cur = this.getDestination(config.default_account_id);
|
|
917
|
+
if (config.destination?.group_id && !cur.group_id) {
|
|
918
|
+
this.setDestination(
|
|
919
|
+
config.destination.account_id || config.default_account_id,
|
|
920
|
+
config.destination.group_id,
|
|
921
|
+
config.destination.group_name || "",
|
|
922
|
+
);
|
|
923
|
+
} else if (!cur.group_id && (config.destination?.group_name || process.env.DESTINATION_GROUP_NAME)) {
|
|
924
|
+
// store name placeholder; group_id filled after QR resolve
|
|
925
|
+
this.setDestination(
|
|
926
|
+
config.destination?.account_id || config.default_account_id,
|
|
927
|
+
"",
|
|
928
|
+
config.destination?.group_name || process.env.DESTINATION_GROUP_NAME || "",
|
|
929
|
+
);
|
|
930
|
+
} else if (cur.group_id && config.destination?.group_name && !cur.group_name) {
|
|
931
|
+
this.setDestination(config.default_account_id, cur.group_id, config.destination.group_name);
|
|
932
|
+
}
|
|
933
|
+
for (const s of config.sources || []) {
|
|
934
|
+
if (!this.getSource(s.account_id, s.source_id)) {
|
|
935
|
+
this.upsertSource({
|
|
936
|
+
accountId: s.account_id,
|
|
937
|
+
sourceId: s.source_id,
|
|
938
|
+
sourceType: s.source_type,
|
|
939
|
+
sourceName: s.source_name,
|
|
940
|
+
mode: s.mode,
|
|
941
|
+
isAllowed: s.mode !== "off",
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
for (const role of ["owner", "admin", "operator", "viewer"]) {
|
|
946
|
+
for (const uid of config.roles?.[role] || []) {
|
|
947
|
+
if (!this.getPermission(config.default_account_id, uid)) {
|
|
948
|
+
this.upsertPermission({
|
|
949
|
+
accountId: config.default_account_id,
|
|
950
|
+
userId: uid,
|
|
951
|
+
role,
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
const envOwners = String(process.env.OWNER_USER_IDS || "")
|
|
957
|
+
.split(",")
|
|
958
|
+
.map((s) => s.trim())
|
|
959
|
+
.filter(Boolean);
|
|
960
|
+
for (const uid of envOwners) {
|
|
961
|
+
this.upsertPermission({
|
|
962
|
+
accountId: config.default_account_id,
|
|
963
|
+
userId: uid,
|
|
964
|
+
role: "owner",
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
close() {
|
|
970
|
+
this.db.close();
|
|
971
|
+
}
|
|
972
|
+
}
|