talon-agent 3.33.4 → 3.34.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.
Files changed (74) hide show
  1. package/package.json +2 -1
  2. package/src/app.ts +16 -8
  3. package/src/backend/claude-sdk/stream.ts +76 -53
  4. package/src/backend/remote-server/turn.ts +58 -55
  5. package/src/bootstrap.ts +120 -79
  6. package/src/cli/setup.ts +375 -349
  7. package/src/core/background/cron-spec.ts +273 -0
  8. package/src/core/background/heartbeat/agent.ts +167 -104
  9. package/src/core/engine/backend-controller/pool.ts +2 -0
  10. package/src/core/engine/backend-controller/state.ts +25 -12
  11. package/src/core/engine/gateway-actions/cron.ts +28 -292
  12. package/src/core/engine/gateway-routes.ts +239 -0
  13. package/src/core/engine/gateway.ts +66 -238
  14. package/src/core/vfs/mounts/files.ts +128 -115
  15. package/src/core/weaver/shuttle.ts +29 -2
  16. package/src/core/weaver/weaver.ts +37 -6
  17. package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
  18. package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
  19. package/src/frontend/discord/callbacks/components/effort.ts +72 -0
  20. package/src/frontend/discord/callbacks/components/index.ts +120 -0
  21. package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
  22. package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
  23. package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
  24. package/src/frontend/discord/callbacks/components/model.ts +33 -0
  25. package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
  26. package/src/frontend/discord/callbacks/components/settings.ts +243 -0
  27. package/src/frontend/discord/callbacks/components/types.ts +34 -0
  28. package/src/frontend/discord/callbacks/index.ts +4 -4
  29. package/src/frontend/discord/connection.ts +36 -0
  30. package/src/frontend/discord/diagnostics.ts +62 -0
  31. package/src/frontend/discord/guild-policy.ts +89 -0
  32. package/src/frontend/discord/index.ts +44 -305
  33. package/src/frontend/discord/outbound.ts +61 -0
  34. package/src/frontend/discord/ready.ts +73 -0
  35. package/src/frontend/discord/runtime.ts +55 -0
  36. package/src/frontend/native/chat-lifecycle.ts +39 -0
  37. package/src/frontend/native/chat-wire.ts +69 -0
  38. package/src/frontend/native/context.ts +106 -0
  39. package/src/frontend/native/control.ts +78 -0
  40. package/src/frontend/native/emit.ts +167 -0
  41. package/src/frontend/native/empty-chat-sweep.ts +50 -0
  42. package/src/frontend/native/handlers.ts +121 -0
  43. package/src/frontend/native/history.ts +101 -0
  44. package/src/frontend/native/index.ts +90 -1293
  45. package/src/frontend/native/media.ts +43 -0
  46. package/src/frontend/native/models.ts +221 -0
  47. package/src/frontend/native/queue.ts +47 -0
  48. package/src/frontend/native/reset.ts +50 -0
  49. package/src/frontend/native/routes/chats.ts +115 -0
  50. package/src/frontend/native/routes/daemon.ts +70 -0
  51. package/src/frontend/native/routes/host.ts +151 -0
  52. package/src/frontend/native/routes/index.ts +22 -0
  53. package/src/frontend/native/routes/mesh.ts +72 -0
  54. package/src/frontend/native/routes/models.ts +54 -0
  55. package/src/frontend/native/routes/params.ts +29 -0
  56. package/src/frontend/native/routes/pre-auth.ts +94 -0
  57. package/src/frontend/native/routes/table.ts +92 -0
  58. package/src/frontend/native/runtime.ts +109 -0
  59. package/src/frontend/native/server.ts +30 -555
  60. package/src/frontend/native/status.ts +26 -0
  61. package/src/frontend/native/tool-result.ts +48 -0
  62. package/src/frontend/native/turn.ts +341 -0
  63. package/src/frontend/whatsapp/access.ts +67 -0
  64. package/src/frontend/whatsapp/connection.ts +280 -0
  65. package/src/frontend/whatsapp/inbound.ts +327 -0
  66. package/src/frontend/whatsapp/index.ts +28 -599
  67. package/src/frontend/whatsapp/runtime.ts +74 -0
  68. package/src/storage/metrics.ts +18 -0
  69. package/src/storage/session-record.ts +29 -0
  70. package/src/storage/sessions.ts +24 -0
  71. package/src/util/boot-timer.ts +31 -0
  72. package/src/util/concurrency.ts +28 -0
  73. package/src/frontend/discord/callbacks/components.ts +0 -793
  74. package/src/frontend/discord/callbacks/shared.ts +0 -22
