talon-agent 3.24.1 → 3.25.1

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 (36) hide show
  1. package/README.md +123 -25
  2. package/package.json +4 -1
  3. package/prompts/whatsapp.md +58 -0
  4. package/src/backend/shared/delivery-contract.ts +1 -0
  5. package/src/core/doctor.ts +5 -0
  6. package/src/core/frontend-runtime/builtins.ts +8 -0
  7. package/src/core/mcp-hub/talon-server.ts +1 -0
  8. package/src/core/prompt/embedded-prompts.ts +2 -0
  9. package/src/core/tools/chat.ts +4 -4
  10. package/src/core/tools/history.ts +5 -5
  11. package/src/core/tools/media.ts +1 -1
  12. package/src/core/tools/members.ts +4 -4
  13. package/src/core/tools/messaging.ts +12 -11
  14. package/src/core/tools/moderation.ts +2 -2
  15. package/src/core/tools/scheduling.ts +2 -2
  16. package/src/core/tools/types.ts +1 -1
  17. package/src/frontend/factories.ts +1 -0
  18. package/src/frontend/whatsapp/actions/chat-info.ts +228 -0
  19. package/src/frontend/whatsapp/actions/index.ts +78 -0
  20. package/src/frontend/whatsapp/actions/media.ts +248 -0
  21. package/src/frontend/whatsapp/actions/messaging.ts +281 -0
  22. package/src/frontend/whatsapp/actions/moderation.ts +231 -0
  23. package/src/frontend/whatsapp/actions/shared.ts +196 -0
  24. package/src/frontend/whatsapp/actions/types.ts +34 -0
  25. package/src/frontend/whatsapp/factory.ts +12 -0
  26. package/src/frontend/whatsapp/formatting.ts +172 -0
  27. package/src/frontend/whatsapp/identity.ts +150 -0
  28. package/src/frontend/whatsapp/index.ts +553 -0
  29. package/src/frontend/whatsapp/media-store.ts +97 -0
  30. package/src/frontend/whatsapp/message-store.ts +140 -0
  31. package/src/frontend/whatsapp/pins.ts +52 -0
  32. package/src/frontend/whatsapp/registry.ts +92 -0
  33. package/src/util/chat-id.ts +5 -0
  34. package/src/util/config.ts +43 -0
  35. package/src/util/log.ts +1 -0
  36. package/src/util/paths.ts +2 -0
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Identity resolution — the LID/phone-number duality.
3
+ *
4
+ * WhatsApp addresses people two ways. The historical form is the phone
5
+ * number (`353834733284@s.whatsapp.net`, "PN"); the newer privacy form
6
+ * is a linked identity (`180753715482747@lid`, "LID") that hides the
7
+ * number. Which one arrives depends on the sender's privacy settings and
8
+ * the chat's addressing mode, and neither is derivable from the other —
9
+ * they are looked up.
10
+ *
11
+ * This matters because every allowlist a human writes is phone numbers.
12
+ * Matching those against a raw `remoteJid` silently ignores anyone whose
13
+ * messages arrive as a LID — which is exactly what happened on the first
14
+ * real message to the live account.
15
+ *
16
+ * So every identity resolves to BOTH forms and matches on either.
17
+ * Baileys carries the counterpart on the message key (`remoteJidAlt` /
18
+ * `participantAlt`) when it knows it, and its signal store can look one
19
+ * up otherwise; results are cached because the mapping is stable.
20
+ */
21
+
22
+ import { isLidUser, jidNormalizedUser, type WASocket } from "baileys";
23
+
24
+ export type Identity = {
25
+ /** Phone-number form, digits only, when known: "353834733284". */
26
+ phone?: string;
27
+ /** LID form, digits only, when known: "180753715482747". */
28
+ lid?: string;
29
+ /** Every bare id this person is known by — what allowlists match on. */
30
+ ids: string[];
31
+ };
32
+
33
+ /**
34
+ * Bare identity of a JID or phone string: strips server, device, and
35
+ * "+". Returns undefined for anything that leaves no digits — WhatsApp
36
+ * hands out empty `participant` fields on DMs, and an empty id that
37
+ * flows onward silently collapses every conversation into one.
38
+ */
39
+ export function bareId(jidOrNumber: string | null | undefined): string {
40
+ return jidOrNumber?.split("@")[0].split(":")[0].replace(/^\+/, "") ?? "";
41
+ }
42
+
43
+ /** Bare id, or undefined when there is nothing usable to key on. */
44
+ function usableId(jidOrNumber: string | null | undefined): string | undefined {
45
+ const bare = bareId(jidOrNumber).trim();
46
+ return bare.length > 0 ? bare : undefined;
47
+ }
48
+
49
+ /** LID ↔ PN is stable for the life of an account; cache both directions. */
50
+ const cache = new Map<string, Identity>();
51
+
52
+ function remember(identity: Identity): Identity {
53
+ // Nothing to key on means nothing worth caching — the next message
54
+ // gets a fresh attempt at resolving this person.
55
+ for (const id of identity.ids) cache.set(id, identity);
56
+ return identity;
57
+ }
58
+
59
+ type LidStore = {
60
+ getPNForLID(lid: string): Promise<string | null>;
61
+ getLIDForPN(pn: string): Promise<string | null>;
62
+ };
63
+
64
+ function lidStore(
65
+ sock: Pick<WASocket, "signalRepository"> | null,
66
+ ): LidStore | undefined {
67
+ return (
68
+ sock?.signalRepository as unknown as { lidMapping?: LidStore } | undefined
69
+ )?.lidMapping;
70
+ }
71
+
72
+ /**
73
+ * Resolve a user JID into every form they can be addressed by.
74
+ *
75
+ * `altJid` is Baileys' counterpart hint from the message key — free when
76
+ * present. Otherwise the signal store is asked, a local lookup that can
77
+ * still miss (the mapping is learned, not computed). A one-sided answer
78
+ * is returned rather than failing, so matching degrades to the form we
79
+ * do have instead of dropping the message.
80
+ */
81
+ export async function resolveIdentity(
82
+ sock: Pick<WASocket, "signalRepository"> | null,
83
+ jid: string,
84
+ altJid?: string | null,
85
+ ): Promise<Identity> {
86
+ const bare = usableId(jid);
87
+ const cached = bare ? cache.get(bare) : undefined;
88
+ if (cached) return cached;
89
+
90
+ const isLid = Boolean(isLidUser(jid));
91
+ const identity: Identity = isLid
92
+ ? { lid: bare, ids: [] }
93
+ : { phone: bare, ids: [] };
94
+
95
+ const altBare = usableId(altJid);
96
+ if (altBare) {
97
+ if (isLid) identity.phone = altBare;
98
+ else identity.lid = altBare;
99
+ } else {
100
+ const store = lidStore(sock);
101
+ if (store) {
102
+ try {
103
+ const counterpart = isLid
104
+ ? await store.getPNForLID(jidNormalizedUser(jid))
105
+ : await store.getLIDForPN(jidNormalizedUser(jid));
106
+ const counterpartId = usableId(counterpart);
107
+ if (counterpartId) {
108
+ if (isLid) identity.phone = counterpartId;
109
+ else identity.lid = counterpartId;
110
+ }
111
+ } catch {
112
+ // A miss is normal before the mapping is learned — match on the
113
+ // form we have rather than failing the message.
114
+ }
115
+ }
116
+ }
117
+
118
+ identity.ids = [identity.phone, identity.lid].filter((id): id is string =>
119
+ Boolean(id),
120
+ );
121
+ return remember(identity);
122
+ }
123
+
124
+ /**
125
+ * True when an identity is on an allowlist of bare ids. Either form
126
+ * counts: the allowlist is written in phone numbers, the message may
127
+ * arrive as a LID, and both name the same person.
128
+ */
129
+ export function identityAllowed(
130
+ identity: Identity,
131
+ allowed: ReadonlySet<string>,
132
+ ): boolean {
133
+ return identity.ids.some((id) => allowed.has(id));
134
+ }
135
+
136
+ /**
137
+ * The stable, human-meaningful id for a person: their phone number when
138
+ * known, else the LID. Chat ids are built from this so a conversation
139
+ * keeps one identity even as WhatsApp switches addressing form.
140
+ */
141
+ export function canonicalId(identity: Identity): string | undefined {
142
+ // Deliberately not `??`: an empty string is "no id", not a value, and
143
+ // letting one through builds a chat id every DM would share.
144
+ return identity.phone || identity.lid || undefined;
145
+ }
146
+
147
+ /** Test seam: forget every resolved identity. */
148
+ export function resetIdentityCache(): void {
149
+ cache.clear();
150
+ }
@@ -0,0 +1,553 @@
1
+ /**
2
+ * WhatsApp frontend — a personal WhatsApp account driven over the
3
+ * multi-device web protocol via Baileys (WebSocket, no browser).
4
+ *
5
+ * AUTH: multi-file credential state under ~/.talon/whatsapp-auth/. First
6
+ * start pairs interactively — a QR code in the terminal, or a pairing
7
+ * code when `whatsapp.pairingNumber` is set. A logged-out close wipes the
8
+ * auth dir and re-pairs, because those credentials are dead.
9
+ *
10
+ * RECEIVE: `messages.upsert` → allowlist gates → media saved to the
11
+ * workspace → history recorded → `execute()`.
12
+ *
13
+ * SEND: the model's delivery tools route through the gateway into
14
+ * `actions/`, which owns the whole WhatsApp API surface.
15
+ */
16
+
17
+ import { rmSync } from "node:fs";
18
+ import makeWASocket, {
19
+ DisconnectReason,
20
+ isJidGroup,
21
+ useMultiFileAuthState,
22
+ type WAMessage,
23
+ type WASocket,
24
+ } from "baileys";
25
+ import qrcode from "qrcode-terminal";
26
+ import type { TalonConfig } from "../../util/config.js";
27
+ import type { ContextManager } from "../../core/types.js";
28
+ import type { Gateway } from "../../core/engine/gateway.js";
29
+ 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 } from "../../storage/history.js";
37
+ import {
38
+ recordError,
39
+ recordMessageProcessed,
40
+ recordMessageReceived,
41
+ recordMessageSettled,
42
+ } from "../../util/watchdog.js";
43
+ import { createWhatsAppActionHandler } from "./actions/index.js";
44
+ import {
45
+ bareId,
46
+ canonicalId,
47
+ identityAllowed,
48
+ resolveIdentity,
49
+ } from "./identity.js";
50
+ import { sendText } from "./actions/shared.js";
51
+ import { saveInboundMedia } from "./media-store.js";
52
+ import { lookupByWaId, rememberMessage } from "./message-store.js";
53
+ import {
54
+ lookupWhatsAppChat,
55
+ registerWhatsAppChat,
56
+ type WhatsAppChatInfo,
57
+ } from "./registry.js";
58
+
59
+ // ── Types ────────────────────────────────────────────────────────────────────
60
+
61
+ export type WhatsAppFrontend = {
62
+ name: "whatsapp";
63
+ context: ContextManager;
64
+ sendTyping: (chatId: number) => Promise<void>;
65
+ sendMessage: (chatId: number, text: string) => Promise<void>;
66
+ getBridgePort: () => number;
67
+ init: () => Promise<void>;
68
+ start: () => Promise<void>;
69
+ stop: () => Promise<void>;
70
+ };
71
+
72
+ type WhatsAppSettings = {
73
+ allowedJids: string[];
74
+ allowedGroups: string[];
75
+ groupPolicy: "listed" | "with-allowed-user" | "all";
76
+ respondMode: "mention" | "all";
77
+ pairingNumber?: string;
78
+ sendReadReceipts: boolean;
79
+ };
80
+
81
+ /** Reconnect backoff: WhatsApp throttles a client that hammers it. */
82
+ const RECONNECT_BASE_MS = 2_000;
83
+ const RECONNECT_MAX_MS = 60_000;
84
+
85
+ /**
86
+ * How long a group's membership answer is trusted. Group metadata costs a
87
+ * round-trip, and the `with-allowed-user` policy would otherwise pay it on
88
+ * every inbound message; memberships change on the order of days.
89
+ */
90
+ const GROUP_POLICY_CACHE_MS = 10 * 60_000;
91
+
92
+ /** Plain text of an inbound message, across the wrappers WhatsApp uses. */
93
+ function extractText(msg: WAMessage): string {
94
+ const m = msg.message;
95
+ if (!m) return "";
96
+ return (
97
+ m.conversation ??
98
+ m.extendedTextMessage?.text ??
99
+ m.imageMessage?.caption ??
100
+ m.videoMessage?.caption ??
101
+ m.documentMessage?.caption ??
102
+ m.documentWithCaptionMessage?.message?.documentMessage?.caption ??
103
+ ""
104
+ );
105
+ }
106
+
107
+ /**
108
+ * Baileys logs through its own pino instance, far too chatty for a daemon
109
+ * that already has structured logging. Bridge warn+ into Talon's log and
110
+ * drop the rest. The shape is pino's minimal logger contract.
111
+ */
112
+ function makeWaLogger(): never {
113
+ const fmt = (args: unknown[]): string =>
114
+ args
115
+ .map((a) => (typeof a === "string" ? a : JSON.stringify(a)))
116
+ .join(" ")
117
+ .slice(0, 300);
118
+ const shim = {
119
+ level: "warn",
120
+ child: () => shim,
121
+ trace: () => {},
122
+ debug: () => {},
123
+ info: () => {},
124
+ warn: (...args: unknown[]) => logWarn("whatsapp", `baileys: ${fmt(args)}`),
125
+ error: (...args: unknown[]) =>
126
+ logError("whatsapp", `baileys: ${fmt(args)}`),
127
+ fatal: (...args: unknown[]) =>
128
+ logError("whatsapp", `baileys: ${fmt(args)}`),
129
+ };
130
+ return shim as never;
131
+ }
132
+
133
+ // ── Frontend factory ─────────────────────────────────────────────────────────
134
+
135
+ export function createWhatsAppFrontend(
136
+ config: TalonConfig,
137
+ gateway: Gateway,
138
+ ): WhatsAppFrontend {
139
+ const settings: WhatsAppSettings = {
140
+ allowedJids: [],
141
+ allowedGroups: [],
142
+ groupPolicy: "listed",
143
+ respondMode: "mention",
144
+ sendReadReceipts: true,
145
+ ...((config as Record<string, unknown>).whatsapp as
146
+ Partial<WhatsAppSettings> | undefined),
147
+ };
148
+ const allowedDms = new Set(settings.allowedJids.map(bareId));
149
+ const allowedGroups = new Set(settings.allowedGroups.map(bareId));
150
+
151
+ let sock: WASocket | null = null;
152
+ let stopping = false;
153
+ let reconnectDelay = RECONNECT_BASE_MS;
154
+ /** Our own ids (phone and LID), once connected — for mention detection. */
155
+ let selfIds: string[] = [];
156
+
157
+ const context: ContextManager = {
158
+ acquire: (chatId: number, stringId?: string) =>
159
+ gateway.setContext(chatId, stringId, "whatsapp"),
160
+ release: (chatId: number) => gateway.clearContext(chatId),
161
+ getMessageCount: (chatId: number) => gateway.getMessageCount(chatId),
162
+ };
163
+
164
+ function chatFor(numericChatId: number): WhatsAppChatInfo | null {
165
+ const info = lookupWhatsAppChat(numericChatId);
166
+ if (!info) {
167
+ logWarn("whatsapp", `No JID registered for chat ${numericChatId}`);
168
+ return null;
169
+ }
170
+ return info;
171
+ }
172
+
173
+ /** Cached `groupPolicy` verdicts, keyed by group JID. */
174
+ const groupAllowCache = new Map<string, { allowed: boolean; at: number }>();
175
+
176
+ /**
177
+ * May the bot act in this group? `allowedGroups` is always honoured;
178
+ * beyond it the policy decides, and "with-allowed-user" asks WhatsApp
179
+ * who is in the group (cached — see GROUP_POLICY_CACHE_MS).
180
+ */
181
+ async function isGroupAllowed(jid: string): Promise<boolean> {
182
+ if (allowedGroups.has(bareId(jid))) return true;
183
+ if (settings.groupPolicy === "listed") return false;
184
+ if (settings.groupPolicy === "all") return true;
185
+
186
+ const cached = groupAllowCache.get(jid);
187
+ if (cached && Date.now() - cached.at < GROUP_POLICY_CACHE_MS) {
188
+ return cached.allowed;
189
+ }
190
+ let allowed = false;
191
+ try {
192
+ const meta = await sock!.groupMetadata(jid);
193
+ // A participant is listed by whichever form the group uses, so
194
+ // check every id WhatsApp gives us for them.
195
+ allowed = meta.participants.some((p) =>
196
+ [p.id, p.lid, p.phoneNumber]
197
+ .filter((id): id is string => Boolean(id))
198
+ .some((id) => allowedDms.has(bareId(id))),
199
+ );
200
+ } catch (err) {
201
+ // A metadata failure must not silently open the group up.
202
+ logWarn(
203
+ "whatsapp",
204
+ `Group policy check failed for ${jid}: ${err instanceof Error ? err.message : err}`,
205
+ );
206
+ }
207
+ groupAllowCache.set(jid, { allowed, at: Date.now() });
208
+ return allowed;
209
+ }
210
+
211
+ /** Is this group message addressed to us — @mentioned or quoting us? */
212
+ function isAddressedToSelf(msg: WAMessage): boolean {
213
+ if (selfIds.length === 0) return false;
214
+ const ctx = msg.message?.extendedTextMessage?.contextInfo;
215
+ const isSelf = (j: string): boolean => selfIds.includes(bareId(j));
216
+ if ((ctx?.mentionedJid ?? []).some(isSelf)) return true;
217
+ return Boolean(ctx?.participant && isSelf(ctx.participant));
218
+ }
219
+
220
+ async function handleInbound(msg: WAMessage): Promise<void> {
221
+ const jid = msg.key.remoteJid;
222
+ // `fromMe` covers our own sends echoing back; status@broadcast is the
223
+ // Stories feed, which is not a conversation.
224
+ if (!jid || jid === "status@broadcast" || msg.key.fromMe) return;
225
+
226
+ const isGroup = Boolean(isJidGroup(jid));
227
+ // The sender may be addressed by phone number or by LID depending on
228
+ // their privacy settings; resolve both before matching an allowlist
229
+ // that is written in phone numbers.
230
+ const senderJid = msg.key.participant || jid;
231
+ const identity = await resolveIdentity(
232
+ sock,
233
+ senderJid,
234
+ msg.key.participantAlt ?? msg.key.remoteJidAlt,
235
+ );
236
+
237
+ // ── Access gates: the allowlists are the entire permission model ──
238
+ if (isGroup) {
239
+ if (!(await isGroupAllowed(jid))) return;
240
+ if (settings.respondMode === "mention" && !isAddressedToSelf(msg)) return;
241
+ } else if (!identityAllowed(identity, allowedDms)) {
242
+ log(
243
+ "whatsapp",
244
+ `Ignoring DM from unlisted ${identity.ids.join("/") || bareId(jid)}`,
245
+ );
246
+ return;
247
+ }
248
+
249
+ const text = extractText(msg).trim();
250
+ // Group chats key on the group JID; DMs key on the person, so the
251
+ // thread survives WhatsApp switching addressing form.
252
+ const chat = registerWhatsAppChat(
253
+ jid,
254
+ undefined,
255
+ isGroup ? undefined : canonicalId(identity),
256
+ );
257
+ const senderName = msg.pushName || canonicalId(identity) || "user";
258
+ const msgId = rememberMessage({
259
+ key: msg.key,
260
+ chatId: chat.chatId,
261
+ message: msg,
262
+ text,
263
+ senderName,
264
+ });
265
+
266
+ // Media is saved before the turn so the model can open the file by
267
+ // path in the same turn it's told about it.
268
+ const media = await saveInboundMedia(msg, chat.chatId, msgId, senderName);
269
+ if (!text && !media) return; // reaction, receipt, or an unsupported type
270
+
271
+ recordMessageReceived();
272
+ if (settings.sendReadReceipts) {
273
+ sock?.readMessages([msg.key]).catch(() => {});
274
+ }
275
+
276
+ // Recorded for read_chat_history / search_chat_history, which the core
277
+ // serves from this store for frontends without a platform history API.
278
+ const replyToWaId =
279
+ msg.message?.extendedTextMessage?.contextInfo?.stanzaId ?? undefined;
280
+ const replyTo = replyToWaId ? lookupByWaId(replyToWaId) : undefined;
281
+ pushMessage(chat.chatId, {
282
+ msgId,
283
+ senderId: Number(BigInt(canonicalId(identity) ?? "0") % 2147483647n),
284
+ senderName,
285
+ senderHandle: canonicalId(identity),
286
+ text,
287
+ timestamp: Date.now(),
288
+ ...(replyTo ? { replyToMsgId: replyTo.msgId } : {}),
289
+ ...(media ? { mediaType: media.type, filePath: media.filePath } : {}),
290
+ });
291
+
292
+ // ── Slash commands ──
293
+ const trimmed = text.toLowerCase();
294
+ if (trimmed === "/reset") {
295
+ await performSessionReset(
296
+ chat.chatId,
297
+ resolveChatBackend(chat.chatId, gateway.backend),
298
+ );
299
+ log("whatsapp", `Session reset by ${senderName}`);
300
+ if (sock) {
301
+ await sendText({ sock, gateway }, chat, "Session cleared.").catch(
302
+ () => {},
303
+ );
304
+ }
305
+ recordMessageProcessed();
306
+ return;
307
+ }
308
+ if (trimmed === "/help") {
309
+ if (sock) {
310
+ await sendText(
311
+ { sock, gateway },
312
+ chat,
313
+ "*Commands*\n/reset — clear session & history\n/help — this message",
314
+ ).catch(() => {});
315
+ }
316
+ recordMessageProcessed();
317
+ return;
318
+ }
319
+
320
+ const preview = text || `(${media?.type ?? "media"})`;
321
+ log(
322
+ "whatsapp",
323
+ `[${chat.chatId}] [${senderName}]: ${preview.slice(0, 80)}${preview.length > 80 ? "..." : ""}`,
324
+ );
325
+ appendDailyLog(senderName, preview, {
326
+ chatTitle: chat.title,
327
+ username: canonicalId(identity) ?? bareId(jid),
328
+ });
329
+
330
+ // The model addresses messages by numeric id (react/reply/edit), so the
331
+ // id travels with the text the same way the other frontends do it.
332
+ const mediaNote = media
333
+ ? `\n[attached ${media.type}: ${media.filePath}]`
334
+ : "";
335
+ const prompt = `[${senderName}] msg_id:${msgId}: ${text}${mediaNote}`;
336
+
337
+ try {
338
+ await execute({
339
+ chatId: chat.chatId,
340
+ numericChatId: chat.numericChatId,
341
+ prompt,
342
+ senderName,
343
+ isGroup,
344
+ source: "message",
345
+ onEvent: async (event) => {
346
+ switch (event.type) {
347
+ case "tool_call": {
348
+ const input = toolInputToRecord(event.name, event.input);
349
+ const detail = (input.description ??
350
+ input.command ??
351
+ input.action ??
352
+ input.query ??
353
+ "") as string;
354
+ log(
355
+ "whatsapp",
356
+ ` tool: ${event.name}${detail ? ` — ${String(detail).slice(0, 100)}` : ""}`,
357
+ );
358
+ break;
359
+ }
360
+ // Progress prose and the end-of-turn trailing-text fallback.
361
+ // Without this, prose-only turns are silently dropped.
362
+ case "assistant_message": {
363
+ if (!event.text.trim() || !sock) break;
364
+ try {
365
+ await sendText({ sock, gateway }, chat, event.text);
366
+ } catch (err) {
367
+ logError(
368
+ "whatsapp",
369
+ `onEvent delivery failed: ${err instanceof Error ? err.message : err}`,
370
+ );
371
+ }
372
+ break;
373
+ }
374
+ }
375
+ },
376
+ });
377
+ recordMessageProcessed();
378
+ } catch (err) {
379
+ const message = err instanceof Error ? err.message : String(err);
380
+ logError("whatsapp", `[${chat.chatId}] execute failed: ${message}`);
381
+ recordError(message);
382
+ recordMessageSettled();
383
+ }
384
+ }
385
+
386
+ /** One socket lifetime. Resolves with what the caller should do next. */
387
+ async function connectOnce(): Promise<"reconnect" | "logged-out" | "stop"> {
388
+ const { state, saveCreds } = await useMultiFileAuthState(dirs.whatsappAuth);
389
+ const socket = makeWASocket({
390
+ auth: state,
391
+ logger: makeWaLogger(),
392
+ markOnlineOnConnect: false,
393
+ // The account is a bot: announcing "online" would suppress the
394
+ // phone's own notifications for the human who owns the number.
395
+ });
396
+ sock = socket;
397
+ socket.ev.on("creds.update", saveCreds);
398
+
399
+ socket.ev.on("messages.upsert", ({ messages, type }) => {
400
+ // "notify" is a live message; "append" is history sync, which must
401
+ // not trigger turns for conversations that already happened.
402
+ if (type !== "notify") return;
403
+ for (const msg of messages) {
404
+ void handleInbound(msg).catch((err) => {
405
+ logError(
406
+ "whatsapp",
407
+ `inbound handler failed: ${err instanceof Error ? err.message : err}`,
408
+ );
409
+ });
410
+ }
411
+ });
412
+
413
+ let pairingRequested = false;
414
+ return new Promise((resolve) => {
415
+ socket.ev.on("connection.update", (update) => {
416
+ const { connection, lastDisconnect, qr } = update;
417
+
418
+ if (qr && !state.creds.registered) {
419
+ if (settings.pairingNumber && !pairingRequested) {
420
+ pairingRequested = true;
421
+ socket
422
+ .requestPairingCode(bareId(settings.pairingNumber))
423
+ .then((code) =>
424
+ log(
425
+ "whatsapp",
426
+ `Pairing code: ${code} — enter it on ${settings.pairingNumber} ` +
427
+ `via WhatsApp → Linked devices → Link with phone number`,
428
+ ),
429
+ )
430
+ .catch((err) =>
431
+ logError(
432
+ "whatsapp",
433
+ `Pairing code request failed: ${err instanceof Error ? err.message : err}`,
434
+ ),
435
+ );
436
+ } else if (!settings.pairingNumber) {
437
+ log(
438
+ "whatsapp",
439
+ "Scan with WhatsApp → Linked devices → Link a device:",
440
+ );
441
+ qrcode.generate(qr, { small: true });
442
+ }
443
+ }
444
+
445
+ if (connection === "open") {
446
+ // Both forms: a group can @-mention us by either.
447
+ selfIds = [socket.user?.id, socket.user?.lid]
448
+ .filter((id): id is string => Boolean(id))
449
+ .map(bareId);
450
+ reconnectDelay = RECONNECT_BASE_MS;
451
+ log(
452
+ "whatsapp",
453
+ `Connected as ${socket.user?.name ?? "?"} (${selfIds.join("/") || "?"})`,
454
+ );
455
+ }
456
+
457
+ if (connection === "close") {
458
+ const code = (
459
+ lastDisconnect?.error as
460
+ { output?: { statusCode?: number } } | undefined
461
+ )?.output?.statusCode;
462
+ if (stopping) return resolve("stop");
463
+ if (code === DisconnectReason.loggedOut) return resolve("logged-out");
464
+ log(
465
+ "whatsapp",
466
+ `Connection closed (code ${code ?? "?"}) — reconnecting`,
467
+ );
468
+ return resolve("reconnect");
469
+ }
470
+ });
471
+ });
472
+ }
473
+
474
+ return {
475
+ name: "whatsapp",
476
+ context,
477
+
478
+ sendTyping: async (chatId: number) => {
479
+ const chat = chatFor(chatId);
480
+ if (!chat || !sock) return;
481
+ await sock.sendPresenceUpdate("composing", chat.jid).catch(() => {});
482
+ },
483
+
484
+ sendMessage: async (chatId: number, text: string) => {
485
+ if (!text.trim()) return;
486
+ const chat = chatFor(chatId);
487
+ if (!chat || !sock) return;
488
+ try {
489
+ await sendText({ sock, gateway }, chat, text);
490
+ } catch (err) {
491
+ logError(
492
+ "whatsapp",
493
+ `sendMessage failed: ${err instanceof Error ? err.message : err}`,
494
+ );
495
+ }
496
+ },
497
+
498
+ getBridgePort: () => gateway.getPort(),
499
+
500
+ async init() {
501
+ gateway.registerFrontendHandler(
502
+ "whatsapp",
503
+ createWhatsAppActionHandler(() => sock, gateway),
504
+ );
505
+ const port = await gateway.start(19876);
506
+ log("whatsapp", `Gateway on port ${port}`);
507
+ },
508
+
509
+ async start() {
510
+ log("whatsapp", "WhatsApp frontend starting (Baileys multi-device)");
511
+ while (!stopping) {
512
+ let outcome: "reconnect" | "logged-out" | "stop";
513
+ try {
514
+ outcome = await connectOnce();
515
+ } catch (err) {
516
+ logError(
517
+ "whatsapp",
518
+ `Socket error: ${err instanceof Error ? err.message : err}`,
519
+ );
520
+ outcome = "reconnect";
521
+ }
522
+ sock = null;
523
+ if (outcome === "stop" || stopping) break;
524
+ if (outcome === "logged-out") {
525
+ // These credentials are dead — WhatsApp unlinked the device.
526
+ // Wipe them so the next attempt pairs fresh instead of looping
527
+ // on a session that can never authenticate.
528
+ logWarn(
529
+ "whatsapp",
530
+ "Logged out by WhatsApp — clearing auth state, re-pairing",
531
+ );
532
+ rmSync(dirs.whatsappAuth, { recursive: true, force: true });
533
+ reconnectDelay = RECONNECT_BASE_MS;
534
+ }
535
+ await new Promise((r) => setTimeout(r, reconnectDelay));
536
+ reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
537
+ }
538
+ log("whatsapp", "WhatsApp connection loop ended");
539
+ },
540
+
541
+ async stop() {
542
+ stopping = true;
543
+ try {
544
+ sock?.end(undefined);
545
+ } catch {
546
+ /* already closed */
547
+ }
548
+ sock = null;
549
+ await gateway.stop();
550
+ log("whatsapp", "WhatsApp frontend stopped");
551
+ },
552
+ };
553
+ }