talon-agent 3.24.0 → 3.25.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 (40) hide show
  1. package/package.json +4 -1
  2. package/prompts/whatsapp.md +58 -0
  3. package/src/backend/shared/delivery-contract.ts +1 -0
  4. package/src/core/doctor.ts +5 -0
  5. package/src/core/frontend-runtime/builtins.ts +8 -0
  6. package/src/core/mcp-hub/talon-server.ts +1 -0
  7. package/src/core/prompt/embedded-prompts.ts +2 -0
  8. package/src/core/tools/chat.ts +4 -4
  9. package/src/core/tools/history.ts +5 -5
  10. package/src/core/tools/media.ts +1 -1
  11. package/src/core/tools/members.ts +4 -4
  12. package/src/core/tools/messaging.ts +12 -11
  13. package/src/core/tools/moderation.ts +2 -2
  14. package/src/core/tools/scheduling.ts +2 -2
  15. package/src/core/tools/types.ts +1 -1
  16. package/src/frontend/factories.ts +1 -0
  17. package/src/frontend/telegram/commands/admin.ts +7 -0
  18. package/src/frontend/telegram/index.ts +5 -0
  19. package/src/frontend/telegram/middleware.ts +10 -0
  20. package/src/frontend/telegram/stale-command.ts +46 -0
  21. package/src/frontend/telegram/update-offset.ts +58 -0
  22. package/src/frontend/whatsapp/actions/chat-info.ts +228 -0
  23. package/src/frontend/whatsapp/actions/index.ts +78 -0
  24. package/src/frontend/whatsapp/actions/media.ts +248 -0
  25. package/src/frontend/whatsapp/actions/messaging.ts +281 -0
  26. package/src/frontend/whatsapp/actions/moderation.ts +231 -0
  27. package/src/frontend/whatsapp/actions/shared.ts +196 -0
  28. package/src/frontend/whatsapp/actions/types.ts +34 -0
  29. package/src/frontend/whatsapp/factory.ts +12 -0
  30. package/src/frontend/whatsapp/formatting.ts +172 -0
  31. package/src/frontend/whatsapp/identity.ts +150 -0
  32. package/src/frontend/whatsapp/index.ts +553 -0
  33. package/src/frontend/whatsapp/media-store.ts +97 -0
  34. package/src/frontend/whatsapp/message-store.ts +140 -0
  35. package/src/frontend/whatsapp/pins.ts +52 -0
  36. package/src/frontend/whatsapp/registry.ts +92 -0
  37. package/src/util/chat-id.ts +5 -0
  38. package/src/util/config.ts +43 -0
  39. package/src/util/log.ts +1 -0
  40. package/src/util/paths.ts +2 -0
@@ -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
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Inbound media persistence.
3
+ *
4
+ * WhatsApp media arrives encrypted and is fetched over HTTP with keys
5
+ * from the message itself; Baileys' `downloadMediaMessage` does that
6
+ * exchange. Everything it yields is written into the workspace uploads
7
+ * dir and indexed like Telegram's, so `list_media`, the media index, and
8
+ * the model's file tools all see WhatsApp attachments as ordinary files.
9
+ */
10
+
11
+ import { mkdirSync, writeFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { downloadMediaMessage, type WAMessage } from "baileys";
14
+ import { addMedia } from "../../storage/media-index.js";
15
+ import { logWarn } from "../../util/log.js";
16
+ import { dirs } from "../../util/paths.js";
17
+
18
+ /** Media kinds Talon's index understands, keyed by WhatsApp message field. */
19
+ const MEDIA_KINDS = [
20
+ ["imageMessage", "photo", "jpg"],
21
+ ["videoMessage", "video", "mp4"],
22
+ ["audioMessage", "voice", "ogg"],
23
+ ["documentMessage", "document", "bin"],
24
+ ["stickerMessage", "sticker", "webp"],
25
+ ] as const;
26
+
27
+ export type SavedMedia = {
28
+ filePath: string;
29
+ type: "photo" | "video" | "voice" | "document" | "sticker";
30
+ caption?: string;
31
+ };
32
+
33
+ /** The media kind carried by a message, if any. */
34
+ export function mediaKindOf(
35
+ message: WAMessage,
36
+ ): (typeof MEDIA_KINDS)[number] | undefined {
37
+ const content = message.message;
38
+ if (!content) return undefined;
39
+ return MEDIA_KINDS.find(([field]) => field in content && content[field]);
40
+ }
41
+
42
+ /**
43
+ * Download a message's media into the workspace and index it. Returns
44
+ * undefined when the message carries none; a failed download logs and
45
+ * returns undefined rather than throwing — a message whose picture
46
+ * didn't fetch is still a message worth handling.
47
+ */
48
+ export async function saveInboundMedia(
49
+ message: WAMessage,
50
+ chatId: string,
51
+ msgId: number,
52
+ senderName: string,
53
+ ): Promise<SavedMedia | undefined> {
54
+ const kind = mediaKindOf(message);
55
+ if (!kind) return undefined;
56
+ const [field, type, defaultExt] = kind;
57
+
58
+ try {
59
+ const buffer = await downloadMediaMessage(message, "buffer", {});
60
+ if (!Buffer.isBuffer(buffer) || buffer.length === 0) return undefined;
61
+
62
+ const content = message.message?.[field] as
63
+ | {
64
+ mimetype?: string | null;
65
+ fileName?: string | null;
66
+ caption?: string | null;
67
+ }
68
+ | undefined;
69
+ // Prefer the sender's own filename for documents; otherwise name the
70
+ // file after the message so the workspace stays traceable.
71
+ const ext =
72
+ content?.fileName?.includes(".") === true
73
+ ? content.fileName.slice(content.fileName.lastIndexOf(".") + 1)
74
+ : (content?.mimetype?.split("/")[1]?.split(";")[0] ?? defaultExt);
75
+ mkdirSync(dirs.uploads, { recursive: true });
76
+ const filePath = join(dirs.uploads, `wa_${chatId}_${msgId}.${ext}`);
77
+ writeFileSync(filePath, buffer);
78
+
79
+ const caption = content?.caption ?? undefined;
80
+ addMedia({
81
+ chatId,
82
+ msgId,
83
+ senderName,
84
+ type,
85
+ filePath,
86
+ timestamp: Date.now(),
87
+ ...(caption ? { caption } : {}),
88
+ });
89
+ return { filePath, type, ...(caption ? { caption } : {}) };
90
+ } catch (err) {
91
+ logWarn(
92
+ "whatsapp",
93
+ `Media download failed for msg ${msgId}: ${err instanceof Error ? err.message : err}`,
94
+ );
95
+ return undefined;
96
+ }
97
+ }