talon-agent 3.34.1 → 3.36.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 (63) 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/native/chat-wire.ts +6 -2
  24. package/src/frontend/native/emit.ts +30 -11
  25. package/src/frontend/native/handlers.ts +62 -13
  26. package/src/frontend/native/history.ts +30 -6
  27. package/src/frontend/native/media.ts +271 -15
  28. package/src/frontend/native/protocol.ts +34 -1
  29. package/src/frontend/native/queue.ts +4 -7
  30. package/src/frontend/native/routes/chats.ts +30 -12
  31. package/src/frontend/native/routes/host.ts +17 -5
  32. package/src/frontend/native/routes/params.ts +21 -0
  33. package/src/frontend/native/runtime.ts +16 -4
  34. package/src/frontend/native/server.ts +2 -35
  35. package/src/frontend/native/status.ts +3 -1
  36. package/src/frontend/native/turn.ts +39 -17
  37. package/src/frontend/shared/model-commands.ts +400 -0
  38. package/src/frontend/telegram/auth-panel.ts +205 -0
  39. package/src/frontend/telegram/callbacks/auth.ts +70 -0
  40. package/src/frontend/telegram/callbacks/index.ts +16 -0
  41. package/src/frontend/telegram/callbacks/whatsapp.ts +50 -0
  42. package/src/frontend/telegram/commands/auth.ts +58 -0
  43. package/src/frontend/telegram/commands/definitions.ts +4 -0
  44. package/src/frontend/telegram/commands/index.ts +10 -4
  45. package/src/frontend/telegram/commands/whatsapp-pairing.ts +121 -79
  46. package/src/frontend/whatsapp/actions/chat-info.ts +2 -36
  47. package/src/frontend/whatsapp/actions/history.ts +100 -0
  48. package/src/frontend/whatsapp/actions/index.ts +4 -1
  49. package/src/frontend/whatsapp/commands.ts +371 -0
  50. package/src/frontend/whatsapp/inbound.ts +24 -46
  51. package/src/frontend/whatsapp/index.ts +12 -1
  52. package/src/frontend/whatsapp/media-store.ts +20 -1
  53. package/src/frontend/whatsapp/message-store.ts +101 -24
  54. package/src/storage/db.ts +7 -1
  55. package/src/storage/history.ts +26 -2
  56. package/src/storage/repositories/history-repo.ts +61 -1
  57. package/src/storage/repositories/whatsapp-messages-repo.ts +101 -0
  58. package/src/storage/sql/db.sql +6 -0
  59. package/src/storage/sql/history.sql +22 -9
  60. package/src/storage/sql/schema.sql +27 -1
  61. package/src/storage/sql/statements.generated.ts +70 -11
  62. package/src/storage/sql/whatsapp-messages.sql +29 -0
  63. package/src/storage/whatsapp-messages.ts +51 -0