@@ -12,59 +12,27 @@
12
12
  *
13
13
  * SEND: the model's delivery tools route through the gateway into
14
14
  * `actions/`, which owns the whole WhatsApp API surface.
15
+ *
16
+ * This file is wiring only: it constructs the shared runtime (runtime.ts)
17
+ * and owns the frontend lifecycle. The behaviour lives in the modules —
18
+ * access (allow-lists), inbound (one message → one turn), connection
19
+ * (socket lifecycle, reconnect, park-until-paired).
15
20
  */
16
21
 
17
- import { rmSync } from "node:fs";
18
- import { readFile } from "node:fs/promises";
19
- import { resolve as resolvePath } from "node:path";
20
- import makeWASocket, {
21
- isJidGroup,
22
- type WAMessage,
23
- type WASocket,
24
- } from "baileys";
25
- import qrcode from "qrcode-terminal";
26
22
  import type { TalonConfig } from "../../util/config.js";
27
23
  import type { ContextManager } from "../../core/types.js";
28
24
  import type { Gateway } from "../../core/engine/gateway.js";
29
25
  import { log, logError, logWarn } from "../../util/log.js";
30
- import { dirs } from "../../util/paths.js";
31
- import { execute } from "../../core/engine/dispatcher.js";
32
- import { toolInputToRecord } from "../../core/agent-runtime/events.js";
33
- import { resolveChatBackend } from "../../core/engine/backend-controller/index.js";
34
- import { performSessionReset } from "../shared/session-status.js";
35
- import { appendDailyLog } from "../../storage/daily-log.js";
36
- import { pushMessage, maxMsgIdForChatPrefix } from "../../storage/history.js";
37
- import {
38
- recordMessageProcessed,
39
- recordMessageReceived,
40
- } from "../../util/watchdog.js";
26
+ import { maxMsgIdForChatPrefix } from "../../storage/history.js";
41
27
  import { createWhatsAppActionHandler } from "./actions/index.js";
42
- import {
43
- bareId,
44
- canonicalId,
45
- identityAllowed,
46
- resolveIdentity,
47
- } from "./identity.js";
48
28
  import { sendText, setWhatsAppBotName } from "./actions/shared.js";
49
- import { saveInboundMedia } from "./media-store.js";
50
- import {
51
- lookupByWaId,
52
- rememberMessage,
53
- seedMessageStore,
54
- } from "./message-store.js";
55
- import { runTurnWithRecovery, shouldReplyToCatchUp } from "./turn-recovery.js";
56
- import { classifyClose, isPaired, REPLACED_BACKOFF_MS } from "./pairing.js";
57
- import { isManualPairingActive, onPairingComplete } from "./pairing-lock.js";
58
- import { flushAuthWrites, useAtomicAuthState } from "./auth-state.js";
59
- import { makeWaLogger } from "./wa-logger.js";
60
- import { notifyAdmin } from "../../core/notify.js";
29
+ import { seedMessageStore } from "./message-store.js";
30
+ import { flushAuthWrites } from "./auth-state.js";
61
31
  import { registerPairingProvider } from "../../core/pairing-broker.js";
62
32
  import { beginPairingAttempt } from "./pairing-service.js";
63
- import {
64
- lookupWhatsAppChat,
65
- registerWhatsAppChat,
66
- type WhatsAppChatInfo,
67
- } from "./registry.js";
33
+ import { runConnectionLoop } from "./connection.js";
34
+ import { lookupWhatsAppChat, type WhatsAppChatInfo } from "./registry.js";
35
+ import { createWhatsAppRuntime } from "./runtime.js";
68
36
 
69
37
  // ── Types ────────────────────────────────────────────────────────────────────
70
38
 
@@ -79,39 +47,13 @@ export type WhatsAppFrontend = {
79
47
  stop: () => Promise<void>;
80
48
  };
81
49
 
