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,295 @@
1
+ // Zalo inbound router — bridge is the channel, Hermes is the brain.
2
+ // Phase 1 (hard): configured destination + authorized operator only + prefix "bot" + no DM/global.
3
+
4
+ import { buildOpsReport, scrubTechJargon } from "./ops_report.js";
5
+ import { callHermesBrain } from "./hermes_client.js";
6
+ import { sanitizeForHermes } from "./privacy.js";
7
+ import { sha256, utcNow } from "./schema.js";
8
+ import { recordZaloBrainTurn } from "./abs_telemetry.js";
9
+
10
+ const BOT_PREFIX_RE = /^(?:bot|@bot)\s+/i;
11
+
12
+ export function parseBotCommand(text) {
13
+ const raw = String(text || "").trim();
14
+ if (!raw) return { ok: false, reason: "empty" };
15
+ const m = raw.match(BOT_PREFIX_RE);
16
+ if (!m) return { ok: false, reason: "missing_bot_prefix" };
17
+ const body = raw.slice(m[0].length).trim();
18
+ if (!body) return { ok: false, reason: "empty_command" };
19
+ return { ok: true, prefix: m[0].trim(), body, raw };
20
+ }
21
+
22
+ export function isBotCommand(text) {
23
+ return parseBotCommand(text).ok;
24
+ }
25
+
26
+ function collectStats(store, accountId, events) {
27
+ return {
28
+ groups: store.listSources(accountId).length,
29
+ users: store.countUsers(accountId),
30
+ member_links: store.countSourceMembers(accountId),
31
+ messages_total: store.countEvents(accountId),
32
+ recent_source_msgs: events.length,
33
+ };
34
+ }
35
+
36
+ function buildFallbackReport({ body, stats, events, hours }) {
37
+ const windowLabel = hours === 24 ? "24 giờ" : `${hours} giờ`;
38
+ return buildOpsReport({
39
+ title: "Tổng hợp vận hành — destination đã cấu hình",
40
+ windowLabel,
41
+ stats,
42
+ recentEvents: events,
43
+ autoReplyEnabled: false,
44
+ listening: true,
45
+ note: body ? `Lệnh: ${String(body).slice(0, 120)}` : "",
46
+ });
47
+ }
48
+
49
+ function buildBrainPrompt({ body, stats, events, hours, senderName }) {
50
+ const samples = events
51
+ .slice(0, 20)
52
+ .map((e) => {
53
+ const who = e.sender_name || "ai đó";
54
+ const src = e.source_name || e.source_id || "nhóm";
55
+ const t = String(e.text || "").replace(/\s+/g, " ").trim().slice(0, 160);
56
+ return t ? `- [${src}] ${who}: ${t}` : null;
57
+ })
58
+ .filter(Boolean)
59
+ .join("\n");
60
+
61
+ return [
62
+ "Bạn là Hermes Agent — não xử lý kênh Zalo cá nhân (channel adapter, không phải bot public).",
63
+ "Viết ĐÚNG 1 tin điều hành tiếng Việt gửi vào destination đã cấu hình.",
64
+ "Format bắt buộc:",
65
+ "Tổng hợp vận hành — destination đã cấu hình",
66
+ "",
67
+ "Trạng thái:",
68
+ "...",
69
+ "",
70
+ "Số liệu hiện có:",
71
+ "...",
72
+ "",
73
+ "Hoạt động gần đây:",
74
+ "...",
75
+ "",
76
+ "Nhận xét:",
77
+ "...",
78
+ "",
79
+ "Việc tiếp theo:",
80
+ "...",
81
+ "",
82
+ "Quy tắc cứng:",
83
+ "- Ngắn, rõ, người quản lý đọc hiểu ngay.",
84
+ "- Không dùng từ kỹ thuật: corpus, digest, ask, outbound, READ_ONLY, bridge, MCP, webhook, event_id.",
85
+ "- Không bịa khi thiếu dữ liệu. Dữ liệu mỏng thì nói mức hệ thống/dữ liệu, không tổng hợp vận hành sâu.",
86
+ "- Không DM, không nhắn group khác, không kêu gọi spam.",
87
+ "- Trả lời thuần text, không markdown code fence.",
88
+ "",
89
+ `Người ra lệnh: ${senderName || "owner"}`,
90
+ `Lệnh: ${body}`,
91
+ `Cửa sổ: ${hours} giờ`,
92
+ `Số liệu: ${stats.groups} nhóm, ${stats.users} người dùng, ${stats.member_links} liên kết, ${stats.messages_total} tin đã lưu, ${stats.recent_source_msgs} tin nguồn gần đây`,
93
+ samples ? `Tin nguồn gần đây:\n${samples}` : "Tin nguồn gần đây: chưa có",
94
+ ].join("\n");
95
+ }
96
+
97
+ /**
98
+ * Phase-1 destination brain path:
99
+ * inbound already policy-approved → Hermes brain → outbound guard → send destination only.
100
+ */
101
+ export async function handleBotBrainCommand({
102
+ event,
103
+ store,
104
+ policy,
105
+ hub,
106
+ config,
107
+ hours = 24,
108
+ fetchImpl,
109
+ }) {
110
+ const accountId = event.account_id;
111
+ const dest = store.getDestination(accountId);
112
+ if (!dest.group_id || String(event.source_id) !== String(dest.group_id)) {
113
+ return { ok: false, reason: "not_destination", silent: true };
114
+ }
115
+
116
+ const parsed = parseBotCommand(event.text);
117
+ if (!parsed.ok) {
118
+ return { ok: false, reason: parsed.reason, silent: true };
119
+ }
120
+
121
+ // Authorized: role OR same logged-in Zalo account.
122
+ const role = policy.roleOf(accountId, event.sender_id);
123
+ const account = store.getAccount(accountId);
124
+ const isBridgeSelf =
125
+ event.is_self ||
126
+ (account?.zalo_user_id && String(account.zalo_user_id) === String(event.sender_id));
127
+ if (!role && !isBridgeSelf) {
128
+ store.audit({
129
+ accountId,
130
+ actorId: event.sender_id,
131
+ action: "bot_cmd_denied",
132
+ detail: event.text?.slice(0, 120) || "",
133
+ });
134
+ return { ok: false, reason: "unauthorized_silent", silent: true };
135
+ }
136
+
137
+ const since = new Date(Date.now() - hours * 3600 * 1000).toISOString();
138
+ const events = store
139
+ .recentEvents({ accountId, limit: 120, sinceIso: since })
140
+ .filter((e) => e.source_id !== dest.group_id);
141
+ const stats = collectStats(store, accountId, events);
142
+
143
+ store.audit({
144
+ accountId,
145
+ actorId: event.sender_id,
146
+ action: "bot_cmd_inbound",
147
+ detail: JSON.stringify({
148
+ body: parsed.body.slice(0, 160),
149
+ source_id: event.source_id,
150
+ msg_id: event.message_id || event.event_id,
151
+ }),
152
+ });
153
+
154
+ let answer = buildFallbackReport({
155
+ body: parsed.body,
156
+ stats,
157
+ events,
158
+ hours,
159
+ });
160
+ let brainMeta = { used: false, reason: "fallback_local" };
161
+
162
+ const prompt = buildBrainPrompt({
163
+ body: parsed.body,
164
+ stats,
165
+ events,
166
+ hours,
167
+ senderName: event.sender_name,
168
+ });
169
+
170
+ const brainStartedAt = Date.now();
171
+ const brain = await callHermesBrain({
172
+ config,
173
+ prompt,
174
+ fetchImpl,
175
+ maxRetries: 2,
176
+ });
177
+ // Telemetry is local, pseudonymous, and fail-open. It observes an explicit
178
+ // Brain turn only; the adapter extracts only source_id/type, outcome,
179
+ // and duration. It never serializes message text or sender data.
180
+ recordZaloBrainTurn({
181
+ event,
182
+ outcome: brain.ok ? "success" : "non_success",
183
+ durationMs: Date.now() - brainStartedAt,
184
+ });
185
+
186
+ if (brain.ok && brain.text) {
187
+ const safe = sanitizeForHermes(brain.text);
188
+ if (safe.ok) {
189
+ const scrubbed = scrubTechJargon(safe.text);
190
+ // Accept if manager-readable; otherwise keep local fallback.
191
+ if (
192
+ scrubbed.length >= 40 &&
193
+ !/\b(corpus|digest|READ_ONLY|outbound|webhook|event_id)\b/i.test(scrubbed)
194
+ ) {
195
+ answer = scrubbed.slice(0, 3200);
196
+ brainMeta = { used: true, reason: brain.reason || "hermes_brain", attempts: brain.attempts };
197
+ } else {
198
+ brainMeta = { used: false, reason: "brain_format_rejected" };
199
+ }
200
+ } else {
201
+ brainMeta = { used: false, reason: safe.reason || "sanitize_fail" };
202
+ }
203
+ } else {
204
+ brainMeta = { used: false, reason: brain.reason || "brain_fail", manual_review: brain.manual_review };
205
+ }
206
+
207
+ const decision = policy.evaluateOutbound({
208
+ accountId,
209
+ targetId: dest.group_id,
210
+ text: answer,
211
+ kind: "ask_reply",
212
+ });
213
+ if (!decision.allow) {
214
+ store.logOutbound({
215
+ accountId,
216
+ targetId: dest.group_id,
217
+ kind: "ask_reply",
218
+ textSha: sha256(answer),
219
+ ok: false,
220
+ reason: decision.reason,
221
+ });
222
+ store.audit({
223
+ accountId,
224
+ actorId: event.sender_id,
225
+ action: "bot_cmd_outbound_blocked",
226
+ detail: decision.reason,
227
+ });
228
+ return { ok: false, reason: decision.reason, answer, brain: brainMeta };
229
+ }
230
+
231
+ const runtime = hub.getRuntime(accountId);
232
+ if (!runtime.api && process.env.ALLOW_FAKE_SEND !== "true") {
233
+ return { ok: false, reason: "not_connected", answer, dry_run: true, brain: brainMeta };
234
+ }
235
+ if (process.env.ALLOW_FAKE_SEND === "true" && !runtime.api) {
236
+ store.logOutbound({
237
+ accountId,
238
+ targetId: dest.group_id,
239
+ kind: "ask_reply",
240
+ textSha: decision.textSha || sha256(answer),
241
+ ok: true,
242
+ reason: "fake_sent",
243
+ });
244
+ store.audit({
245
+ accountId,
246
+ actorId: event.sender_id,
247
+ action: "bot_cmd_fake_sent",
248
+ detail: parsed.body.slice(0, 120),
249
+ });
250
+ return { ok: true, reason: "fake_sent", answer, brain: brainMeta };
251
+ }
252
+
253
+ try {
254
+ await runtime.sendText(dest.group_id, answer, 1);
255
+ store.logOutbound({
256
+ accountId,
257
+ targetId: dest.group_id,
258
+ kind: "ask_reply",
259
+ textSha: decision.textSha || sha256(answer),
260
+ ok: true,
261
+ reason: "sent",
262
+ });
263
+ store.putReport({
264
+ account_id: accountId,
265
+ destination_source_id: dest.group_id,
266
+ report_type: "bot_cmd",
267
+ period_start: since,
268
+ period_end: utcNow(),
269
+ content: answer,
270
+ sent_status: "sent",
271
+ sent_at: utcNow(),
272
+ });
273
+ store.audit({
274
+ accountId,
275
+ actorId: event.sender_id,
276
+ action: "bot_cmd_sent",
277
+ detail: JSON.stringify({
278
+ body: parsed.body.slice(0, 120),
279
+ brain: brainMeta,
280
+ target: dest.group_id,
281
+ }),
282
+ });
283
+ return { ok: true, reason: "sent", answer, brain: brainMeta };
284
+ } catch (err) {
285
+ store.logOutbound({
286
+ accountId,
287
+ targetId: dest.group_id,
288
+ kind: "ask_reply",
289
+ textSha: decision.textSha || sha256(answer),
290
+ ok: false,
291
+ reason: String(err?.message || err),
292
+ });
293
+ return { ok: false, reason: String(err?.message || err), answer, brain: brainMeta };
294
+ }
295
+ }
@@ -0,0 +1,375 @@
1
+ // 24/7 keepalive: auto-connect on boot, reconnect with backoff, health ticks,
2
+ // disconnect alerts to destination only. No deep-scroll. History = from attach time.
3
+
4
+ import { utcNow } from "./schema.js";
5
+
6
+ const DEFAULTS = {
7
+ tick_ms: 20_000,
8
+ reconnect_base_ms: 5_000,
9
+ reconnect_max_ms: 5 * 60_000,
10
+ stale_message_ms: 0, // 0 = do not treat quiet chat as down
11
+ alert_cooldown_ms: 15 * 60_000,
12
+ auto_connect: true,
13
+ };
14
+
15
+ function num(v, fallback) {
16
+ const n = Number(v);
17
+ return Number.isFinite(n) ? n : fallback;
18
+ }
19
+
20
+ export function loadKeepaliveOptions(env = process.env) {
21
+ return {
22
+ tick_ms: num(env.KEEPALIVE_TICK_MS, DEFAULTS.tick_ms),
23
+ reconnect_base_ms: num(env.KEEPALIVE_RECONNECT_BASE_MS, DEFAULTS.reconnect_base_ms),
24
+ reconnect_max_ms: num(env.KEEPALIVE_RECONNECT_MAX_MS, DEFAULTS.reconnect_max_ms),
25
+ stale_message_ms: num(env.KEEPALIVE_STALE_MESSAGE_MS, DEFAULTS.stale_message_ms),
26
+ alert_cooldown_ms: num(env.KEEPALIVE_ALERT_COOLDOWN_MS, DEFAULTS.alert_cooldown_ms),
27
+ auto_connect: String(env.KEEPALIVE_AUTO_CONNECT ?? "true") !== "false",
28
+ };
29
+ }
30
+
31
+ /** Pure helper — used by tests without timers. */
32
+ export function nextBackoffMs(attempt, baseMs = DEFAULTS.reconnect_base_ms, maxMs = DEFAULTS.reconnect_max_ms) {
33
+ const a = Math.max(0, Number(attempt) || 0);
34
+ const base = Math.max(500, Number(baseMs) || DEFAULTS.reconnect_base_ms);
35
+ const max = Math.max(base, Number(maxMs) || DEFAULTS.reconnect_max_ms);
36
+ const exp = Math.min(max, base * 2 ** Math.min(a, 8));
37
+ const jitter = Math.floor(Math.random() * Math.min(1000, exp * 0.1));
38
+ return Math.min(max, exp + jitter);
39
+ }
40
+
41
+ export function isListenerAlive(runtime) {
42
+ if (!runtime?.api) return false;
43
+ const listener = runtime.api.listener;
44
+ if (!listener) return false;
45
+ // zca-js may expose started/closed flags; treat missing flags as alive if api exists.
46
+ if (listener.closed === true) return false;
47
+ if (listener.started === false) return false;
48
+ return true;
49
+ }
50
+
51
+ export function shouldReconnect({ status, hasSession, listenerAlive, paused }) {
52
+ if (paused) return false;
53
+ if (!hasSession) return false; // need QR — do not thrash
54
+ if (status === "paused" || status === "need_scan") return false;
55
+ if (status === "connected" && listenerAlive) return false;
56
+ return true;
57
+ }
58
+
59
+ /**
60
+ * Build short ops alert for connection issues — no tech jargon.
61
+ */
62
+ export function buildConnectionAlert({
63
+ kind = "disconnect",
64
+ displayName = "Zalo",
65
+ detail = "",
66
+ attempt = 0,
67
+ } = {}) {
68
+ const title =
69
+ kind === "recovered"
70
+ ? "Kết nối Zalo đã ổn lại — tài khoản đã cấu hình"
71
+ : "Cảnh báo kết nối Zalo — tài khoản đã cấu hình";
72
+ const lines = [title, ""];
73
+ lines.push("Trạng thái:");
74
+ if (kind === "recovered") {
75
+ lines.push(`Tài khoản ${displayName || "Zalo"} đã kết nối lại và đang theo dõi tin mới.`);
76
+ } else if (kind === "need_scan") {
77
+ lines.push("Phiên đăng nhập hết hạn. Cần quét QR lại để tiếp tục theo dõi.");
78
+ } else {
79
+ lines.push(`Mất kết nối tạm thời${attempt > 0 ? ` (lần thử ${attempt})` : ""}. Hệ thống đang tự kết nối lại.`);
80
+ }
81
+ lines.push("");
82
+ lines.push("Số liệu hiện có:");
83
+ lines.push("Chưa có thay đổi số liệu vận hành do sự cố kết nối.");
84
+ lines.push("");
85
+ lines.push("Hoạt động gần đây:");
86
+ lines.push(
87
+ kind === "recovered"
88
+ ? "Đã khôi phục theo dõi tin mới realtime."
89
+ : "Tạm dừng nhận tin mới; sẽ ghi lại sau khi kết nối ổn.",
90
+ );
91
+ if (detail) {
92
+ lines.push("");
93
+ lines.push("Nhận xét:");
94
+ lines.push(String(detail).slice(0, 180));
95
+ }
96
+ lines.push("");
97
+ lines.push("Việc tiếp theo:");
98
+ if (kind === "need_scan") {
99
+ lines.push("1. Quét QR đăng nhập lại.");
100
+ lines.push("2. Kiểm tra tin điều hành sau khi online.");
101
+ } else if (kind === "recovered") {
102
+ lines.push("1. Không cần thao tác thêm.");
103
+ lines.push("2. Tiếp tục theo dõi tin mới realtime.");
104
+ } else {
105
+ lines.push("1. Chờ hệ thống tự kết nối lại.");
106
+ lines.push("2. Nếu quá 15 phút chưa ổn, kiểm tra phiên đăng nhập.");
107
+ }
108
+ return lines.join("\n").slice(0, 2000);
109
+ }
110
+
111
+ export class KeepaliveSupervisor {
112
+ constructor({
113
+ config,
114
+ store,
115
+ policy,
116
+ hub,
117
+ accountId,
118
+ options = {},
119
+ now = () => Date.now(),
120
+ setTimeoutFn = setTimeout,
121
+ clearTimeoutFn = clearTimeout,
122
+ setIntervalFn = setInterval,
123
+ clearIntervalFn = clearInterval,
124
+ }) {
125
+ this.config = config;
126
+ this.store = store;
127
+ this.policy = policy;
128
+ this.hub = hub;
129
+ this.accountId = String(accountId || config.default_account_id);
130
+ this.opts = { ...DEFAULTS, ...options };
131
+ this.now = now;
132
+ this.setTimeoutFn = setTimeoutFn;
133
+ this.clearTimeoutFn = clearTimeoutFn;
134
+ this.setIntervalFn = setIntervalFn;
135
+ this.clearIntervalFn = clearIntervalFn;
136
+
137
+ this.started = false;
138
+ this.tickTimer = null;
139
+ this.reconnectTimer = null;
140
+ this.reconnectAttempt = 0;
141
+ this.reconnectInFlight = false;
142
+ this.lastAlertAt = 0;
143
+ this.lastState = "init";
144
+ this.lastError = "";
145
+ this.startedAt = null;
146
+ }
147
+
148
+ start() {
149
+ if (this.started) return this.snapshot();
150
+ this.started = true;
151
+ this.startedAt = new Date(this.now()).toISOString();
152
+ this.store.setHealth(`keepalive_${this.accountId}`, {
153
+ started: true,
154
+ at: this.startedAt,
155
+ opts: {
156
+ tick_ms: this.opts.tick_ms,
157
+ reconnect_base_ms: this.opts.reconnect_base_ms,
158
+ reconnect_max_ms: this.opts.reconnect_max_ms,
159
+ },
160
+ });
161
+ // Boot connect once.
162
+ if (this.opts.auto_connect) {
163
+ this.#scheduleReconnect(0, "boot");
164
+ }
165
+ this.tickTimer = this.setIntervalFn(() => {
166
+ this.tick().catch((err) => {
167
+ try {
168
+ this.store.setHealth(`keepalive_tick_error_${this.accountId}`, String(err?.message || err));
169
+ } catch {
170
+ /* ignore */
171
+ }
172
+ });
173
+ }, this.opts.tick_ms);
174
+ // first tick soon
175
+ this.setTimeoutFn(() => {
176
+ this.tick().catch(() => {});
177
+ }, 1500);
178
+ return this.snapshot();
179
+ }
180
+
181
+ stop() {
182
+ this.started = false;
183
+ if (this.tickTimer) this.clearIntervalFn(this.tickTimer);
184
+ if (this.reconnectTimer) this.clearTimeoutFn(this.reconnectTimer);
185
+ this.tickTimer = null;
186
+ this.reconnectTimer = null;
187
+ }
188
+
189
+ snapshot() {
190
+ const runtime = this.hub.getRuntime(this.accountId);
191
+ const st = runtime.status();
192
+ const alive = isListenerAlive(runtime);
193
+ return {
194
+ started: this.started,
195
+ account_id: this.accountId,
196
+ status: st.status,
197
+ listener_alive: alive,
198
+ reconnect_attempt: this.reconnectAttempt,
199
+ reconnect_in_flight: this.reconnectInFlight,
200
+ last_state: this.lastState,
201
+ last_error: this.lastError || st.last_error || "",
202
+ last_message_at: runtime.lastMessageAt || null,
203
+ started_at: this.startedAt,
204
+ now: new Date(this.now()).toISOString(),
205
+ };
206
+ }
207
+
208
+ async tick() {
209
+ if (!this.started) return this.snapshot();
210
+ const runtime = this.hub.getRuntime(this.accountId);
211
+ const st = runtime.status();
212
+ const hasSession = this.store.hasSession(this.accountId);
213
+ const listenerAlive = isListenerAlive(runtime);
214
+ const paused = this.store.isGlobalPaused() || st.status === "paused";
215
+
216
+ this.store.setHealth(`keepalive_tick_${this.accountId}`, {
217
+ at: utcNow(),
218
+ status: st.status,
219
+ listener_alive: listenerAlive,
220
+ has_session: hasSession,
221
+ reconnect_attempt: this.reconnectAttempt,
222
+ last_message_at: runtime.lastMessageAt || null,
223
+ });
224
+
225
+ if (st.status === "connected" && listenerAlive) {
226
+ if (this.lastState !== "healthy") {
227
+ const prev = this.lastState;
228
+ this.lastState = "healthy";
229
+ this.reconnectAttempt = 0;
230
+ this.lastError = "";
231
+ if (prev === "reconnecting" || prev === "down") {
232
+ await this.#maybeAlert("recovered", { displayName: st.display_name });
233
+ }
234
+ }
235
+ return this.snapshot();
236
+ }
237
+
238
+ if (st.status === "need_scan" || !hasSession) {
239
+ this.lastState = "need_scan";
240
+ await this.#maybeAlert("need_scan", {
241
+ displayName: st.display_name,
242
+ detail: "Phiên đăng nhập chưa sẵn sàng.",
243
+ });
244
+ return this.snapshot();
245
+ }
246
+
247
+ if (shouldReconnect({ status: st.status, hasSession, listenerAlive, paused })) {
248
+ this.lastState = "reconnecting";
249
+ this.#scheduleReconnect(this.reconnectAttempt, st.last_error || "listener_down");
250
+ }
251
+ return this.snapshot();
252
+ }
253
+
254
+ #scheduleReconnect(attempt, reason) {
255
+ if (this.reconnectInFlight) return;
256
+ if (this.reconnectTimer) return;
257
+ const delay = attempt <= 0 ? 200 : nextBackoffMs(attempt, this.opts.reconnect_base_ms, this.opts.reconnect_max_ms);
258
+ this.reconnectTimer = this.setTimeoutFn(() => {
259
+ this.reconnectTimer = null;
260
+ this.#doReconnect(reason).catch(() => {});
261
+ }, delay);
262
+ this.store.setHealth(`keepalive_schedule_${this.accountId}`, {
263
+ at: utcNow(),
264
+ attempt,
265
+ delay_ms: delay,
266
+ reason: String(reason || "").slice(0, 200),
267
+ });
268
+ }
269
+
270
+ async #doReconnect(reason) {
271
+ if (this.reconnectInFlight || !this.started) return;
272
+ this.reconnectInFlight = true;
273
+ this.reconnectAttempt += 1;
274
+ const attempt = this.reconnectAttempt;
275
+ const runtime = this.hub.getRuntime(this.accountId);
276
+ try {
277
+ this.store.setAccountStatus(this.accountId, "reconnecting", {
278
+ last_error: String(reason || "reconnect").slice(0, 300),
279
+ });
280
+ // Drop stale api so connect re-binds listener.
281
+ try {
282
+ runtime.api?.listener?.stop?.();
283
+ } catch {
284
+ /* ignore */
285
+ }
286
+ runtime.api = null;
287
+ runtime.listenerWired = false;
288
+ runtime._wiredListener = null;
289
+ await runtime.connect({ forceQr: false });
290
+ const wasDown = attempt > 1 || this.lastState === "down" || this.lastState === "reconnecting";
291
+ this.lastState = "healthy";
292
+ this.reconnectAttempt = 0;
293
+ this.lastError = "";
294
+ this.store.setHealth(`keepalive_reconnect_ok_${this.accountId}`, {
295
+ at: utcNow(),
296
+ attempt,
297
+ });
298
+ if (wasDown && attempt > 1) {
299
+ await this.#maybeAlert("recovered", {
300
+ displayName: runtime.status().display_name,
301
+ attempt,
302
+ });
303
+ }
304
+ } catch (err) {
305
+ this.lastState = "down";
306
+ this.lastError = String(err?.message || err).slice(0, 300);
307
+ this.store.setAccountStatus(this.accountId, "disconnected", {
308
+ last_error: this.lastError,
309
+ });
310
+ this.store.setHealth(`keepalive_reconnect_err_${this.accountId}`, {
311
+ at: utcNow(),
312
+ attempt,
313
+ error: this.lastError,
314
+ });
315
+ await this.#maybeAlert("disconnect", {
316
+ displayName: runtime.status().display_name,
317
+ detail: this.lastError,
318
+ attempt,
319
+ });
320
+ // schedule next
321
+ this.reconnectInFlight = false;
322
+ this.#scheduleReconnect(this.reconnectAttempt, this.lastError);
323
+ return;
324
+ } finally {
325
+ this.reconnectInFlight = false;
326
+ }
327
+ }
328
+
329
+ async #maybeAlert(kind, { displayName = "", detail = "", attempt = 0 } = {}) {
330
+ const now = this.now();
331
+ if (now - this.lastAlertAt < this.opts.alert_cooldown_ms) {
332
+ return { sent: false, reason: "alert_cooldown" };
333
+ }
334
+ const text = buildConnectionAlert({ kind, displayName, detail, attempt });
335
+ const dest = this.store.getDestination(this.accountId);
336
+ if (!dest?.group_id) return { sent: false, reason: "no_destination" };
337
+
338
+ const outbound = this.policy.evaluateOutbound({
339
+ accountId: this.accountId,
340
+ targetId: dest.group_id,
341
+ text,
342
+ kind: "alert",
343
+ alertKey: `keepalive:${kind}`,
344
+ });
345
+ if (!outbound.allow) {
346
+ this.store.setHealth(`keepalive_alert_blocked_${this.accountId}`, {
347
+ at: utcNow(),
348
+ reason: outbound.reason,
349
+ kind,
350
+ });
351
+ return { sent: false, reason: outbound.reason };
352
+ }
353
+
354
+ try {
355
+ const runtime = this.hub.getRuntime(this.accountId);
356
+ if (!runtime.api?.sendMessage) {
357
+ // Queue as health note only when offline.
358
+ this.store.setHealth(`keepalive_alert_queued_${this.accountId}`, {
359
+ at: utcNow(),
360
+ kind,
361
+ text: text.slice(0, 500),
362
+ });
363
+ return { sent: false, reason: "not_connected" };
364
+ }
365
+ await runtime.sendText(dest.group_id, text, 1);
366
+ this.lastAlertAt = now;
367
+ this.store.setCooldown(this.accountId, `keepalive:${kind}`);
368
+ this.store.audit(this.accountId, "system", "keepalive_alert", `${kind}:${attempt}`);
369
+ return { sent: true, kind };
370
+ } catch (err) {
371
+ this.store.setHealth(`keepalive_alert_error_${this.accountId}`, String(err?.message || err));
372
+ return { sent: false, reason: String(err?.message || err) };
373
+ }
374
+ }
375
+ }