@@ -0,0 +1,50 @@
1
+ /**
2
+ * `whatsapp:*` callbacks — the /whatsapp panel's buttons.
3
+ *
4
+ * whatsapp:pair run one bounded pairing attempt
5
+ * whatsapp:refresh re-read link state and redraw the panel
6
+ */
7
+
8
+ import type { Context } from "grammy";
9
+ import {
10
+ runWhatsAppPairing,
11
+ whatsAppPanel,
12
+ } from "../commands/whatsapp-pairing.js";
13
+ import { isAuthorizedAdmin } from "../commands/state.js";
14
+ import { answerCallbackQuerySafe } from "./shared.js";
15
+
16
+ export async function handleWhatsAppCallback(
17
+ ctx: Context,
18
+ data: string,
19
+ ): Promise<void> {
20
+ if (!isAuthorizedAdmin(ctx)) {
21
+ await answerCallbackQuerySafe(ctx, { text: "Not authorized." });
22
+ return;
23
+ }
24
+ const [, action] = data.split(":");
25
+
26
+ if (action === "refresh") {
27
+ await answerCallbackQuerySafe(ctx);
28
+ const panel = whatsAppPanel();
29
+ const chatId = ctx.chat?.id;
30
+ const messageId = ctx.callbackQuery?.message?.message_id;
31
+ if (chatId === undefined || messageId === undefined) return;
32
+ await ctx.api
33
+ .editMessageText(chatId, messageId, panel.text, {
34
+ parse_mode: "HTML",
35
+ ...(panel.keyboard.length
36
+ ? { reply_markup: { inline_keyboard: panel.keyboard } }
37
+ : {}),
38
+ })
39
+ .catch(() => {});
40
+ return;
41
+ }
42
+
43
+ if (action === "pair") {
44
+ await answerCallbackQuerySafe(ctx, { text: "Starting pairing…" });
45
+ await runWhatsAppPairing(ctx);
46
+ return;
47
+ }
48
+
49
+ await answerCallbackQuerySafe(ctx, { text: "Invalid callback data" });
50
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * /auth — backend login panel (admin only). Also captures the code an
3
+ * admin replies with during a Claude sign-in, before the message reaches
4
+ * the agent.
5
+ */
6
+
7
+ import type { Bot } from "grammy";
8
+ import type { LoginBinaries } from "../../../core/auth/login-flow.js";
9
+ import {
10
+ currentAuthPanel,
11
+ pendingCodePrompt,
12
+ submitPendingCode,
13
+ } from "../auth-panel.js";
14
+ import { isAuthorizedAdmin, type RegisterDeps } from "./state.js";
15
+
16
+ export function loginBinariesFrom(
17
+ config: RegisterDeps["config"],
18
+ ): LoginBinaries {
19
+ return {
20
+ claude: config.claudeBinary,
21
+ codex: process.env.TALON_CODEX_BINARY || config.codexBinary,
22
+ };
23
+ }
24
+
25
+ export function registerAuthCommand(bot: Bot): void {
26
+ bot.command("auth", async (ctx) => {
27
+ if (!isAuthorizedAdmin(ctx)) {
28
+ await ctx.reply("Not authorized.");
29
+ return;
30
+ }
31
+ const panel = await currentAuthPanel();
32
+ await ctx.reply(panel.text, {
33
+ parse_mode: "HTML",
34
+ reply_markup: { inline_keyboard: panel.keyboard },
35
+ });
36
+ });
37
+
38
+ // A reply to the sign-in prompt carries the pasted code. Only admins,
39
+ // only while a flow is waiting, only as a reply to that exact message —
40
+ // anything else falls through to the normal pipeline untouched.
41
+ bot.on("message:text", async (ctx, next) => {
42
+ const chatId = String(ctx.chat.id);
43
+ const pending = pendingCodePrompt(chatId);
44
+ if (
45
+ !pending ||
46
+ !isAuthorizedAdmin(ctx) ||
47
+ ctx.message.reply_to_message?.message_id !== pending.messageId
48
+ ) {
49
+ await next();
50
+ return;
51
+ }
52
+ if (submitPendingCode(chatId, ctx.message.text)) {
53
+ await ctx.reply("Got it — finishing sign-in…");
54
+ return;
55
+ }
56
+ await next();
57
+ });
58
+ }
@@ -33,6 +33,10 @@ export const TELEGRAM_COMMANDS: ReadonlyArray<{
33
33
  { command: "reset", description: "Clear session and start fresh" },
34
34
  { command: "restart", description: "Restart the bot (admin)" },
35
35
  { command: "whatsapp", description: "WhatsApp link status / pair (admin)" },
36
+ {
37
+ command: "auth",
38
+ description: "Backend logins — sign in to Claude/Codex (admin)",
39
+ },
36
40
  { command: "usage", description: "Plan limits across every backend" },
37
41
  { command: "metrics", description: "Aggregate performance metrics" },
38
42
  {
@@ -10,9 +10,9 @@
10
10
  * - `admin` — /admin /metrics /doctor /dream /soul /restart /update
11
11
  * + the unknown-command suggester
12
12
  *
13
- * `registerCommands` wires every group onto the bot, preserving the original
14
- * registration order (info → session → settings → admin, with the
15
- * unknown-command catch-all registered last).
13
+ * `registerCommands` wires every group onto the bot in an order that ends
14
+ * with `admin`, because admin owns the unknown-command catch-all and that
15
+ * must be the last handler to see a bare /command.
16
16
  */
17
17
 
18
18
  import type { Bot } from "grammy";
@@ -23,6 +23,7 @@ import { registerSessionCommands } from "./session.js";
23
23
  import { registerSettingsCommands } from "./settings.js";
24
24
  import { registerAdminCommands } from "./admin.js";
25
25
  import { registerWhatsAppPairingCommand } from "./whatsapp-pairing.js";
26
+ import { registerAuthCommand } from "./auth.js";
26
27
 
27
28
  export { telegramCommandMenu } from "./definitions.js";
28
29
  export { setAdminUserId } from "./state.js";
@@ -36,6 +37,11 @@ export function registerCommands(
36
37
  registerInfoCommands(bot);
37
38
  registerSessionCommands(bot, deps);
38
39
  registerSettingsCommands(bot, deps);
39
- registerAdminCommands(bot, deps);
40
40
  registerWhatsAppPairingCommand(bot);
41
+ registerAuthCommand(bot);
42
+ // admin LAST: it owns the unknown-command catch-all, which must only
43
+ // be reached after every real command has had its chance to match.
44
+ // Registering anything after it makes that command look unknown
45
+ // ("Unknown command /whatsapp — did you mean /whatsapp?").
46
+ registerAdminCommands(bot, deps);
41
47
  }
@@ -1,19 +1,125 @@
1
1
  /**
2
2
  * /whatsapp — drive WhatsApp device pairing from Telegram.
3
3
  *
4
- * `/whatsapp` reports link state; `/whatsapp pair` runs ONE bounded
5
- * pairing attempt via the core pairing broker and replies with the QR as
6
- * a photo (plus the phone-number code when the account has one
7
- * configured). The human runs it when they are holding the phone —
8
- * pairing is never automatic, because automatic retries are how the
9
- * account got rate-limited into "couldn't connect device".
4
+ * Bare `/whatsapp` draws a status panel with a button per sub-command;
5
+ * `/whatsapp pair` runs ONE bounded pairing attempt via the core pairing
6
+ * broker and replies with the QR as a photo (plus the phone-number code
7
+ * when the account has one configured). The human runs it when they are
8
+ * holding the phone — pairing is never automatic, because automatic
9
+ * retries are how the account got rate-limited into "couldn't connect
10
+ * device".
10
11
  */
11
12
 
12
- import { InputFile, type Bot } from "grammy";
13
+ import { InputFile, type Bot, type Context } from "grammy";
13
14
  import { getPairingProvider } from "../../../core/pairing-broker.js";
14
15
  import { log } from "../../../util/log.js";
15
16
  import { isAuthorizedAdmin } from "./state.js";
16
17
 
18
+ const WHATSAPP_NOT_ENABLED =
19
+ 'WhatsApp isn\'t enabled on this daemon — add "whatsapp" to the ' +
20
+ "frontend list and configure the whatsapp block, then restart.";
21
+
22
+ export type WhatsAppPanel = {
23
+ text: string;
24
+ keyboard: Array<Array<{ text: string; callback_data: string }>>;
25
+ };
26
+
27
+ /**
28
+ * The bare-`/whatsapp` panel: link state plus one button per
29
+ * sub-command, so the commands are discoverable instead of folklore.
30
+ */
31
+ export function whatsAppPanel(): WhatsAppPanel {
32
+ const provider = getPairingProvider();
33
+ if (!provider) {
34
+ return { text: WHATSAPP_NOT_ENABLED, keyboard: [] };
35
+ }
36
+ const linked = provider.isLinked();
37
+ const text = linked
38
+ ? "<b>WhatsApp</b>\n✅ Linked and connected.\n\n" +
39
+ "Re-link only if the phone has unlinked this device."
40
+ : "<b>WhatsApp</b>\n📴 Not linked.\n\n" +
41
+ "Tap <b>Pair device</b> when you're holding the phone — I'll reply " +
42
+ "with a QR to scan (WhatsApp → ⋮ → Linked devices → Link a device).";
43
+ return {
44
+ text,
45
+ keyboard: [
46
+ [
47
+ {
48
+ text: linked ? "🔗 Re-pair" : "🔗 Pair device",
49
+ callback_data: "whatsapp:pair",
50
+ },
51
+ { text: "🔄 Refresh", callback_data: "whatsapp:refresh" },
52
+ ],
53
+ ],
54
+ };
55
+ }
56
+
57
+ /**
58
+ * One bounded pairing attempt, shared by `/whatsapp pair` and the panel
59
+ * button so both paths behave identically.
60
+ */
61
+ export async function runWhatsAppPairing(ctx: Context): Promise<void> {
62
+ const provider = getPairingProvider();
63
+ if (!provider) {
64
+ await ctx.reply(WHATSAPP_NOT_ENABLED);
65
+ return;
66
+ }
67
+
68
+ if (provider.isLinked()) {
69
+ await ctx.reply(
70
+ "WhatsApp is already linked. Unlink it on the phone first if you " +
71
+ "want to re-pair.",
72
+ );
73
+ return;
74
+ }
75
+
76
+ let attempt;
77
+ try {
78
+ attempt = await provider.begin();
79
+ } catch (err) {
80
+ await ctx.reply(
81
+ `Couldn't start pairing: ${err instanceof Error ? err.message : err}`,
82
+ );
83
+ return;
84
+ }
85
+
86
+ if (attempt.qrPng) {
87
+ const codeLine = attempt.code
88
+ ? `\nOr enter code: ${attempt.code} (Link with phone number instead)`
89
+ : "";
90
+ await ctx.replyWithPhoto(new InputFile(attempt.qrPng, "whatsapp-qr.png"), {
91
+ caption:
92
+ "Scan within ~2 minutes:\n" +
93
+ "WhatsApp → ⋮ → Linked devices → Link a device" +
94
+ codeLine,
95
+ });
96
+ } else {
97
+ await ctx.reply(
98
+ "No QR came back — WhatsApp may still be rate-limiting. Try later.",
99
+ );
100
+ }
101
+
102
+ const outcome = await attempt.result;
103
+ if (outcome.ok) {
104
+ log("bot", `WhatsApp paired via /whatsapp pair (${outcome.identity})`);
105
+ await ctx.reply(
106
+ `✅ Linked as ${outcome.identity}. WhatsApp is connecting now.`,
107
+ );
108
+ } else if (outcome.reason === "expired") {
109
+ await ctx.reply(
110
+ "⌛ That window expired without a successful scan. Run " +
111
+ "/whatsapp pair again when you're ready — if the phone says " +
112
+ '"couldn\'t connect device", WhatsApp is still rate-limiting; ' +
113
+ "wait an hour and try once more.",
114
+ );
115
+ } else if (outcome.reason !== "cancelled") {
116
+ await ctx.reply(
117
+ `❌ Pairing failed${outcome.detail ? `: ${outcome.detail}` : ""}. ` +
118
+ "Run /whatsapp pair to try again.",
119
+ );
120
+ }
121
+ }
122
+
17
123
  export function registerWhatsAppPairingCommand(bot: Bot): void {
18
124
  bot.command("whatsapp", async (ctx) => {
19
125
  if (!isAuthorizedAdmin(ctx)) {
@@ -21,82 +127,18 @@ export function registerWhatsAppPairingCommand(bot: Bot): void {
21
127
  return;
22
128
  }
23
129
 
24
- const provider = getPairingProvider();
25
- if (!provider) {
26
- await ctx.reply(
27
- 'WhatsApp isn\'t enabled on this daemon — add "whatsapp" to the ' +
28
- "frontend list and configure the whatsapp block, then restart.",
29
- );
30
- return;
31
- }
32
-
33
130
  const arg = (ctx.match ?? "").trim().toLowerCase();
34
131
  if (arg !== "pair") {
35
- await ctx.reply(
36
- provider.isLinked()
37
- ? "✅ WhatsApp is linked and connected.\nSend /whatsapp pair to re-link."
38
- : "📴 WhatsApp is not linked.\nSend /whatsapp pair when you're " +
39
- "holding the phone — I'll reply with a QR to scan " +
40
- "(WhatsApp → Linked devices → Link a device).",
41
- );
132
+ const panel = whatsAppPanel();
133
+ await ctx.reply(panel.text, {
134
+ parse_mode: "HTML",
135
+ ...(panel.keyboard.length
136
+ ? { reply_markup: { inline_keyboard: panel.keyboard } }
137
+ : {}),
138
+ });
42
139
  return;
43
140
  }
44
141
 
45
- if (provider.isLinked()) {
46
- await ctx.reply(
47
- "WhatsApp is already linked. Unlink it on the phone first if you " +
48
- "want to re-pair.",
49
- );
50
- return;
51
- }
52
-
53
- let attempt;
54
- try {
55
- attempt = await provider.begin();
56
- } catch (err) {
57
- await ctx.reply(
58
- `Couldn't start pairing: ${err instanceof Error ? err.message : err}`,
59
- );
60
- return;
61
- }
62
-
63
- if (attempt.qrPng) {
64
- const codeLine = attempt.code
65
- ? `\nOr enter code: ${attempt.code} (Link with phone number instead)`
66
- : "";
67
- await ctx.replyWithPhoto(
68
- new InputFile(attempt.qrPng, "whatsapp-qr.png"),
69
- {
70
- caption:
71
- "Scan within ~2 minutes:\n" +
72
- "WhatsApp → ⋮ → Linked devices → Link a device" +
73
- codeLine,
74
- },
75
- );
76
- } else {
77
- await ctx.reply(
78
- "No QR came back — WhatsApp may still be rate-limiting. Try later.",
79
- );
80
- }
81
-
82
- const outcome = await attempt.result;
83
- if (outcome.ok) {
84
- log("bot", `WhatsApp paired via /whatsapp pair (${outcome.identity})`);
85
- await ctx.reply(
86
- `✅ Linked as ${outcome.identity}. WhatsApp is connecting now.`,
87
- );
88
- } else if (outcome.reason === "expired") {
89
- await ctx.reply(
90
- "⌛ That window expired without a successful scan. Run " +
91
- "/whatsapp pair again when you're ready — if the phone says " +
92
- '"couldn\'t connect device", WhatsApp is still rate-limiting; ' +
93
- "wait an hour and try once more.",
94
- );
95
- } else if (outcome.reason !== "cancelled") {
96
- await ctx.reply(
97
- `❌ Pairing failed${outcome.detail ? `: ${outcome.detail}` : ""}. ` +
98
- "Run /whatsapp pair to try again.",
99
- );
100
- }
142
+ await runWhatsAppPairing(ctx);
101
143
  });
102
144
  }
@@ -3,10 +3,10 @@
3
3
  *
4
4
  * Group data comes from `groupMetadata`, which WhatsApp serves per JID
5
5
  * and Baileys caches; DMs have no metadata endpoint, so their answers are
6
- * assembled from the registry and the profile endpoints.
6
+ * assembled from the registry and the profile endpoints. Message
7
+ * retrieval (get_message_by_id, download_media) lives in history.ts.
7
8
  */
8
9
 
9
- import { lookupMessage } from "../message-store.js";
10
10
  import { toUserJid, tryAction } from "./shared.js";
11
11
  import type { WhatsAppActionHandlers } from "./types.js";
12
12
 
@@ -188,38 +188,4 @@ export const chatInfoHandlers: WhatsAppActionHandlers = {
188
188
  );
189
189
  return { ok: true };
190
190
  }),
191
-
192
- /**
193
- * Re-download a message's media on demand. Inbound media is already
194
- * saved to the workspace as it arrives; this covers anything the model
195
- * wants to pull again by id.
196
- */
197
- download_media: (body, _chatId, ctx) =>
198
- tryAction("download_media", async () => {
199
- const raw = body.message_id;
200
- const msgId = typeof raw === "number" ? raw : Number(raw);
201
- const stored = Number.isFinite(msgId) ? lookupMessage(msgId) : undefined;
202
- if (!stored || stored.chatId !== ctx.chat!.chatId) {
203
- return { ok: false, error: `Unknown message_id ${String(raw)}` };
204
- }
205
- if (!stored.message) {
206
- return {
207
- ok: false,
208
- error: `Message ${msgId} is no longer retained in full`,
209
- };
210
- }
211
- const { saveInboundMedia } = await import("../media-store.js");
212
- const saved = await saveInboundMedia(
213
- stored.message,
214
- ctx.chat!.chatId,
215
- msgId,
216
- stored.senderName,
217
- );
218
- return saved
219
- ? { ok: true, text: saved.filePath, file_path: saved.filePath }
220
- : {
221
- ok: false,
222
- error: `Message ${msgId} carries no downloadable media`,
223
- };
224
- }),
225
191
  };
@@ -0,0 +1,100 @@
1
+ /**
2
+ * History retrieval actions — the WhatsApp side of `get_message_by_id`
3
+ * and `download_media`.
4
+ *
5
+ * Reading and searching history need nothing WhatsApp-specific: the
6
+ * core's shared handlers serve `read_history` / `search_history` from
7
+ * the persistent store every inbound and outbound message lands in.
8
+ * Fetching one message by id and pulling its media do: the id has to
9
+ * resolve to a WhatsApp key, and a file that was never saved (or has
10
+ * since expired from the workspace) has to come back down from
11
+ * WhatsApp — via the sender's re-upload when the CDN copy is gone.
12
+ */
13
+
14
+ import { existsSync } from "node:fs";
15
+ import {
16
+ getHistoryMessage,
17
+ getMessageById,
18
+ setMessageFilePath,
19
+ } from "../../../storage/history.js";
20
+ import { saveInboundMedia } from "../media-store.js";
21
+ import { lookupMessage, resolveKey } from "../message-store.js";
22
+ import { tryAction } from "./shared.js";
23
+ import type { WhatsAppActionHandlers } from "./types.js";
24
+
25
+ function toMsgId(raw: unknown): number {
26
+ return typeof raw === "number" ? raw : Number(raw);
27
+ }
28
+
29
+ export const historyHandlers: WhatsAppActionHandlers = {
30
+ get_message_by_id: (body, _chatId, ctx) =>
31
+ tryAction("get_message_by_id", async () => {
32
+ const chatId = ctx.chat!.chatId;
33
+ const msgId = toMsgId(body.message_id);
34
+ if (!Number.isFinite(msgId)) {
35
+ return {
36
+ ok: false,
37
+ error: `Invalid message_id: ${String(body.message_id)}`,
38
+ };
39
+ }
40
+ const row = getHistoryMessage(chatId, msgId);
41
+ const stored = lookupMessage(msgId);
42
+ if (!row && (!stored || stored.chatId !== chatId)) {
43
+ return { ok: false, error: `Message ${msgId} not found in this chat.` };
44
+ }
45
+ const lines = [
46
+ row
47
+ ? getMessageById(chatId, msgId)
48
+ : `[${new Date(stored!.timestamp).toISOString()}] ${stored!.senderName}: ${stored!.text}`,
49
+ ];
50
+ if (row?.replyToMsgId) lines.push(`reply to: msg_id ${row.replyToMsgId}`);
51
+ if (row?.mediaType) {
52
+ const saved = row.filePath && existsSync(row.filePath);
53
+ lines.push(
54
+ saved
55
+ ? `${row.mediaType}: ${row.filePath}`
56
+ : `${row.mediaType}: not on disk — download_media ${msgId} fetches it`,
57
+ );
58
+ }
59
+ return { ok: true, text: lines.join("\n") };
60
+ }),
61
+
62
+ /**
63
+ * Media for a message by id. Inbound media is saved as it arrives, so
64
+ * the saved file is answered first; otherwise the retained proto is
65
+ * downloaded again, asking the sender to re-upload when WhatsApp's
66
+ * CDN copy has expired.
67
+ */
68
+ download_media: (body, _chatId, ctx) =>
69
+ tryAction("download_media", async () => {
70
+ const chatId = ctx.chat!.chatId;
71
+ const resolved = resolveKey(body.message_id, chatId);
72
+ if ("error" in resolved) return { ok: false, error: resolved.error };
73
+ const { stored } = resolved;
74
+ const row = getHistoryMessage(chatId, stored.msgId);
75
+ if (row?.filePath && existsSync(row.filePath)) {
76
+ return { ok: true, text: row.filePath, file_path: row.filePath };
77
+ }
78
+ if (!stored.message) {
79
+ return {
80
+ ok: false,
81
+ error: `Message ${stored.msgId} was not retained in full, so its media cannot be re-downloaded`,
82
+ };
83
+ }
84
+ const saved = await saveInboundMedia(
85
+ stored.message,
86
+ chatId,
87
+ stored.msgId,
88
+ stored.senderName,
89
+ { reuploadRequest: ctx.sock.updateMediaMessage },
90
+ );
91
+ if (!saved) {
92
+ return {
93
+ ok: false,
94
+ error: `Message ${stored.msgId} carries no downloadable media`,
95
+ };
96
+ }
97
+ setMessageFilePath(chatId, stored.msgId, saved.filePath);
98
+ return { ok: true, text: saved.filePath, file_path: saved.filePath };
99
+ }),
100
+ };
@@ -12,7 +12,8 @@
12
12
  * - shared — tryAction, media/quote resolution, send helpers
13
13
  * - messaging — text, replies, reactions, edits, deletes, pins, presence
14
14
  * - media — images, video, audio, documents, polls, locations, …
15
- * - chat-info — group metadata, members, titles, media download
15
+ * - chat-info — group metadata, members, titles
16
+ * - history — get_message_by_id, download_media (re-upload aware)
16
17
  * - moderation — the `moderate` op switch
17
18
  */
18
19
 
@@ -25,6 +26,7 @@ import {
25
26
  type WhatsAppChatInfo,
26
27
  } from "../registry.js";
27
28
  import { chatInfoHandlers } from "./chat-info.js";
29
+ import { historyHandlers } from "./history.js";
28
30
  import { mediaHandlers } from "./media.js";
29
31
  import { messagingHandlers } from "./messaging.js";
30
32
  import { moderationHandlers } from "./moderation.js";
@@ -36,6 +38,7 @@ const handlers: WhatsAppActionHandlers = Object.assign(Object.create(null), {
36
38
  ...messagingHandlers,
37
39
  ...mediaHandlers,
38
40
  ...chatInfoHandlers,
41
+ ...historyHandlers,
39
42
  ...moderationHandlers,
40
43
  });
41
44