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/server.js ADDED
@@ -0,0 +1,604 @@
1
+ // Express dashboard + control API. Binds localhost by default.
2
+ import express from "express";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { redactSecrets } from "./schema.js";
6
+ import { sendDigest } from "./digest.js";
7
+ import { isValidMode } from "./policy.js";
8
+ import { loadBotRegistry } from "./bot_registry.js";
9
+ import { createOaWebhookHandler } from "./oa_webhook.js";
10
+ import { createOaAutoReplyWorker } from "./oa_auto_reply.js";
11
+ import { publicBrandMetadata } from "./brand.js";
12
+
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+
15
+ function auth(req, res, next) {
16
+ const expected = process.env.DASHBOARD_TOKEN || "";
17
+ if (!expected || expected === "change-me") {
18
+ return next();
19
+ }
20
+ const header = req.get("x-bridge-token") || "";
21
+ if (header === expected) return next();
22
+ return res.status(401).json({ error: "unauthorized" });
23
+ }
24
+
25
+ export function createApp({
26
+ config,
27
+ store,
28
+ policy,
29
+ hub,
30
+ keepalive = null,
31
+ oaRegistry = null,
32
+ oaWebhookSecret = process.env.ZALO_OA_WEBHOOK_SECRET || "",
33
+ }) {
34
+ const app = express();
35
+ app.disable("x-powered-by");
36
+ app.use(
37
+ express.json({
38
+ limit: "256kb",
39
+ verify: (req, _res, buffer) => {
40
+ req.rawBody = Buffer.from(buffer);
41
+ },
42
+ }),
43
+ );
44
+
45
+ // The official OA ingress is intentionally registered before dashboard auth:
46
+ // provider webhooks authenticate with their own signature, not a dashboard
47
+ // token. The handler only validates/normalizes/acknowledges; it never calls
48
+ // an AI provider or sends a reply.
49
+ const resolvedOaRegistry = oaRegistry ?? loadBotRegistry(
50
+ process.env.BOTS_FILE || path.join(__dirname, "..", "config", "bots.json"),
51
+ );
52
+ // Replies can be initiated only from this signed OA webhook callback. The
53
+ // worker remains fail-closed unless a bot explicitly opts into
54
+ // `inbound_auto_reply` and carries the server-only bridge token.
55
+ const oaAutoReply = createOaAutoReplyWorker({ registry: resolvedOaRegistry });
56
+ const oaWebhook = createOaWebhookHandler({
57
+ registry: resolvedOaRegistry,
58
+ webhookSecret: oaWebhookSecret,
59
+ onEvent: (event) => oaAutoReply.handle(event),
60
+ });
61
+ app.post("/webhooks/zalo/oa/:bot_id", oaWebhook);
62
+
63
+ // Public, non-sensitive product metadata. Runtime/account state remains behind
64
+ // the normal dashboard auth boundary below.
65
+ app.get("/api/brand", (_req, res) => {
66
+ res.json({ ok: true, brand: publicBrandMetadata() });
67
+ });
68
+
69
+ // The QR onboarding shell is intentionally public and contains no QR/session
70
+ // material. The QR/status APIs below remain behind dashboard auth, so a VPS
71
+ // operator can open this page first and enter the token in the browser.
72
+ app.get(["/connect", "/connect/"], (_req, res) => {
73
+ res.sendFile(path.join(__dirname, "..", "public", "connect.html"));
74
+ });
75
+
76
+ app.get("/healthz", (_req, res) => {
77
+ const accountId = config.default_account_id;
78
+ const acc = store.getAccount(accountId);
79
+ const runtime = hub.getRuntime(accountId);
80
+ const listener = Boolean(runtime.api?.listener);
81
+ const connected = acc?.status === "connected" && Boolean(runtime.api);
82
+ res.json({
83
+ ok: true,
84
+ brand: publicBrandMetadata(),
85
+ service: "hermes-zalo-personal-bridge",
86
+ status: acc?.status || "unknown",
87
+ connected,
88
+ listener,
89
+ keepalive: Boolean(keepalive?.started),
90
+ ts: new Date().toISOString(),
91
+ });
92
+ });
93
+
94
+ app.get("/api/health", (_req, res) => {
95
+ const accountId = config.default_account_id;
96
+ const acc = store.getAccount(accountId);
97
+ const dest = store.getDestination(accountId);
98
+ const runtime = hub.getRuntime(accountId);
99
+ const snap = keepalive?.snapshot?.() || null;
100
+ const lastMsg =
101
+ store.getHealth(`last_message_at_${accountId}`) || runtime.lastMessageAt || null;
102
+ const lastListenerErr = store.getHealth(`last_listener_error_${accountId}`);
103
+ const lastTick = store.getHealth(`keepalive_tick_${accountId}`);
104
+ res.json({
105
+ ok: true,
106
+ brand: publicBrandMetadata(),
107
+ account: {
108
+ id: accountId,
109
+ status: acc?.status,
110
+ display_name: acc?.display_name,
111
+ has_session: store.hasSession(accountId),
112
+ last_error: acc?.last_error || "",
113
+ },
114
+ destination: dest,
115
+ listener: Boolean(runtime.api?.listener),
116
+ last_message_at: lastMsg,
117
+ last_listener_error: lastListenerErr,
118
+ keepalive: snap,
119
+ last_tick: lastTick,
120
+ corpus: {
121
+ messages: store.countEvents(accountId),
122
+ users: store.countUsers(accountId),
123
+ sources: store.listSources(accountId).length,
124
+ },
125
+ safety: policy.safetyFlags(),
126
+ });
127
+ });
128
+
129
+ app.use(auth);
130
+
131
+ app.get("/api/status", (_req, res) => {
132
+ const accountId = config.default_account_id;
133
+ const acc = store.getAccount(accountId);
134
+ let bootstrap = null;
135
+ try {
136
+ bootstrap = JSON.parse(store.getHealth(`bootstrap_${accountId}`) || "null");
137
+ } catch {
138
+ bootstrap = store.getHealth(`bootstrap_${accountId}`);
139
+ }
140
+ res.json(
141
+ redactSecrets({
142
+ brand: publicBrandMetadata(),
143
+ accounts: hub.listStatus(),
144
+ phone_label: store.getSetting("phone_label", config.phone_label || ""),
145
+ owner_user_id: acc?.zalo_user_id || "",
146
+ destination: store.getDestination(accountId),
147
+ sources: store.listSources(accountId),
148
+ source_count: store.listSources(accountId).length,
149
+ event_count: store.countEvents(),
150
+ global_paused: store.isGlobalPaused(),
151
+ paused: store.isGlobalPaused(),
152
+ safety: policy.safetyFlags(),
153
+ listen_all_groups: Boolean(config.listen_all_groups),
154
+ auto_alert: Boolean(config.auto_alert),
155
+ permissions: store.listPermissions(accountId).map((p) => ({
156
+ user_id: p.user_id,
157
+ role: p.role,
158
+ display_name: p.display_name,
159
+ })),
160
+ bootstrap,
161
+ token_is_default: !process.env.DASHBOARD_TOKEN || process.env.DASHBOARD_TOKEN === "change-me",
162
+ snapshot: store.snapshot(),
163
+ }),
164
+ );
165
+ });
166
+
167
+ app.get("/api/discovery", (req, res) => {
168
+ const accountId = req.query.account_id || config.default_account_id;
169
+ const groups = store.getSetting(`discovered_groups_${accountId}`, []) || [];
170
+ const acc = store.getAccount(accountId);
171
+ res.json({
172
+ ok: true,
173
+ account_id: accountId,
174
+ owner_user_id: acc?.zalo_user_id || "",
175
+ destination: store.getDestination(accountId),
176
+ groups,
177
+ sources: store.listSources(accountId),
178
+ });
179
+ });
180
+
181
+ app.post("/api/discovery/refresh", async (req, res) => {
182
+ try {
183
+ const accountId = req.body?.account_id || config.default_account_id;
184
+ const runtime = hub.getRuntime(accountId);
185
+ if (!runtime.api) {
186
+ return res.status(400).json({ ok: false, error: "not_connected" });
187
+ }
188
+ const { bootstrapAccount } = await import("./discovery.js");
189
+ const dest = store.getDestination(accountId);
190
+ const result = await bootstrapAccount({
191
+ api: runtime.api,
192
+ store,
193
+ accountId,
194
+ destinationName: dest.group_name || process.env.DESTINATION_GROUP_NAME || "configured destination",
195
+ });
196
+ res.json({ ok: true, result });
197
+ } catch (err) {
198
+ res.status(500).json({ ok: false, error: String(err?.message || err) });
199
+ }
200
+ });
201
+
202
+ app.post("/api/corpus/backfill", async (req, res) => {
203
+ try {
204
+ const accountId = req.body?.account_id || config.default_account_id;
205
+ const runtime = hub.getRuntime(accountId);
206
+ if (!runtime.api) return res.status(400).json({ ok: false, error: "not_connected" });
207
+ const { backfillAccountCorpus } = await import("./backfill.js");
208
+ // respond async-ish but await for evidence in MVP (can take minutes)
209
+ const result = await backfillAccountCorpus({
210
+ api: runtime.api,
211
+ store,
212
+ accountId,
213
+ historyCount: Number(req.body?.history_count || 50),
214
+ maxGroups: Number(req.body?.max_groups || 200),
215
+ delayMs: Number(req.body?.delay_ms || 250),
216
+ groupIds: Array.isArray(req.body?.group_ids) ? req.body.group_ids : null,
217
+ });
218
+ res.json({
219
+ ok: true,
220
+ result: {
221
+ ...result,
222
+ errors: (result.errors || []).slice(0, 30),
223
+ },
224
+ corpus: {
225
+ messages: store.countEvents(accountId),
226
+ users: store.countUsers(accountId),
227
+ member_links: store.countSourceMembers(accountId),
228
+ sources: store.listSources(accountId).length,
229
+ },
230
+ });
231
+ } catch (err) {
232
+ res.status(500).json({ ok: false, error: String(err?.message || err) });
233
+ }
234
+ });
235
+
236
+ app.get("/api/corpus/summary", (req, res) => {
237
+ const accountId = req.query.account_id || config.default_account_id;
238
+ let last = null;
239
+ try {
240
+ last = JSON.parse(store.getHealth(`backfill_${accountId}`) || "null");
241
+ } catch {
242
+ last = store.getHealth(`backfill_${accountId}`);
243
+ }
244
+ res.json({
245
+ ok: true,
246
+ account_id: accountId,
247
+ messages: store.countEvents(accountId),
248
+ users: store.countUsers(accountId),
249
+ member_links: store.countSourceMembers(accountId),
250
+ sources: store.listSources(accountId).length,
251
+ last_backfill: last,
252
+ });
253
+ });
254
+
255
+ app.get("/api/users", (req, res) => {
256
+ const accountId = req.query.account_id || config.default_account_id;
257
+ res.json({
258
+ ok: true,
259
+ users: store.listUsers(accountId, Number(req.query.limit || 200)),
260
+ });
261
+ });
262
+
263
+ app.get("/api/sources/:sourceId/members", (req, res) => {
264
+ const accountId = req.query.account_id || config.default_account_id;
265
+ res.json({
266
+ ok: true,
267
+ source_id: req.params.sourceId,
268
+ members: store.listSourceMembers(accountId, req.params.sourceId, Number(req.query.limit || 500)),
269
+ });
270
+ });
271
+
272
+ app.post("/api/accounts/:accountId/connect", async (req, res) => {
273
+ try {
274
+ const runtime = hub.getRuntime(req.params.accountId);
275
+ const forceQr = Boolean(req.body?.force_qr);
276
+ const connectPromise = runtime.connect({ forceQr });
277
+ const raced = await Promise.race([
278
+ connectPromise.then((s) => ({ done: true, status: s })),
279
+ new Promise((resolve) => setTimeout(() => resolve({ done: false }), 1500)),
280
+ ]);
281
+ res.json({
282
+ ok: true,
283
+ connecting: !raced.done,
284
+ status: runtime.status(),
285
+ qr: runtime.getQr(),
286
+ });
287
+ } catch (err) {
288
+ res.status(500).json({ ok: false, error: String(err?.message || err) });
289
+ }
290
+ });
291
+
292
+ app.get("/api/accounts/:accountId/qr", (req, res) => {
293
+ const runtime = hub.getRuntime(req.params.accountId);
294
+ const qr = runtime.getQr();
295
+ if (!qr) return res.status(404).json({ ok: false, error: "no_qr" });
296
+ res.json({ ok: true, qr: { code: qr.code, updated_at: qr.updated_at, image: qr.image } });
297
+ });
298
+
299
+ app.post("/api/accounts/:accountId/pause", async (req, res) => {
300
+ const runtime = hub.getRuntime(req.params.accountId);
301
+ store.setGlobalPaused(true);
302
+ res.json({ ok: true, status: await runtime.pause(), global_paused: true });
303
+ });
304
+
305
+ app.post("/api/accounts/:accountId/resume", async (req, res) => {
306
+ store.setGlobalPaused(false);
307
+ store.setAccountStatus(req.params.accountId, "connected");
308
+ res.json({ ok: true, global_paused: false, status: hub.getRuntime(req.params.accountId).status() });
309
+ });
310
+
311
+ app.post("/api/accounts/:accountId/disconnect", async (req, res) => {
312
+ const runtime = hub.getRuntime(req.params.accountId);
313
+ const wipe = Boolean(req.body?.wipe_session);
314
+ res.json({ ok: true, status: await runtime.disconnect({ wipeSession: wipe }) });
315
+ });
316
+
317
+ app.get("/api/events", (req, res) => {
318
+ const accountId = req.query.account_id || null;
319
+ const sourceId = req.query.source_id || null;
320
+ const limit = Number(req.query.limit || 50);
321
+ const rows = store.recentEvents({ accountId, sourceId, limit });
322
+ res.json({
323
+ ok: true,
324
+ events: rows.map((r) => ({
325
+ ...r,
326
+ text: String(r.text || "").slice(0, 500),
327
+ })),
328
+ });
329
+ });
330
+
331
+ app.get("/api/sources", (req, res) => {
332
+ const accountId = req.query.account_id || config.default_account_id;
333
+ res.json({
334
+ ok: true,
335
+ sources: store.listSources(accountId),
336
+ destination: store.getDestination(accountId),
337
+ });
338
+ });
339
+
340
+ app.post("/api/sources", (req, res) => {
341
+ const accountId = req.body?.account_id || config.default_account_id;
342
+ const sourceId = req.body?.source_id;
343
+ if (!sourceId) return res.status(400).json({ ok: false, error: "source_id_required" });
344
+ const mode = req.body?.mode || "listen_only";
345
+ if (!isValidMode(mode)) return res.status(400).json({ ok: false, error: "bad_mode" });
346
+ const row = store.upsertSource({
347
+ accountId,
348
+ sourceId,
349
+ sourceType: req.body?.source_type || "group",
350
+ sourceName: req.body?.source_name || "",
351
+ mode,
352
+ isAllowed: mode !== "off",
353
+ muted: Boolean(req.body?.muted),
354
+ });
355
+ store.audit({ accountId, actorId: "dashboard", action: "upsert_source", detail: `${sourceId}:${mode}` });
356
+ res.json({ ok: true, source: row });
357
+ });
358
+
359
+ app.post("/api/sources/:sourceId/mode", (req, res) => {
360
+ const accountId = req.body?.account_id || config.default_account_id;
361
+ const mode = req.body?.mode;
362
+ if (!isValidMode(mode)) return res.status(400).json({ ok: false, error: "bad_mode" });
363
+ const row = store.setSourceMode(accountId, req.params.sourceId, mode);
364
+ res.json({ ok: true, source: row });
365
+ });
366
+
367
+ app.post("/api/sources/:sourceId/mute", (req, res) => {
368
+ const accountId = req.body?.account_id || config.default_account_id;
369
+ const muted = req.body?.muted !== false;
370
+ const row = store.muteSource(accountId, req.params.sourceId, muted);
371
+ res.json({ ok: true, source: row });
372
+ });
373
+
374
+ app.post("/api/destination", (req, res) => {
375
+ const accountId = req.body?.account_id || config.default_account_id;
376
+ const groupId = req.body?.group_id || "";
377
+ const groupName = req.body?.group_name || "";
378
+ const dest = store.setDestination(accountId, groupId, groupName);
379
+ store.audit({ accountId, actorId: "dashboard", action: "set_destination", detail: groupId });
380
+ res.json({ ok: true, destination: dest });
381
+ });
382
+
383
+ app.get("/api/permissions", (req, res) => {
384
+ const accountId = req.query.account_id || config.default_account_id;
385
+ res.json({ ok: true, permissions: store.listPermissions(accountId) });
386
+ });
387
+
388
+ app.post("/api/permissions", (req, res) => {
389
+ const accountId = req.body?.account_id || config.default_account_id;
390
+ const userId = req.body?.user_id;
391
+ const role = req.body?.role;
392
+ if (!userId || !role) return res.status(400).json({ ok: false, error: "user_id_and_role_required" });
393
+ if (!["owner", "admin", "operator", "viewer"].includes(role)) {
394
+ return res.status(400).json({ ok: false, error: "bad_role" });
395
+ }
396
+ const row = store.upsertPermission({
397
+ accountId,
398
+ userId,
399
+ role,
400
+ displayName: req.body?.display_name || "",
401
+ });
402
+ store.audit({ accountId, actorId: "dashboard", action: "upsert_permission", detail: `${userId}:${role}` });
403
+ res.json({ ok: true, permission: row });
404
+ });
405
+
406
+ app.delete("/api/permissions/:userId", (req, res) => {
407
+ const accountId = req.query.account_id || config.default_account_id;
408
+ store.removePermission(accountId, req.params.userId);
409
+ res.json({ ok: true });
410
+ });
411
+
412
+ app.post("/api/kill-switch", (req, res) => {
413
+ const paused = Boolean(req.body?.paused);
414
+ store.setGlobalPaused(paused);
415
+ store.audit({ actorId: "dashboard", action: paused ? "kill_on" : "kill_off", detail: "" });
416
+ res.json({ ok: true, global_paused: store.isGlobalPaused() });
417
+ });
418
+
419
+ app.get("/api/reports", (req, res) => {
420
+ const accountId = req.query.account_id || null;
421
+ res.json({ ok: true, reports: store.listReports({ accountId, limit: Number(req.query.limit || 20) }) });
422
+ });
423
+
424
+ app.get("/api/audit", (req, res) => {
425
+ res.json({ ok: true, audit: store.recentAudit(Number(req.query.limit || 50)) });
426
+ });
427
+
428
+ app.post("/api/digest/run", async (req, res) => {
429
+ try {
430
+ if (config.listener_only !== false) {
431
+ return res.status(403).json({ ok: false, reason: "outbound_disabled", skipped_before_analysis: true });
432
+ }
433
+ const accountId = req.body?.account_id || config.default_account_id;
434
+ const hours = Number(req.body?.hours || 24);
435
+ const result = await sendDigest({ config, store, policy, hub, accountId, hours, reportType: "manual" });
436
+ res.json(result);
437
+ } catch (err) {
438
+ res.status(500).json({ ok: false, error: String(err?.message || err) });
439
+ }
440
+ });
441
+
442
+ // Operator: send ONE preformatted manager report to configured destination only.
443
+ // Does not rewrite text via Hermes brain (verbatim). Policy Guard still destination-only.
444
+ app.post("/api/report/send", async (req, res) => {
445
+ try {
446
+ if (config.listener_only !== false) {
447
+ return res.status(403).json({ ok: false, reason: "outbound_disabled" });
448
+ }
449
+ const { sha256 } = await import("./schema.js");
450
+ const { scrubTechJargon } = await import("./ops_report.js");
451
+ const accountId = req.body?.account_id || config.default_account_id;
452
+ let text = scrubTechJargon(String(req.body?.text || req.body?.message || "").trim()).slice(0, 3200);
453
+ if (!text || text.length < 20) {
454
+ return res.status(400).json({ ok: false, error: "text_required" });
455
+ }
456
+ const dest = store.getDestination(accountId);
457
+ if (!dest.group_id) {
458
+ return res.status(400).json({ ok: false, error: "destination_unset" });
459
+ }
460
+ const decision = policy.evaluateOutbound({
461
+ accountId,
462
+ targetId: dest.group_id,
463
+ text,
464
+ kind: "report",
465
+ });
466
+ if (!decision.allow) {
467
+ store.logOutbound({
468
+ accountId,
469
+ targetId: dest.group_id,
470
+ kind: "report",
471
+ textSha: sha256(text),
472
+ ok: false,
473
+ reason: decision.reason,
474
+ });
475
+ return res.status(403).json({ ok: false, reason: decision.reason, destination: dest });
476
+ }
477
+ const runtime = hub.getRuntime(accountId);
478
+ if (!runtime?.api?.sendMessage) {
479
+ return res.status(503).json({ ok: false, error: "not_connected", destination: dest });
480
+ }
481
+ await runtime.sendText(dest.group_id, text, 1);
482
+ store.logOutbound({
483
+ accountId,
484
+ targetId: dest.group_id,
485
+ kind: "report",
486
+ textSha: decision.textSha || sha256(text),
487
+ ok: true,
488
+ reason: "sent",
489
+ });
490
+ store.audit({
491
+ accountId,
492
+ actorId: "api_report_send",
493
+ action: "report_sent",
494
+ detail: text.slice(0, 120),
495
+ });
496
+ res.json({ ok: true, reason: "sent", chars: text.length, destination: dest });
497
+ } catch (err) {
498
+ res.status(500).json({ ok: false, error: String(err?.message || err) });
499
+ }
500
+ });
501
+
502
+ // On-demand ask → analyze corpus → ONE message to configured destination only.
503
+ app.post("/api/ask", async (req, res) => {
504
+ try {
505
+ if (config.listener_only !== false) {
506
+ return res.status(403).json({ ok: false, reason: "outbound_disabled", skipped_before_analysis: true });
507
+ }
508
+ const accountId = req.body?.account_id || config.default_account_id;
509
+ const question = String(req.body?.question || req.body?.text || "").trim();
510
+ const hours = Number(req.body?.hours || 24);
511
+ if (!question) return res.status(400).json({ ok: false, error: "question_required" });
512
+
513
+ const dest = store.getDestination(accountId);
514
+ if (!dest.group_id) {
515
+ return res.status(400).json({ ok: false, error: "destination_unset" });
516
+ }
517
+
518
+ const acc = store.getAccount(accountId);
519
+ const senderId = String(
520
+ req.body?.sender_id || acc?.zalo_user_id || process.env.OWNER_USER_IDS?.split(",")[0] || "owner",
521
+ );
522
+
523
+ const { handleDestinationAsk, isBotCommand } = await import("./ask.js");
524
+ // API/MCP is owner-initiated; normalize to Phase-1 bot prefix.
525
+ const cmdText = isBotCommand(question) ? question : `bot ${question}`;
526
+ const event = {
527
+ event_id: `api-ask-${Date.now()}`,
528
+ account_id: accountId,
529
+ source_type: "group",
530
+ source_id: dest.group_id,
531
+ source_name: dest.group_name || "configured destination",
532
+ sender_id: senderId,
533
+ sender_name: acc?.display_name || "owner",
534
+ message_id: `api-ask-${Date.now()}`,
535
+ message_type: "text",
536
+ text: cmdText,
537
+ is_self: Boolean(acc?.zalo_user_id && String(acc.zalo_user_id) === senderId),
538
+ is_mention: false,
539
+ raw_metadata: { via: "api_ask" },
540
+ created_at: new Date().toISOString(),
541
+ };
542
+
543
+ const result = await handleDestinationAsk({
544
+ event,
545
+ store,
546
+ policy,
547
+ hub,
548
+ config,
549
+ hours,
550
+ });
551
+ res.json({ ok: Boolean(result?.ok), ...result, destination: dest });
552
+ } catch (err) {
553
+ res.status(500).json({ ok: false, error: String(err?.message || err) });
554
+ }
555
+ });
556
+
557
+ app.get("/api/battle-ready", async (_req, res) => {
558
+ const accountId = config.default_account_id;
559
+ const acc = store.getAccount(accountId);
560
+ const dest = store.getDestination(accountId);
561
+ const safety = policy.safetyFlags();
562
+ const runtime = hub.getRuntime(accountId);
563
+ const ka = keepalive?.snapshot?.() || null;
564
+ res.json({
565
+ ok: true,
566
+ brand: publicBrandMetadata(),
567
+ battle_ready: Boolean(
568
+ acc?.status === "connected" &&
569
+ dest.group_id &&
570
+ safety.READ_ONLY_SOURCE &&
571
+ safety.auto_reply_disabled &&
572
+ runtime.api,
573
+ ),
574
+ account: {
575
+ id: accountId,
576
+ status: acc?.status,
577
+ display_name: acc?.display_name,
578
+ zalo_user_id: acc?.zalo_user_id,
579
+ has_session: store.hasSession(accountId),
580
+ },
581
+ destination: dest,
582
+ safety,
583
+ corpus: {
584
+ messages: store.countEvents(accountId),
585
+ users: store.countUsers(accountId),
586
+ member_links: store.countSourceMembers(accountId),
587
+ sources: store.listSources(accountId).length,
588
+ },
589
+ listener: Boolean(runtime.api?.listener),
590
+ keepalive: ka,
591
+ });
592
+ });
593
+
594
+ app.use(express.static(path.join(__dirname, "..", "public")));
595
+
596
+ app.use((err, _req, res, _next) => {
597
+ if (err?.type === "entity.parse.failed") {
598
+ return res.status(400).json({ ok: false, error: "invalid_json" });
599
+ }
600
+ return res.status(500).json({ ok: false, error: "internal" });
601
+ });
602
+
603
+ return app;
604
+ }