talon-agent 3.34.1 → 3.35.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 (47) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +0 -10
  3. package/package.json +3 -3
  4. package/src/backend/codex/auth.ts +40 -1
  5. package/src/backend/codex/handler/message.ts +16 -1
  6. package/src/backend/codex/plan-usage.ts +27 -2
  7. package/src/backend/kilo/handler/message.ts +2 -0
  8. package/src/backend/kilo/server.ts +1 -0
  9. package/src/backend/opencode/handler/message.ts +2 -0
  10. package/src/backend/opencode/server.ts +1 -0
  11. package/src/backend/remote-server/chat-turn.ts +13 -3
  12. package/src/backend/remote-server/lifecycle.ts +37 -0
  13. package/src/backend/remote-server/server-bindings.ts +12 -0
  14. package/src/backend/remote-server/state.ts +8 -0
  15. package/src/backend/remote-server/turn.ts +93 -23
  16. package/src/bootstrap.ts +24 -10
  17. package/src/core/auth/expiry-monitor.ts +89 -0
  18. package/src/core/auth/login-flow.ts +247 -0
  19. package/src/core/auth/status.ts +193 -0
  20. package/src/core/engine/gateway-actions/history.ts +12 -1
  21. package/src/core/errors.ts +1 -1
  22. package/src/core/tools/history.ts +10 -1
  23. package/src/frontend/shared/model-commands.ts +400 -0
  24. package/src/frontend/telegram/auth-panel.ts +205 -0
  25. package/src/frontend/telegram/callbacks/auth.ts +70 -0
  26. package/src/frontend/telegram/callbacks/index.ts +16 -0
  27. package/src/frontend/telegram/callbacks/whatsapp.ts +50 -0
  28. package/src/frontend/telegram/commands/auth.ts +58 -0
  29. package/src/frontend/telegram/commands/definitions.ts +4 -0
  30. package/src/frontend/telegram/commands/index.ts +10 -4
  31. package/src/frontend/telegram/commands/whatsapp-pairing.ts +121 -79
  32. package/src/frontend/whatsapp/actions/chat-info.ts +2 -36
  33. package/src/frontend/whatsapp/actions/history.ts +100 -0
  34. package/src/frontend/whatsapp/actions/index.ts +4 -1
  35. package/src/frontend/whatsapp/commands.ts +371 -0
  36. package/src/frontend/whatsapp/inbound.ts +24 -46
  37. package/src/frontend/whatsapp/index.ts +12 -1
  38. package/src/frontend/whatsapp/media-store.ts +20 -1
  39. package/src/frontend/whatsapp/message-store.ts +101 -24
  40. package/src/storage/history.ts +22 -1
  41. package/src/storage/repositories/history-repo.ts +14 -0
  42. package/src/storage/repositories/whatsapp-messages-repo.ts +101 -0
  43. package/src/storage/sql/history.sql +12 -0
  44. package/src/storage/sql/schema.sql +21 -0
  45. package/src/storage/sql/statements.generated.ts +50 -1
  46. package/src/storage/sql/whatsapp-messages.sql +29 -0
  47. package/src/storage/whatsapp-messages.ts +51 -0
