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.
- package/LICENSE +1 -1
- package/README.md +0 -10
- package/package.json +3 -3
- package/src/backend/codex/auth.ts +40 -1
- package/src/backend/codex/handler/message.ts +16 -1
- package/src/backend/codex/plan-usage.ts +27 -2
- package/src/backend/kilo/handler/message.ts +2 -0
- package/src/backend/kilo/server.ts +1 -0
- package/src/backend/opencode/handler/message.ts +2 -0
- package/src/backend/opencode/server.ts +1 -0
- package/src/backend/remote-server/chat-turn.ts +13 -3
- package/src/backend/remote-server/lifecycle.ts +37 -0
- package/src/backend/remote-server/server-bindings.ts +12 -0
- package/src/backend/remote-server/state.ts +8 -0
- package/src/backend/remote-server/turn.ts +93 -23
- package/src/bootstrap.ts +24 -10
- package/src/core/auth/expiry-monitor.ts +89 -0
- package/src/core/auth/login-flow.ts +247 -0
- package/src/core/auth/status.ts +193 -0
- package/src/core/engine/gateway-actions/history.ts +12 -1
- package/src/core/errors.ts +1 -1
- package/src/core/tools/history.ts +10 -1
- package/src/frontend/native/chat-wire.ts +6 -2
- package/src/frontend/native/emit.ts +30 -11
- package/src/frontend/native/handlers.ts +62 -13
- package/src/frontend/native/history.ts +30 -6
- package/src/frontend/native/media.ts +271 -15
- package/src/frontend/native/protocol.ts +34 -1
- package/src/frontend/native/queue.ts +4 -7
- package/src/frontend/native/routes/chats.ts +30 -12
- package/src/frontend/native/routes/host.ts +17 -5
- package/src/frontend/native/routes/params.ts +21 -0
- package/src/frontend/native/runtime.ts +16 -4
- package/src/frontend/native/server.ts +2 -35
- package/src/frontend/native/status.ts +3 -1
- package/src/frontend/native/turn.ts +39 -17
- package/src/frontend/shared/model-commands.ts +400 -0
- package/src/frontend/telegram/auth-panel.ts +205 -0
- package/src/frontend/telegram/callbacks/auth.ts +70 -0
- package/src/frontend/telegram/callbacks/index.ts +16 -0
- package/src/frontend/telegram/callbacks/whatsapp.ts +50 -0
- package/src/frontend/telegram/commands/auth.ts +58 -0
- package/src/frontend/telegram/commands/definitions.ts +4 -0
- package/src/frontend/telegram/commands/index.ts +10 -4
- package/src/frontend/telegram/commands/whatsapp-pairing.ts +121 -79
- package/src/frontend/whatsapp/actions/chat-info.ts +2 -36
- package/src/frontend/whatsapp/actions/history.ts +100 -0
- package/src/frontend/whatsapp/actions/index.ts +4 -1
- package/src/frontend/whatsapp/commands.ts +371 -0
- package/src/frontend/whatsapp/inbound.ts +24 -46
- package/src/frontend/whatsapp/index.ts +12 -1
- package/src/frontend/whatsapp/media-store.ts +20 -1
- package/src/frontend/whatsapp/message-store.ts +101 -24
- package/src/storage/db.ts +7 -1
- package/src/storage/history.ts +26 -2
- package/src/storage/repositories/history-repo.ts +61 -1
- package/src/storage/repositories/whatsapp-messages-repo.ts +101 -0
- package/src/storage/sql/db.sql +6 -0
- package/src/storage/sql/history.sql +22 -9
- package/src/storage/sql/schema.sql +27 -1
- package/src/storage/sql/statements.generated.ts +70 -11
- package/src/storage/sql/whatsapp-messages.sql +29 -0
- 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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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`
|
|
5
|
-
*
|
|
6
|
-
* a photo (plus the phone-number code
|
|
7
|
-
* configured). The human runs it when they are
|
|
8
|
-
* pairing is never automatic, because automatic
|
|
9
|
-
* account got rate-limited into "couldn't connect
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|