82
- type WhatsAppSettings = {
83
- allowedJids: string[];
84
- allowedGroups: string[];
85
- groupPolicy: "listed" | "with-allowed-user" | "all";
86
- respondMode: "mention" | "all";
87
- pairingNumber?: string;
88
- sendReadReceipts: boolean;
89
- };
90
-
91
- /** Reconnect backoff: WhatsApp throttles a client that hammers it. */
92
- const RECONNECT_BASE_MS = 2_000;
93
- const RECONNECT_MAX_MS = 60_000;
94
-
95
- /**
96
- * How long a group's membership answer is trusted. Group metadata costs a
97
- * round-trip, and the `with-allowed-user` policy would otherwise pay it on
98
- * every inbound message; memberships change on the order of days.
99
- */
100
- const GROUP_POLICY_CACHE_MS = 10 * 60_000;
101
-
102
- /** Plain text of an inbound message, across the wrappers WhatsApp uses. */
103
- function extractText(msg: WAMessage): string {
104
- const m = msg.message;
105
- if (!m) return "";
106
- return (
107
- m.conversation ??
108
- m.extendedTextMessage?.text ??
109
- m.imageMessage?.caption ??
110
- m.videoMessage?.caption ??
111
- m.documentMessage?.caption ??
112
- m.documentWithCaptionMessage?.message?.documentMessage?.caption ??
113
- ""
114
- );
50
+ function chatFor(numericChatId: number): WhatsAppChatInfo | null {
51
+ const info = lookupWhatsAppChat(numericChatId);
52
+ if (!info) {
53
+ logWarn("whatsapp", `No JID registered for chat ${numericChatId}`);
54
+ return null;
55
+ }
56
+ return info;
115
57
  }
116
58
 
117
59
  // ── Frontend factory ─────────────────────────────────────────────────────────
@@ -120,25 +62,7 @@ export function createWhatsAppFrontend(
120
62
  config: TalonConfig,
121
63
  gateway: Gateway,
122
64
  ): WhatsAppFrontend {
123
- const settings: WhatsAppSettings = {
124
- allowedJids: [],
125
- allowedGroups: [],
126
- groupPolicy: "listed",
127
- respondMode: "mention",
128
- sendReadReceipts: true,
129
- ...((config as Record<string, unknown>).whatsapp as
130
- Partial<WhatsAppSettings> | undefined),
131
- };
132
- const allowedDms = new Set(settings.allowedJids.map(bareId));
133
- const allowedGroups = new Set(settings.allowedGroups.map(bareId));
134
-
135
- let sock: WASocket | null = null;
136
- let stopping = false;
137
- let reconnectDelay = RECONNECT_BASE_MS;
138
- /** One "not linked" admin note per outage, not one per QR window. */
139
- let unpairedNotified = false;
140
- /** Our own ids (phone and LID), once connected — for mention detection. */
141
- let selfIds: string[] = [];
65
+ const runtime = createWhatsAppRuntime(config, gateway);
142
66
 
143
67
  const context: ContextManager = {
144
68
  acquire: (chatId: number, stringId?: string) =>
@@ -147,456 +71,13 @@ export function createWhatsAppFrontend(
147
71
  getMessageCount: (chatId: number) => gateway.getMessageCount(chatId),
148
72
  };
149
73
 
150
- function chatFor(numericChatId: number): WhatsAppChatInfo | null {
151
- const info = lookupWhatsAppChat(numericChatId);
152
- if (!info) {
153
- logWarn("whatsapp", `No JID registered for chat ${numericChatId}`);
154
- return null;
155
- }
156
- return info;
157
- }
158
-
159
- /** Cached `groupPolicy` verdicts, keyed by group JID. */
160
- const groupAllowCache = new Map<string, { allowed: boolean; at: number }>();
161
-
162
- /**
163
- * May the bot act in this group? `allowedGroups` is always honoured;
164
- * beyond it the policy decides, and "with-allowed-user" asks WhatsApp
165
- * who is in the group (cached — see GROUP_POLICY_CACHE_MS).
166
- */
167
- async function isGroupAllowed(jid: string): Promise<boolean> {
168
- if (allowedGroups.has(bareId(jid))) return true;
169
- if (settings.groupPolicy === "listed") return false;
170
- if (settings.groupPolicy === "all") return true;
171
-
172
- const cached = groupAllowCache.get(jid);
173
- if (cached && Date.now() - cached.at < GROUP_POLICY_CACHE_MS) {
174
- return cached.allowed;
175
- }
176
- let allowed = false;
177
- try {
178
- const meta = await sock!.groupMetadata(jid);
179
- // A participant is listed by whichever form the group uses, so
180
- // check every id WhatsApp gives us for them.
181
- allowed = meta.participants.some((p) =>
182
- [p.id, p.lid, p.phoneNumber]
183
- .filter((id): id is string => Boolean(id))
184
- .some((id) => allowedDms.has(bareId(id))),
185
- );
186
- } catch (err) {
187
- // A metadata failure must not silently open the group up.
188
- logWarn(
189
- "whatsapp",
190
- `Group policy check failed for ${jid}: ${err instanceof Error ? err.message : err}`,
191
- );
192
- }
193
- groupAllowCache.set(jid, { allowed, at: Date.now() });
194
- return allowed;
195
- }
196
-
197
- /** Is this group message addressed to us — @mentioned or quoting us? */
198
- function isAddressedToSelf(msg: WAMessage): boolean {
199
- if (selfIds.length === 0) return false;
200
- const ctx = msg.message?.extendedTextMessage?.contextInfo;
201
- const isSelf = (j: string): boolean => selfIds.includes(bareId(j));
202
- if ((ctx?.mentionedJid ?? []).some(isSelf)) return true;
203
- return Boolean(ctx?.participant && isSelf(ctx.participant));
204
- }
205
-
206
- async function handleInbound(
207
- msg: WAMessage,
208
- opts: { catchUp?: boolean } = {},
209
- ): Promise<void> {
210
- const jid = msg.key.remoteJid;
211
- // `fromMe` covers our own sends echoing back; status@broadcast is the
212
- // Stories feed, which is not a conversation.
213
- if (!jid || jid === "status@broadcast" || msg.key.fromMe) return;
214
-
215
- const isGroup = Boolean(isJidGroup(jid));
216
- // The sender may be addressed by phone number or by LID depending on
217
- // their privacy settings; resolve both before matching an allowlist
218
- // that is written in phone numbers.
219
- const senderJid = msg.key.participant || jid;
220
- const identity = await resolveIdentity(
221
- sock,
222
- senderJid,
223
- msg.key.participantAlt ?? msg.key.remoteJidAlt,
224
- );
225
-
226
- // ── Access gates: the allowlists are the entire permission model ──
227
- if (isGroup) {
228
- if (!(await isGroupAllowed(jid))) return;
229
- if (settings.respondMode === "mention" && !isAddressedToSelf(msg)) return;
230
- } else if (!identityAllowed(identity, allowedDms)) {
231
- log(
232
- "whatsapp",
233
- `Ignoring DM from unlisted ${identity.ids.join("/") || bareId(jid)}`,
234
- );
235
- return;
236
- }
237
-
238
- const text = extractText(msg).trim();
239
- // Group chats key on the group JID; DMs key on the person, so the
240
- // thread survives WhatsApp switching addressing form.
241
- const chat = registerWhatsAppChat(
242
- jid,
243
- undefined,
244
- isGroup ? undefined : canonicalId(identity),
245
- );
246
- const senderName = msg.pushName || canonicalId(identity) || "user";
247
- const msgId = rememberMessage({
248
- key: msg.key,
249
- chatId: chat.chatId,
250
- message: msg,
251
- text,
252
- senderName,
253
- });
254
-
255
- // Media is saved before the turn so the model can open the file by
256
- // path in the same turn it's told about it.
257
- const media = await saveInboundMedia(msg, chat.chatId, msgId, senderName);
258
- if (!text && !media) return; // reaction, receipt, or an unsupported type
259
-
260
- recordMessageReceived();
261
- if (settings.sendReadReceipts) {
262
- sock?.readMessages([msg.key]).catch(() => {});
263
- }
264
-
265
- // Recorded for read_chat_history / search_chat_history, which the core
266
- // serves from this store for frontends without a platform history API.
267
- const replyToWaId =
268
- msg.message?.extendedTextMessage?.contextInfo?.stanzaId ?? undefined;
269
- const replyTo = replyToWaId ? lookupByWaId(replyToWaId) : undefined;
270
- const platformTs = Number(msg.messageTimestamp) * 1000;
271
- pushMessage(chat.chatId, {
272
- msgId,
273
- senderId: Number(BigInt(canonicalId(identity) ?? "0") % 2147483647n),
274
- senderName,
275
- senderHandle: canonicalId(identity),
276
- text,
277
- // The platform timestamp, so a catch-up message recorded late still
278
- // reads in true order; Date.now() only when Baileys omits it.
279
- timestamp:
280
- Number.isFinite(platformTs) && platformTs > 0 ? platformTs : Date.now(),
281
- ...(replyTo ? { replyToMsgId: replyTo.msgId } : {}),
282
- ...(media ? { mediaType: media.type, filePath: media.filePath } : {}),
283
- });
284
-
285
- // ── Slash commands ──
286
- const trimmed = text.toLowerCase();
287
- if (trimmed === "/reset") {
288
- await performSessionReset(
289
- chat.chatId,
290
- resolveChatBackend(chat.chatId, gateway.backend),
291
- // The local history store is WhatsApp's only chat record — a
292
- // reset clears the model's session, not the conversation log.
293
- { keepHistory: true },
294
- );
295
- log("whatsapp", `Session reset by ${senderName}`);
296
- if (sock) {
297
- await sendText({ sock, gateway }, chat, "Session cleared.").catch(
298
- () => {},
299
- );
300
- }
301
- recordMessageProcessed();
302
- return;
303
- }
304
- if (trimmed === "/help") {
305
- if (sock) {
306
- await sendText(
307
- { sock, gateway },
308
- chat,
309
- "*Commands*\n/reset — start a fresh session (chat log kept)\n/help — this message",
310
- ).catch(() => {});
311
- }
312
- recordMessageProcessed();
313
- return;
314
- }
315
-
316
- // Catch-up messages (queued while the daemon was down) get a reply
317
- // turn only while fresh; stale ones are already recorded above and
318
- // the next live turn reads them from history.
319
- if (opts.catchUp) {
320
- if (!shouldReplyToCatchUp(platformTs)) {
321
- log(
322
- "whatsapp",
323
- `[${chat.chatId}] Recorded offline message from ${senderName} (history only — too old for a reply turn)`,
324
- );
325
- recordMessageProcessed();
326
- return;
327
- }
328
- log(
329
- "whatsapp",
330
- `[${chat.chatId}] Catch-up: replying to offline message from ${senderName}`,
331
- );
332
- }
333
-
334
- const preview = text || `(${media?.type ?? "media"})`;
335
- log(
336
- "whatsapp",
337
- `[${chat.chatId}] [${senderName}]: ${preview.slice(0, 80)}${preview.length > 80 ? "..." : ""}`,
338
- );
339
- appendDailyLog(senderName, preview, {
340
- chatTitle: chat.title,
341
- username: canonicalId(identity) ?? bareId(jid),
342
- });
343
-
344
- // The model addresses messages by numeric id (react/reply/edit), so the
345
- // id travels with the text the same way the other frontends do it.
346
- const mediaNote = media
347
- ? `\n[attached ${media.type}: ${media.filePath}]`
348
- : "";
349
- const prompt = `[${senderName}] msg_id:${msgId}: ${text}${mediaNote}`;
350
-
351
- const runTurn = () =>
352
- execute({
353
- chatId: chat.chatId,
354
- numericChatId: chat.numericChatId,
355
- prompt,
356
- senderName,
357
- isGroup,
358
- source: "message",
359
- onEvent: async (event) => {
360
- switch (event.type) {
361
- case "tool_call": {
362
- const input = toolInputToRecord(event.name, event.input);
363
- const detail = (input.description ??
364
- input.command ??
365
- input.action ??
366
- input.query ??
367
- "") as string;
368
- log(
369
- "whatsapp",
370
- ` tool: ${event.name}${detail ? ` — ${String(detail).slice(0, 100)}` : ""}`,
371
- );
372
- break;
373
- }
374
- // Progress prose and the end-of-turn trailing-text fallback.
375
- // Without this, prose-only turns are silently dropped.
376
- case "assistant_message": {
377
- if (!event.text.trim() || !sock) break;
378
- try {
379
- await sendText({ sock, gateway }, chat, event.text);
380
- } catch (err) {
381
- logError(
382
- "whatsapp",
383
- `onEvent delivery failed: ${err instanceof Error ? err.message : err}`,
384
- );
385
- }
386
- break;
387
- }
388
- }
389
- },
390
- });
391
-
392
- await runTurnWithRecovery({
393
- chatId: chat.chatId,
394
- senderName,
395
- runTurn,
396
- sendErrorText: async (text) => {
397
- if (!sock) return;
398
- try {
399
- await sendText({ sock, gateway }, chat, text);
400
- } catch (sendErr) {
401
- logError(
402
- "whatsapp",
403
- `error delivery failed: ${sendErr instanceof Error ? sendErr.message : sendErr}`,
404
- );
405
- }
406
- },
407
- });
408
- }
409
-
410
- /** One socket lifetime. Resolves with what the caller should do next. */
411
- async function connectOnce(): Promise<
412
- "reconnect" | "logged-out" | "unpaired" | "stop"
413
- > {
414
- // Atomic replacement for Baileys' useMultiFileAuthState — same disk
415
- // format, torn-write-proof (see auth-state.ts for why that matters).
416
- const { state, saveCreds } = await useAtomicAuthState(dirs.whatsappAuth);
417
- const socket = makeWASocket({
418
- auth: state,
419
- logger: makeWaLogger(),
420
- markOnlineOnConnect: false,
421
- // The account is a bot: announcing "online" would suppress the
422
- // phone's own notifications for the human who owns the number.
423
- //
424
- // 120s per QR/pairing ref — the default (60s + 20s refreshes) gave
425
- // a ~2½-minute socket lifetime in pairing mode, shorter than it
426
- // takes a human to pick up their phone and type the code.
427
- qrTimeout: 120_000,
428
- // OpenClaw's production timings: the Baileys 20s connect timeout
429
- // is tight on a loaded box, and a slightly faster keepalive spots
430
- // a dead transport sooner.
431
- connectTimeoutMs: 60_000,
432
- keepAliveIntervalMs: 25_000,
433
- });
434
- sock = socket;
435
- socket.ev.on("creds.update", saveCreds);
436
-
437
- socket.ev.on("messages.upsert", ({ messages, type }) => {
438
- // "notify" is a live message. "append" is everything delivered out
439
- // of band — chiefly messages QUEUED WHILE THE DAEMON WAS DOWN
440
- // (Baileys marks offline-queued nodes as append), but also our own
441
- // sends echoing back and newsletter posts, which handleInbound's
442
- // fromMe/allowlist gates drop. Dropping append wholesale meant any
443
- // message sent during a restart simply vanished: never recorded,
444
- // never answered. Appends are processed as catch-up: always
445
- // recorded, replied to only while fresh.
446
- if (type !== "notify" && type !== "append") return;
447
- const catchUp = type === "append";
448
- for (const msg of messages) {
449
- void handleInbound(msg, { catchUp }).catch((err) => {
450
- logError(
451
- "whatsapp",
452
- `inbound handler failed: ${err instanceof Error ? err.message : err}`,
453
- );
454
- });
455
- }
456
- });
457
-
458
- return new Promise((resolve) => {
459
- socket.ev.on("connection.update", (update) => {
460
- const { connection, lastDisconnect, qr } = update;
461
-
462
- if (qr && !state.creds.registered) {
463
- // The frontend NEVER requests pairing codes on its own — that
464
- // loop once burned ~30 codes in 80 minutes and rate-limited
465
- // the account. Pairing is on demand via /whatsapp pair
466
- // (core/pairing-broker.ts). The terminal QR stays for
467
- // foreground first-run setups without a Telegram admin.
468
- log(
469
- "whatsapp",
470
- "Not linked — pair on demand with /whatsapp pair (Telegram), " +
471
- "or scan below: WhatsApp → Linked devices → Link a device",
472
- );
473
- qrcode.generate(qr, { small: true });
474
- if (!unpairedNotified) {
475
- unpairedNotified = true;
476
- void notifyAdmin(
477
- "📱 WhatsApp is not linked. Send /whatsapp pair when you're " +
478
- "ready and I'll reply with a QR to scan.",
479
- );
480
- }
481
- }
482
-
483
- if (connection === "open") {
484
- // Both forms: a group can @-mention us by either.
485
- selfIds = [socket.user?.id, socket.user?.lid]
486
- .filter((id): id is string => Boolean(id))
487
- .map(bareId);
488
- reconnectDelay = RECONNECT_BASE_MS;
489
- if (unpairedNotified) {
490
- void notifyAdmin("✅ WhatsApp linked and connected.");
491
- }
492
- unpairedNotified = false;
493
- log(
494
- "whatsapp",
495
- `Connected as ${socket.user?.name ?? "?"} (${selfIds.join("/") || "?"})`,
496
- );
497
- }
498
-
499
- if (connection === "close") {
500
- const code = (
501
- lastDisconnect?.error as
502
- { output?: { statusCode?: number } } | undefined
503
- )?.output?.statusCode;
504
- if (stopping) return resolve("stop");
505
- const disposition = classifyClose(code, state.creds.registered);
506
- switch (disposition.kind) {
507
- case "pairing-accepted":
508
- // 515 right after a pairing code/QR is SUCCESS, not an
509
- // error: WhatsApp requires one reconnect with the same
510
- // credentials to complete the login.
511
- log(
512
- "whatsapp",
513
- "Pairing accepted (515) — reconnecting to complete login",
514
- );
515
- reconnectDelay = RECONNECT_BASE_MS;
516
- return resolve("reconnect");
517
- case "replaced":
518
- // Another socket owns this session (440). Fighting it with
519
- // an instant reconnect just steals the session back and
520
- // forth; sit out a full minute instead.
521
- logWarn(
522
- "whatsapp",
523
- "Connection replaced by another client (440) — backing off",
524
- );
525
- reconnectDelay = REPLACED_BACKOFF_MS;
526
- return resolve("reconnect");
527
- case "logged-out":
528
- return resolve("logged-out");
529
- default:
530
- // A socket that died without ever completing a login is a
531
- // pairing window that expired — reconnecting would just open QR
532
- // session after QR session against WhatsApp's servers.
533
- // Park instead; /whatsapp pair opens the next one.
534
- //
535
- // Test with isPaired, not creds.registered: a QR-linked session
536
- // never sets that flag, so the flag alone parks a healthy
537
- // session on its first ordinary disconnect.
538
- if (!isPaired(state.creds)) return resolve("unpaired");
539
- log(
540
- "whatsapp",
541
- `Connection closed (code ${code ?? "?"}) — reconnecting`,
542
- );
543
- return resolve("reconnect");
544
- }
545
- }
546
- });
547
- });
548
- }
549
-
550
- /**
551
- * Wait until a manual pairing completes (event from pairing-lock) or
552
- * registered creds appear on disk, polling slowly. Resolves promptly
553
- * when the frontend is stopping.
554
- */
555
- async function parkUntilPaired(): Promise<void> {
556
- while (!stopping) {
557
- if (!isManualPairingActive()) {
558
- try {
559
- const raw = await readFile(
560
- resolvePath(dirs.whatsappAuth, "creds.json"),
561
- "utf-8",
562
- );
563
- // Same trap as above: waiting on `registered` alone means a QR
564
- // re-link can never end the park, because that flag stays false.
565
- if (
566
- isPaired(
567
- JSON.parse(raw) as {
568
- registered?: boolean;
569
- me?: { id?: string } | null;
570
- },
571
- )
572
- )
573
- return;
574
- } catch {
575
- /* no creds yet — stay parked */
576
- }
577
- }
578
- const paired = await new Promise<boolean>((r) => {
579
- const off = onPairingComplete(() => {
580
- clearTimeout(timer);
581
- off();
582
- r(true);
583
- });
584
- const timer = setTimeout(() => {
585
- off();
586
- r(false);
587
- }, 30_000);
588
- timer.unref?.();
589
- });
590
- if (paired) return;
591
- }
592
- }
593
-
594
74
  return {
595
75
  name: "whatsapp",
596
76
  context,
597
77
 
598
78
  sendTyping: async (chatId: number) => {
599
79
  const chat = chatFor(chatId);
80
+ const sock = runtime.sock;
600
81
  if (!chat || !sock) return;
601
82
  await sock.sendPresenceUpdate("composing", chat.jid).catch(() => {});
602
83
  },
@@ -604,6 +85,7 @@ export function createWhatsAppFrontend(
604
85
  sendMessage: async (chatId: number, text: string) => {
605
86
  if (!text.trim()) return;
606
87
  const chat = chatFor(chatId);
88
+ const sock = runtime.sock;
607
89
  if (!chat || !sock) return;
608
90
  try {
609
91
  await sendText({ sock, gateway }, chat, text);
@@ -623,8 +105,8 @@ export function createWhatsAppFrontend(
623
105
  // (/whatsapp pair on Telegram) through the core broker.
624
106
  registerPairingProvider({
625
107
  label: "WhatsApp",
626
- isLinked: () => Boolean(sock?.user),
627
- begin: () => beginPairingAttempt(settings.pairingNumber),
108
+ isLinked: () => Boolean(runtime.sock?.user),
109
+ begin: () => beginPairingAttempt(runtime.settings.pairingNumber),
628
110
  });
629
111
  // The in-memory message-id counter restarts at its base every boot,
630
112
  // but history persists — seed it past what the table already holds
@@ -633,76 +115,23 @@ export function createWhatsAppFrontend(
633
115
  seedMessageStore((maxMsgIdForChatPrefix("wa_") ?? 0) + 1);
634
116
  gateway.registerFrontendHandler(
635
117
  "whatsapp",
636
- createWhatsAppActionHandler(() => sock, gateway),
118
+ createWhatsAppActionHandler(() => runtime.sock, gateway),
637
119
  );
638
120
  const port = await gateway.start(19876);
639
121
  log("whatsapp", `Gateway on port ${port}`);
640
122
  },
641
123
 
642
- async start() {
643
- log("whatsapp", "WhatsApp frontend starting (Baileys multi-device)");
644
- while (!stopping) {
645
- // A manual pairing attempt owns the auth dir: two sockets on one
646
- // keypair corrupt it and burn rate-limited pairing attempts.
647
- if (isManualPairingActive()) {
648
- await new Promise((r) => setTimeout(r, 5_000));
649
- continue;
650
- }
651
- let outcome: "reconnect" | "logged-out" | "unpaired" | "stop";
652
- try {
653
- outcome = await connectOnce();
654
- } catch (err) {
655
- logError(
656
- "whatsapp",
657
- `Socket error: ${err instanceof Error ? err.message : err}`,
658
- );
659
- outcome = "reconnect";
660
- }
661
- sock = null;
662
- if (outcome === "stop" || stopping) break;
663
- if (outcome === "logged-out") {
664
- // These credentials are dead — WhatsApp unlinked the device.
665
- // Wipe them and PARK. Re-pairing needs a human holding the
666
- // phone, so it happens strictly on demand (Telegram's
667
- // `/whatsapp pair`) — every automatic retry policy tried here,
668
- // including a 30-minute backoff ladder, still burned codes
669
- // against WhatsApp's rate limit until scans failed with
670
- // "couldn't connect device" for everyone.
671
- logWarn(
672
- "whatsapp",
673
- "Logged out by WhatsApp — parked until /whatsapp pair re-links",
674
- );
675
- void notifyAdmin(
676
- "⚠️ WhatsApp unlinked this device. When you're ready to " +
677
- "re-link, send /whatsapp pair and scan the QR I reply with. " +
678
- "Nothing is retried until then.",
679
- );
680
- rmSync(dirs.whatsappAuth, { recursive: true, force: true });
681
- await parkUntilPaired();
682
- reconnectDelay = RECONNECT_BASE_MS;
683
- continue;
684
- }
685
- if (outcome === "unpaired") {
686
- log("whatsapp", "Not paired — parked until /whatsapp pair links");
687
- await parkUntilPaired();
688
- reconnectDelay = RECONNECT_BASE_MS;
689
- continue;
690
- }
691
- await new Promise((r) => setTimeout(r, reconnectDelay));
692
- reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
693
- }
694
- log("whatsapp", "WhatsApp connection loop ended");
695
- },
124
+ start: () => runConnectionLoop(runtime),
696
125
 
697
126
  async stop() {
698
- stopping = true;
127
+ runtime.stopping = true;
699
128
  registerPairingProvider(null);
700
129
  try {
701
- sock?.end(undefined);
130
+ runtime.sock?.end(undefined);
702
131
  } catch {
703
132
  /* already closed */
704
133
  }
705
- sock = null;
134
+ runtime.sock = null;
706
135
  // Drain queued credential writes before the process exits — a key
707
136
  // half-written at shutdown is invisible until the server starts
708
137
  // rejecting stanzas with it.