@@ -0,0 +1,371 @@
1
+ /**
2
+ * Slash commands on WhatsApp — `/model`, `/effort`, `/settings`,
3
+ * `/reset`, `/status`, `/help`.
4
+ *
5
+ * WhatsApp has no inline buttons, so everything Telegram does with a
6
+ * picker is done here with typed arguments: `/model` prints a numbered
7
+ * catalog, `/model 3` (or `/model <id>`, `/model <backend>`) picks from
8
+ * it. The state changes come from frontend/shared/model-commands.ts;
9
+ * this module only parses, authorises, and renders.
10
+ *
11
+ * Replies are written in Markdown — `sendText` translates them into
12
+ * WhatsApp's dialect and splits long listings into bubbles.
13
+ */
14
+
15
+ import { log } from "../../util/log.js";
16
+ import { recordMessageProcessed } from "../../util/watchdog.js";
17
+ import { getChatSettings } from "../../storage/chat-settings.js";
18
+ import { isPulseEnabled } from "../../core/background/pulse.js";
19
+ import {
20
+ DEFAULT_PULSE_INTERVAL_MS,
21
+ formatBytes,
22
+ formatDuration,
23
+ formatModelLabel,
24
+ formatTokenCount,
25
+ formatUsd,
26
+ } from "../shared/format.js";
27
+ import {
28
+ collectSessionStatus,
29
+ performSessionReset,
30
+ type SessionStatusData,
31
+ } from "../shared/session-status.js";
32
+ import {
33
+ describeChatEffort,
34
+ describeChatModels,
35
+ matchBackendArg,
36
+ resetChatBackend,
37
+ resetChatModel,
38
+ resolveChatBackendPair,
39
+ selectChatModel,
40
+ setChatEffortLevel,
41
+ switchChatBackend,
42
+ type ModelCommandDeps,
43
+ type ModelOverview,
44
+ } from "../shared/model-commands.js";
45
+ import { sendText } from "./actions/shared.js";
46
+ import { identityAllowed, type Identity } from "./identity.js";
47
+ import type { WhatsAppChatInfo } from "./registry.js";
48
+ import type { WhatsAppRuntime } from "./runtime.js";
49
+
50
+ const COMMAND_NAMES = [
51
+ "model",
52
+ "effort",
53
+ "settings",
54
+ "reset",
55
+ "status",
56
+ "help",
57
+ ] as const;
58
+
59
+ type WhatsAppCommandName = (typeof COMMAND_NAMES)[number];
60
+
61
+ export type WhatsAppCommand = { name: WhatsAppCommandName; arg: string };
62
+
63
+ /** The inbound message fields the command layer needs. */
64
+ export type CommandInbound = {
65
+ chat: WhatsAppChatInfo;
66
+ text: string;
67
+ senderName: string;
68
+ identity: Identity;
69
+ isGroup: boolean;
70
+ };
71
+
72
+ /** How many catalog entries `/model` prints before pointing at `/model <id>`. */
73
+ const MAX_LISTED_MODELS = 40;
74
+
75
+ /**
76
+ * Parse a slash command out of message text. Group messages in mention
77
+ * mode arrive as "@<number> /model", so leading @mentions are skipped.
78
+ * Returns null for anything that is not one of ours, including bare
79
+ * text that merely starts with a slash (a path, a fraction).
80
+ */
81
+ export function parseWhatsAppCommand(text: string): WhatsAppCommand | null {
82
+ const body = text.replace(/^(?:@\S+\s*)+/, "").trim();
83
+ const match = /^\/([a-zA-Z]+)(?:@\S+)?(?:\s+([\s\S]*))?$/.exec(body);
84
+ if (!match) return null;
85
+ const name = match[1].toLowerCase();
86
+ if (!(COMMAND_NAMES as readonly string[]).includes(name)) return null;
87
+ return { name: name as WhatsAppCommandName, arg: (match[2] ?? "").trim() };
88
+ }
89
+
90
+ /** Commands that change chat state, as opposed to showing it. */
91
+ function isMutating(cmd: WhatsAppCommand): boolean {
92
+ if (cmd.name === "reset") return true;
93
+ return (cmd.name === "model" || cmd.name === "effort") && cmd.arg !== "";
94
+ }
95
+
96
+ /**
97
+ * May this sender change the chat's settings? A DM sender already passed
98
+ * the allowlist. In a group, the same allowlist is the admin rule — the
99
+ * people in `allowedJids` are the operator's, everyone else may read
100
+ * settings but not change them. With no allowlist at all there is no
101
+ * admin to distinguish, the way Telegram opens up with no adminUserId.
102
+ */
103
+ export function canChangeSettings(
104
+ runtime: WhatsAppRuntime,
105
+ inbound: Pick<CommandInbound, "isGroup" | "identity">,
106
+ ): boolean {
107
+ if (!inbound.isGroup || runtime.allowedDms.size === 0) return true;
108
+ return identityAllowed(inbound.identity, runtime.allowedDms);
109
+ }
110
+
111
+ // ── Renderers ───────────────────────────────────────────────────────────────
112
+
113
+ function renderModelOverview(view: ModelOverview): string {
114
+ const lines: string[] = [];
115
+ lines.push(
116
+ view.activeModel
117
+ ? `**Model:** \`${view.activeModel}\`${
118
+ view.activeDisplay && view.activeDisplay !== view.activeModel
119
+ ? ` (${view.activeDisplay})`
120
+ : ""
121
+ }${view.hasModelOverride ? " — chat override" : ""}`
122
+ : "**Model:** none selected — messages will be refused until you pick one",
123
+ );
124
+ lines.push(
125
+ `**Backend:** ${view.backendLabel} (\`${view.backendId}\`)${view.hasBackendOverride ? " — chat override" : ""}`,
126
+ );
127
+ if (view.backends.length > 1) {
128
+ lines.push(
129
+ `Backends: ${view.backends.map((b) => `\`${b.id}\``).join(", ")} — \`/model <backend>\` switches (session restarts).`,
130
+ );
131
+ }
132
+ if (view.choices.length === 0) {
133
+ lines.push(
134
+ "",
135
+ "This backend has no browsable catalog; `/model <id>` sets one directly.",
136
+ );
137
+ } else {
138
+ lines.push("", `**Models** (${view.choices.length}):`);
139
+ for (const c of view.choices.slice(0, MAX_LISTED_MODELS)) {
140
+ const marker = c.id === view.activeModel ? " ✓" : "";
141
+ const free = c.free ? " · free" : "";
142
+ lines.push(`${c.index}. ${c.displayName} — \`${c.id}\`${free}${marker}`);
143
+ }
144
+ if (view.choices.length > MAX_LISTED_MODELS) {
145
+ lines.push(
146
+ `… ${view.choices.length - MAX_LISTED_MODELS} more — \`/model <id>\` picks any of them.`,
147
+ );
148
+ }
149
+ }
150
+ lines.push(
151
+ "",
152
+ "`/model <number>` or `/model <id>` picks; `/model default` clears the chat's pick" +
153
+ (view.hasBackendOverride
154
+ ? "; `/model backend default` reverts the backend."
155
+ : "."),
156
+ );
157
+ return lines.join("\n");
158
+ }
159
+
160
+ function renderSettings(
161
+ view: ModelOverview,
162
+ effort: { current: string; levels: string[] },
163
+ pulseOn: boolean,
164
+ pulseIntervalMs: number | undefined,
165
+ freeOnly: boolean,
166
+ ): string {
167
+ const lines = [
168
+ "**Settings**",
169
+ `Model: \`${view.activeModel ?? "none selected"}\`${view.hasModelOverride ? " (chat override)" : ""}`,
170
+ `Backend: ${view.backendLabel} (\`${view.backendId}\`)${view.hasBackendOverride ? " (chat override)" : ""}`,
171
+ `Effort: ${effort.current}${effort.levels.length ? ` — levels: ${effort.levels.join(", ")}` : ""}`,
172
+ `Pulse: ${pulseOn ? "on" : "off"} (every ${formatDuration(pulseIntervalMs ?? DEFAULT_PULSE_INTERVAL_MS)})`,
173
+ ];
174
+ if (freeOnly) lines.push("Free-only models: on");
175
+ lines.push(
176
+ "",
177
+ "Change with `/model`, `/effort`; `/reset` starts a fresh session.",
178
+ );
179
+ return lines.join("\n");
180
+ }
181
+
182
+ function renderStatus(s: SessionStatusData): string {
183
+ const used = s.context.known ? formatTokenCount(s.context.used) : "unknown";
184
+ const max = s.context.max > 0 ? formatTokenCount(s.context.max) : "unknown";
185
+ const pct = s.context.known ? `${s.context.pct}%` : "unknown";
186
+ const lines = [
187
+ `**Talon** · \`${formatModelLabel(s.activeModel)}\`${s.backendLabel ? ` · ${s.backendLabel}` : ""} · effort: ${s.effortName}${s.turnInProgress ? " · ⏳ turn running" : ""}`,
188
+ "",
189
+ `**Context** ${used} / ${max} (${pct})${s.context.warn ? " ⚠️ consider /reset" : ""}`,
190
+ `\`${s.context.bar}\``,
191
+ "",
192
+ "**Session stats**",
193
+ `Response: last ${s.lastResponseMs ? formatDuration(s.lastResponseMs) : "—"} · avg ${s.avgResponseMs ? formatDuration(s.avgResponseMs) : "—"} · best ${s.fastestMs ? formatDuration(s.fastestMs) : "—"}`,
194
+ `Turns: ${s.turns}${s.turnsModelLabel ? ` (${formatModelLabel(s.turnsModelLabel)})` : ""}`,
195
+ `Tokens: in ${formatTokenCount(s.inputTokens)} · out ${formatTokenCount(s.outputTokens)}${s.costUsd > 0 ? ` · cost ${formatUsd(s.costUsd)}` : ""}`,
196
+ ];
197
+ if (s.cache) {
198
+ lines.push(
199
+ `Cache: ${s.cache.hitPct}% hit · read ${formatTokenCount(s.cache.read)}${s.cache.showsWrite ? ` · write ${formatTokenCount(s.cache.write)}` : ""}`,
200
+ );
201
+ }
202
+ if (s.plan) {
203
+ lines.push(
204
+ "",
205
+ `**Plan**${s.plan.plan ? ` ${s.plan.plan}` : ""}${s.plan.ageLabel ? ` (${s.plan.ageLabel})` : ""}`,
206
+ ...s.plan.windows.map(
207
+ (w) =>
208
+ `\`${w.label.padEnd(6)}${w.bar} ${String(w.percent).padStart(3)}%\`${w.resetLabel ? ` reset ${w.resetLabel}` : ""}`,
209
+ ),
210
+ );
211
+ }
212
+ lines.push(
213
+ "",
214
+ `**Pulse** ${s.pulseOn ? "on" : "off"}`,
215
+ `**Workspace** ${formatBytes(s.diskBytes)}`,
216
+ `**Session** ${s.sessionName ? `"${s.sessionName}" ` : ""}${s.sessionId ? `\`${s.sessionId.slice(0, 8)}…\`` : "(new)"} · ${s.sessionAge} old`,
217
+ `**Uptime** ${s.uptime} · ${s.activeSessionCount} active session${s.activeSessionCount === 1 ? "" : "s"}`,
218
+ `**Runtime** ${s.runtime} · ${formatBytes(s.rssBytes)} RSS`,
219
+ );
220
+ return lines.join("\n");
221
+ }
222
+
223
+ const HELP_TEXT = [
224
+ "**Commands**",
225
+ "/model — list backends and models; `/model <n|id>` picks, `/model <backend>` switches",
226
+ "/effort — show or set thinking effort: `/effort high`, `/effort adaptive`",
227
+ "/settings — this chat's model, backend, effort and pulse",
228
+ "/status — session info, context usage and stats",
229
+ "/reset — start a fresh session (chat log kept)",
230
+ "/help — this message",
231
+ ].join("\n");
232
+
233
+ // ── Handlers ────────────────────────────────────────────────────────────────
234
+
235
+ async function runModelCommand(
236
+ chatId: string,
237
+ arg: string,
238
+ deps: ModelCommandDeps,
239
+ ): Promise<string> {
240
+ if (!arg) return renderModelOverview(await describeChatModels(chatId, deps));
241
+ const lower = arg.toLowerCase();
242
+ // `/reset` keeps history on WhatsApp because the local store is the only
243
+ // chat record; a backend switch keeps it for the same reason.
244
+ if (lower === "backend default" || lower === "backend reset") {
245
+ return (await resetChatBackend(chatId, deps, { keepHistory: true })).text;
246
+ }
247
+ if (lower === "reset" || lower === "default") {
248
+ return (await resetChatModel(chatId, deps)).text;
249
+ }
250
+ const backend = matchBackendArg(arg, deps.config);
251
+ if (backend) {
252
+ return (
253
+ await switchChatBackend(chatId, backend, deps, { keepHistory: true })
254
+ ).text;
255
+ }
256
+ return (await selectChatModel(chatId, arg, deps)).text;
257
+ }
258
+
259
+ async function runEffortCommand(
260
+ chatId: string,
261
+ arg: string,
262
+ deps: ModelCommandDeps,
263
+ ): Promise<string> {
264
+ if (arg) return (await setChatEffortLevel(chatId, arg, deps)).text;
265
+ const effort = await describeChatEffort(chatId, deps);
266
+ if (effort.levels.length === 0) {
267
+ return `No reasoning levels available for ${effort.activeModel ?? "the active model"} on backend ${effort.backendId}.`;
268
+ }
269
+ return `**Effort:** ${effort.current}\nLevels: ${effort.levels.join(", ")}, or adaptive — \`/effort <level>\` sets one.`;
270
+ }
271
+
272
+ async function runSettingsCommand(
273
+ chatId: string,
274
+ deps: ModelCommandDeps,
275
+ ): Promise<string> {
276
+ const [view, effort] = await Promise.all([
277
+ describeChatModels(chatId, deps),
278
+ describeChatEffort(chatId, deps),
279
+ ]);
280
+ const sets = getChatSettings(chatId);
281
+ return renderSettings(
282
+ view,
283
+ effort,
284
+ isPulseEnabled(chatId),
285
+ sets.pulseIntervalMs,
286
+ sets.freeOnly === true,
287
+ );
288
+ }
289
+
290
+ async function runStatusCommand(
291
+ chatId: string,
292
+ deps: ModelCommandDeps,
293
+ ): Promise<string> {
294
+ const { backend, backendId } = resolveChatBackendPair(chatId, deps);
295
+ return renderStatus(
296
+ await collectSessionStatus(chatId, deps.config, backend, backendId),
297
+ );
298
+ }
299
+
300
+ async function runResetCommand(
301
+ chatId: string,
302
+ senderName: string,
303
+ deps: ModelCommandDeps,
304
+ ): Promise<string> {
305
+ // The local history store is WhatsApp's only chat record — a reset
306
+ // clears the model's session, not the conversation log.
307
+ await performSessionReset(
308
+ chatId,
309
+ resolveChatBackendPair(chatId, deps).backend,
310
+ {
311
+ keepHistory: true,
312
+ },
313
+ );
314
+ log("whatsapp", `Session reset by ${senderName}`);
315
+ return "Session cleared.";
316
+ }
317
+
318
+ /** The reply text for one parsed command. */
319
+ export async function executeWhatsAppCommand(
320
+ runtime: WhatsAppRuntime,
321
+ cmd: WhatsAppCommand,
322
+ inbound: CommandInbound,
323
+ ): Promise<string> {
324
+ const chatId = inbound.chat.chatId;
325
+ const deps: ModelCommandDeps = {
326
+ config: runtime.config,
327
+ gateway: runtime.gateway,
328
+ };
329
+ if (isMutating(cmd) && !canChangeSettings(runtime, inbound)) {
330
+ return "Only allowlisted users can change settings in a group.";
331
+ }
332
+ switch (cmd.name) {
333
+ case "model":
334
+ return runModelCommand(chatId, cmd.arg, deps);
335
+ case "effort":
336
+ return runEffortCommand(chatId, cmd.arg, deps);
337
+ case "settings":
338
+ return runSettingsCommand(chatId, deps);
339
+ case "status":
340
+ return runStatusCommand(chatId, deps);
341
+ case "reset":
342
+ return runResetCommand(chatId, inbound.senderName, deps);
343
+ case "help":
344
+ return HELP_TEXT;
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Handle a slash command if the message is one. True when it was — the
350
+ * caller then skips the agent turn. Delivery failures are swallowed:
351
+ * the state change already happened and is visible on the next
352
+ * `/settings`; a dead socket is the connection loop's problem.
353
+ */
354
+ export async function handleWhatsAppCommand(
355
+ runtime: WhatsAppRuntime,
356
+ inbound: CommandInbound,
357
+ ): Promise<boolean> {
358
+ const cmd = parseWhatsAppCommand(inbound.text);
359
+ if (!cmd) return false;
360
+ const reply = await executeWhatsAppCommand(runtime, cmd, inbound);
361
+ const sock = runtime.sock;
362
+ if (sock) {
363
+ await sendText(
364
+ { sock, gateway: runtime.gateway },
365
+ inbound.chat,
366
+ reply,
367
+ ).catch(() => {});
368
+ }
369
+ recordMessageProcessed();
370
+ return true;
371
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * One inbound message, from `messages.upsert` to the model's turn:
3
3
  * access gates → media saved to the workspace → history recorded →
4
- * slash commands → catch-up policy → `execute()`.
4
+ * slash commands (commands.ts) → catch-up policy → `execute()`.
5
5
  */
6
6
 
7
7
  import { isJidGroup, type WAMessage } from "baileys";
@@ -9,8 +9,6 @@ import type { AgentEvent } from "../../core/agent-runtime/events.js";
9
9
  import { log, logError } from "../../util/log.js";
10
10
  import { execute } from "../../core/engine/dispatcher.js";
11
11
  import { toolInputToRecord } from "../../core/agent-runtime/events.js";
12
- import { resolveChatBackend } from "../../core/engine/backend-controller/index.js";
13
- import { performSessionReset } from "../shared/session-status.js";
14
12
  import { appendDailyLog } from "../../storage/daily-log.js";
15
13
  import { pushMessage } from "../../storage/history.js";
16
14
  import {
@@ -18,6 +16,7 @@ import {
18
16
  recordMessageReceived,
19
17
  } from "../../util/watchdog.js";
20
18
  import { isAddressedToSelf, isGroupAllowed } from "./access.js";
19
+ import { handleWhatsAppCommand } from "./commands.js";
21
20
  import { sendText } from "./actions/shared.js";
22
21
  import {
23
22
  bareId,
@@ -92,8 +91,13 @@ async function admitInbound(
92
91
  if (
93
92
  runtime.settings.respondMode === "mention" &&
94
93
  !isAddressedToSelf(runtime.selfIds, msg)
95
- )
94
+ ) {
95
+ // Not for us to answer, but still part of the conversation: the
96
+ // history store is the model's only record of the group, and a
97
+ // turn that reads it must see the messages around the mention.
98
+ await recordPassively(runtime, msg, { jid, isGroup, identity });
96
99
  return null;
100
+ }
97
101
  } else if (!identityAllowed(identity, runtime.allowedDms)) {
98
102
  log(
99
103
  "whatsapp",
@@ -104,6 +108,21 @@ async function admitInbound(
104
108
  return { jid, isGroup, identity };
105
109
  }
106
110
 
111
+ /** Record a message that gets no turn — group chatter the bot only reads. */
112
+ async function recordPassively(
113
+ runtime: WhatsAppRuntime,
114
+ msg: WAMessage,
115
+ admitted: AdmittedMessage,
116
+ ): Promise<void> {
117
+ const recorded = await recordInbound(runtime, msg, admitted);
118
+ if (!recorded) return;
119
+ log(
120
+ "whatsapp",
121
+ `[${recorded.chat.chatId}] Recorded group message from ${recorded.senderName} (history only)`,
122
+ );
123
+ recordMessageProcessed();
124
+ }
125
+
107
126
  async function recordInbound(
108
127
  runtime: WhatsAppRuntime,
109
128
  msg: WAMessage,
@@ -159,47 +178,6 @@ async function recordInbound(
159
178
  return { ...admitted, chat, msgId, text, media, senderName, platformTs };
160
179
  }
161
180
 
162
- /** `/reset` and `/help`. True when the message was a command. */
163
- async function handleSlashCommand(
164
- runtime: WhatsAppRuntime,
165
- inbound: RecordedMessage,
166
- ): Promise<boolean> {
167
- const { chat, senderName } = inbound;
168
- const { gateway } = runtime;
169
- const trimmed = inbound.text.toLowerCase();
170
- if (trimmed === "/reset") {
171
- await performSessionReset(
172
- chat.chatId,
173
- resolveChatBackend(chat.chatId, gateway.backend),
174
- // The local history store is WhatsApp's only chat record — a
175
- // reset clears the model's session, not the conversation log.
176
- { keepHistory: true },
177
- );
178
- log("whatsapp", `Session reset by ${senderName}`);
179
- const sock = runtime.sock;
180
- if (sock) {
181
- await sendText({ sock, gateway }, chat, "Session cleared.").catch(
182
- () => {},
183
- );
184
- }
185
- recordMessageProcessed();
186
- return true;
187
- }
188
- if (trimmed === "/help") {
189
- const sock = runtime.sock;
190
- if (sock) {
191
- await sendText(
192
- { sock, gateway },
193
- chat,
194
- "*Commands*\n/reset — start a fresh session (chat log kept)\n/help — this message",
195
- ).catch(() => {});
196
- }
197
- recordMessageProcessed();
198
- return true;
199
- }
200
- return false;
201
- }
202
-
203
181
  /**
204
182
  * Catch-up messages (queued while the daemon was down) get a reply
205
183
  * turn only while fresh; stale ones are already recorded and the next
@@ -321,7 +299,7 @@ export async function handleInbound(
321
299
  if (!admitted) return;
322
300
  const inbound = await recordInbound(runtime, msg, admitted);
323
301
  if (!inbound) return;
324
- if (await handleSlashCommand(runtime, inbound)) return;
302
+ if (await handleWhatsAppCommand(runtime, inbound)) return;
325
303
  if (options.catchUp && !catchUpDeservesReply(inbound)) return;
326
304
  await runInboundTurn(runtime, inbound);
327
305
  }
@@ -27,6 +27,10 @@ import { maxMsgIdForChatPrefix } from "../../storage/history.js";
27
27
  import { createWhatsAppActionHandler } from "./actions/index.js";
28
28
  import { sendText, setWhatsAppBotName } from "./actions/shared.js";
29
29
  import { seedMessageStore } from "./message-store.js";
30
+ import {
31
+ maxWhatsAppMsgId,
32
+ pruneWhatsAppMessages,
33
+ } from "../../storage/whatsapp-messages.js";
30
34
  import { flushAuthWrites } from "./auth-state.js";
31
35
  import { registerPairingProvider } from "../../core/pairing-broker.js";
32
36
  import { beginPairingAttempt } from "./pairing-service.js";
@@ -112,7 +116,14 @@ export function createWhatsAppFrontend(
112
116
  // but history persists — seed it past what the table already holds
113
117
  // so post-restart messages don't re-issue ids INSERT OR IGNORE then
114
118
  // silently drops (chat ids all start with "wa_").
115
- seedMessageStore((maxMsgIdForChatPrefix("wa_") ?? 0) + 1);
119
+ seedMessageStore(
120
+ Math.max(maxMsgIdForChatPrefix("wa_") ?? 0, maxWhatsAppMsgId() ?? 0) +
121
+ 1,
122
+ );
123
+ const pruned = pruneWhatsAppMessages();
124
+ if (pruned > 0) {
125
+ log("whatsapp", `Pruned ${pruned} expired message key(s)`);
126
+ }
116
127
  gateway.registerFrontendHandler(
117
128
  "whatsapp",
118
129
  createWhatsAppActionHandler(() => runtime.sock, gateway),
@@ -11,6 +11,7 @@
11
11
  import { mkdirSync, writeFileSync } from "node:fs";
12
12
  import { join } from "node:path";
13
13
  import { downloadMediaMessage, type WAMessage } from "baileys";
14
+ import { makeWaLogger } from "./wa-logger.js";
14
15
  import { addMedia } from "../../storage/media-index.js";
15
16
  import { logWarn } from "../../util/log.js";
16
17
  import { dirs } from "../../util/paths.js";
@@ -30,6 +31,16 @@ export type SavedMedia = {
30
31
  caption?: string;
31
32
  };
32
33
 
34
+ /**
35
+ * How to recover media WhatsApp's CDN no longer serves. Media URLs expire
36
+ * (days to weeks); `reuploadRequest` — the socket's `updateMediaMessage`
37
+ * — asks the sender's device to upload the file again and returns the
38
+ * refreshed message, which Baileys then downloads.
39
+ */
40
+ export type MediaRetrieval = {
41
+ reuploadRequest: (message: WAMessage) => Promise<WAMessage>;
42
+ };
43
+
33
44
  /** The media kind carried by a message, if any. */
34
45
  function mediaKindOf(
35
46
  message: WAMessage,
@@ -50,13 +61,21 @@ export async function saveInboundMedia(
50
61
  chatId: string,
51
62
  msgId: number,
52
63
  senderName: string,
64
+ retrieval?: MediaRetrieval,
53
65
  ): Promise<SavedMedia | undefined> {
54
66
  const kind = mediaKindOf(message);
55
67
  if (!kind) return undefined;
56
68
  const [field, type, defaultExt] = kind;
57
69
 
58
70
  try {
59
- const buffer = await downloadMediaMessage(message, "buffer", {});
71
+ const buffer = await downloadMediaMessage(
72
+ message,
73
+ "buffer",
74
+ {},
75
+ retrieval
76
+ ? { reuploadRequest: retrieval.reuploadRequest, logger: makeWaLogger() }
77
+ : undefined,
78
+ );
60
79
  if (!Buffer.isBuffer(buffer) || buffer.length === 0) return undefined;
61
80
 
62
81
  const content = message.message?.[field] as