talon-agent 3.34.0 → 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 (70) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +0 -10
  3. package/package.json +6 -4
  4. package/src/backend/claude-sdk/handler.ts +428 -415
  5. package/src/backend/codex/auth.ts +40 -1
  6. package/src/backend/codex/handler/message.ts +290 -366
  7. package/src/backend/codex/handler/rollout-accounting.ts +137 -0
  8. package/src/backend/codex/plan-usage.ts +27 -2
  9. package/src/backend/kilo/handler/message.ts +2 -0
  10. package/src/backend/kilo/server.ts +1 -0
  11. package/src/backend/openai-agents/handler/message.ts +282 -356
  12. package/src/backend/opencode/handler/message.ts +2 -0
  13. package/src/backend/opencode/server.ts +1 -0
  14. package/src/backend/remote-server/chat-turn.ts +76 -122
  15. package/src/backend/remote-server/index.ts +4 -0
  16. package/src/backend/remote-server/lifecycle.ts +37 -0
  17. package/src/backend/remote-server/mcp.ts +73 -9
  18. package/src/backend/remote-server/model-catalog/presentation.ts +269 -228
  19. package/src/backend/remote-server/server-bindings.ts +12 -0
  20. package/src/backend/remote-server/sessions.ts +2 -2
  21. package/src/backend/remote-server/state.ts +8 -0
  22. package/src/backend/remote-server/turn.ts +93 -23
  23. package/src/backend/shared/cache-telemetry.ts +17 -1
  24. package/src/backend/shared/handler-to-events.ts +11 -16
  25. package/src/backend/shared/index.ts +14 -15
  26. package/src/backend/shared/result-events.ts +30 -0
  27. package/src/backend/shared/turn-phases.ts +277 -0
  28. package/src/bootstrap.ts +24 -10
  29. package/src/core/auth/expiry-monitor.ts +89 -0
  30. package/src/core/auth/login-flow.ts +247 -0
  31. package/src/core/auth/status.ts +193 -0
  32. package/src/core/background/triggers/exit.ts +19 -2
  33. package/src/core/background/triggers/resume.ts +14 -7
  34. package/src/core/background/triggers/state.ts +9 -0
  35. package/src/core/engine/gateway-actions/history.ts +12 -1
  36. package/src/core/errors.ts +1 -1
  37. package/src/core/mcp-hub/child-transport.ts +215 -0
  38. package/src/core/mcp-hub/children.ts +72 -13
  39. package/src/core/mcp-hub/index.ts +32 -13
  40. package/src/core/models/active-model.ts +29 -6
  41. package/src/core/tools/history.ts +10 -1
  42. package/src/core/weaver/shuttle.ts +4 -0
  43. package/src/frontend/shared/model-commands.ts +400 -0
  44. package/src/frontend/telegram/admin.ts +75 -52
  45. package/src/frontend/telegram/auth-panel.ts +205 -0
  46. package/src/frontend/telegram/callbacks/auth.ts +70 -0
  47. package/src/frontend/telegram/callbacks/index.ts +16 -0
  48. package/src/frontend/telegram/callbacks/whatsapp.ts +50 -0
  49. package/src/frontend/telegram/commands/auth.ts +58 -0
  50. package/src/frontend/telegram/commands/definitions.ts +4 -0
  51. package/src/frontend/telegram/commands/index.ts +10 -4
  52. package/src/frontend/telegram/commands/whatsapp-pairing.ts +121 -79
  53. package/src/frontend/whatsapp/actions/chat-info.ts +2 -36
  54. package/src/frontend/whatsapp/actions/history.ts +100 -0
  55. package/src/frontend/whatsapp/actions/index.ts +4 -1
  56. package/src/frontend/whatsapp/commands.ts +371 -0
  57. package/src/frontend/whatsapp/inbound.ts +24 -46
  58. package/src/frontend/whatsapp/index.ts +12 -1
  59. package/src/frontend/whatsapp/media-store.ts +20 -1
  60. package/src/frontend/whatsapp/message-store.ts +101 -24
  61. package/src/storage/history.ts +22 -1
  62. package/src/storage/repositories/history-repo.ts +14 -0
  63. package/src/storage/repositories/whatsapp-messages-repo.ts +101 -0
  64. package/src/storage/sql/history.sql +12 -0
  65. package/src/storage/sql/schema.sql +21 -0
  66. package/src/storage/sql/statements.generated.ts +50 -1
  67. package/src/storage/sql/whatsapp-messages.sql +29 -0
  68. package/src/storage/trigger-store.ts +8 -0
  69. package/src/storage/whatsapp-messages.ts +51 -0
  70. package/src/util/watchdog.ts +32 -7
@@ -0,0 +1,205 @@
1
+ /**
2
+ * /auth panel — sign the daemon's Claude and Codex CLIs back in from
3
+ * Telegram, admin only.
4
+ *
5
+ * The panel lists each provider's login state with a sign-in button. A
6
+ * tap starts the core login flow and rewrites the same message into the
7
+ * instructions the CLI printed: an "Open sign-in page" URL button plus,
8
+ * for Codex, the one-time device code, or, for Claude, a request to reply
9
+ * with the code the browser shows. The message is edited again when the
10
+ * CLI finishes, so the whole exchange lives in one bubble.
11
+ */
12
+
13
+ import type { Context } from "grammy";
14
+ import type { InlineKeyboardButton } from "grammy/types";
15
+ import {
16
+ activeLoginFlow,
17
+ startLogin,
18
+ type LoginBinaries,
19
+ type LoginFlow,
20
+ type LoginPrompt,
21
+ } from "../../core/auth/login-flow.js";
22
+ import {
23
+ AUTH_PROVIDERS,
24
+ describeProviderStatus,
25
+ PROVIDER_LABELS,
26
+ readAllProviderStatus,
27
+ type AuthProvider,
28
+ type ProviderAuthStatus,
29
+ } from "../../core/auth/status.js";
30
+ import { logWarn } from "../../util/log.js";
31
+ import { escapeHtml } from "./formatting.js";
32
+
33
+ type AuthKeyboard = InlineKeyboardButton[][];
34
+
35
+ export interface AuthPanel {
36
+ text: string;
37
+ keyboard: AuthKeyboard;
38
+ }
39
+
40
+ export function isAuthProvider(value: string): value is AuthProvider {
41
+ return (AUTH_PROVIDERS as readonly string[]).includes(value);
42
+ }
43
+
44
+ function statusIcon(s: ProviderAuthStatus): string {
45
+ if (!s.loggedIn || s.expired) return "🔴";
46
+ if (
47
+ s.loginExpiresAt !== undefined &&
48
+ s.loginExpiresAt - Date.now() < 7 * 86_400_000
49
+ )
50
+ return "🟡";
51
+ return "🟢";
52
+ }
53
+
54
+ /** The resting panel: one status line + one sign-in button per provider. */
55
+ export function renderAuthPanel(statuses: ProviderAuthStatus[]): AuthPanel {
56
+ const lines = ["<b>🔑 Backend logins</b>", ""];
57
+ const keyboard: AuthKeyboard = [];
58
+ for (const s of statuses) {
59
+ const label = PROVIDER_LABELS[s.provider];
60
+ lines.push(
61
+ `${statusIcon(s)} <b>${label}</b> — ${escapeHtml(describeProviderStatus(s))}`,
62
+ );
63
+ const pending = activeLoginFlow(s.provider);
64
+ keyboard.push([
65
+ pending
66
+ ? {
67
+ text: `⏳ ${label} sign-in in progress…`,
68
+ callback_data: `auth:resume:${s.provider}`,
69
+ }
70
+ : {
71
+ text: `${s.loggedIn && !s.expired ? "🔄 Re-sign in to" : "🔑 Sign in to"} ${label}`,
72
+ callback_data: `auth:login:${s.provider}`,
73
+ },
74
+ ]);
75
+ }
76
+ keyboard.push([{ text: "↻ Refresh", callback_data: "auth:refresh" }]);
77
+ return { text: lines.join("\n"), keyboard };
78
+ }
79
+
80
+ /** The in-progress panel: the CLI's instructions, an open-link button, cancel. */
81
+ export function renderLoginPrompt(
82
+ provider: AuthProvider,
83
+ prompt: LoginPrompt,
84
+ ): AuthPanel {
85
+ const label = PROVIDER_LABELS[provider];
86
+ const lines = [
87
+ `<b>🔑 Sign in to ${label}</b>`,
88
+ "",
89
+ "1. Open the sign-in page (button below) and log in.",
90
+ ];
91
+ if (prompt.code) {
92
+ lines.push(
93
+ `2. Enter this one-time code: <code>${escapeHtml(prompt.code)}</code>`,
94
+ );
95
+ }
96
+ if (prompt.needsCode) {
97
+ lines.push(
98
+ `${prompt.code ? 3 : 2}. Copy the code the page shows and <b>reply to this message</b> with it.`,
99
+ );
100
+ }
101
+ lines.push("", "<i>This link expires in 15 minutes.</i>");
102
+ return {
103
+ text: lines.join("\n"),
104
+ keyboard: [
105
+ [{ text: "🌐 Open sign-in page", url: prompt.url }],
106
+ [{ text: "✖ Cancel", callback_data: `auth:cancel:${provider}` }],
107
+ ],
108
+ };
109
+ }
110
+
111
+ export async function currentAuthPanel(): Promise<AuthPanel> {
112
+ return renderAuthPanel(await readAllProviderStatus());
113
+ }
114
+
115
+ /** Messages awaiting a pasted code, keyed by chat → prompt message id. */
116
+ const awaitingCode = new Map<
117
+ string,
118
+ { provider: AuthProvider; messageId: number }
119
+ >();
120
+
121
+ export function pendingCodePrompt(
122
+ chatId: string,
123
+ ): { provider: AuthProvider; messageId: number } | undefined {
124
+ return awaitingCode.get(chatId);
125
+ }
126
+
127
+ async function editPanel(
128
+ ctx: Context,
129
+ chatId: number,
130
+ messageId: number,
131
+ panel: AuthPanel,
132
+ ): Promise<void> {
133
+ try {
134
+ await ctx.api.editMessageText(chatId, messageId, panel.text, {
135
+ parse_mode: "HTML",
136
+ reply_markup: { inline_keyboard: panel.keyboard },
137
+ });
138
+ } catch (err) {
139
+ const msg = err instanceof Error ? err.message : String(err);
140
+ if (!/message is not modified/i.test(msg))
141
+ logWarn("bot", `auth panel edit failed: ${msg}`);
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Start (or attach to) a login for `provider` and drive the panel message
147
+ * through prompt → outcome. Returns once the prompt is on screen; the
148
+ * outcome edit happens when the CLI exits.
149
+ */
150
+ export async function driveLogin(
151
+ ctx: Context,
152
+ chatId: number,
153
+ messageId: number,
154
+ provider: AuthProvider,
155
+ bins: LoginBinaries,
156
+ ): Promise<void> {
157
+ const flow: LoginFlow =
158
+ activeLoginFlow(provider) ?? startLogin(provider, bins);
159
+ let prompt: LoginPrompt;
160
+ try {
161
+ prompt = await flow.prompt;
162
+ } catch (err) {
163
+ const detail = err instanceof Error ? err.message : String(err);
164
+ const panel = await currentAuthPanel();
165
+ await editPanel(ctx, chatId, messageId, {
166
+ text: `${panel.text}\n\n❌ Couldn't start ${PROVIDER_LABELS[provider]} sign-in: ${escapeHtml(detail)}`,
167
+ keyboard: panel.keyboard,
168
+ });
169
+ return;
170
+ }
171
+ if (prompt.needsCode)
172
+ awaitingCode.set(String(chatId), { provider, messageId });
173
+ await editPanel(ctx, chatId, messageId, renderLoginPrompt(provider, prompt));
174
+
175
+ void flow.done.then(async (outcome) => {
176
+ if (awaitingCode.get(String(chatId))?.messageId === messageId)
177
+ awaitingCode.delete(String(chatId));
178
+ const panel = await currentAuthPanel();
179
+ const label = PROVIDER_LABELS[provider];
180
+ const note = outcome.ok
181
+ ? `✅ ${label} signed in.`
182
+ : outcome.reason === "cancelled"
183
+ ? `✖ ${label} sign-in cancelled.`
184
+ : outcome.reason === "timeout"
185
+ ? `⌛ ${label} sign-in link expired. Tap the button to try again.`
186
+ : `❌ ${label} sign-in failed: ${escapeHtml(outcome.detail ?? "unknown error")}`;
187
+ await editPanel(ctx, chatId, messageId, {
188
+ text: `${panel.text}\n\n${note}`,
189
+ keyboard: panel.keyboard,
190
+ });
191
+ });
192
+ }
193
+
194
+ /** Feed a pasted code to the pending flow for this chat. True when consumed. */
195
+ export function submitPendingCode(chatId: string, text: string): boolean {
196
+ const pending = awaitingCode.get(chatId);
197
+ if (!pending) return false;
198
+ const flow = activeLoginFlow(pending.provider);
199
+ if (!flow) {
200
+ awaitingCode.delete(chatId);
201
+ return false;
202
+ }
203
+ flow.submitCode(text);
204
+ return true;
205
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * `auth:*` callbacks — the /auth panel's buttons.
3
+ *
4
+ * auth:login:<provider> start a sign-in and show its instructions
5
+ * auth:resume:<provider> re-show the instructions of a running sign-in
6
+ * auth:cancel:<provider> abort a running sign-in
7
+ * auth:refresh re-read credential files and redraw
8
+ */
9
+
10
+ import type { Context } from "grammy";
11
+ import { activeLoginFlow } from "../../../core/auth/login-flow.js";
12
+ import { currentAuthPanel, driveLogin, isAuthProvider } from "../auth-panel.js";
13
+ import { loginBinariesFrom } from "../commands/auth.js";
14
+ import { isAuthorizedAdmin } from "../commands/state.js";
15
+ import { answerCallbackQuerySafe, type CallbackDeps } from "./shared.js";
16
+
17
+ export async function handleAuthCallback(
18
+ ctx: Context,
19
+ data: string,
20
+ { config }: CallbackDeps,
21
+ ): Promise<void> {
22
+ if (!isAuthorizedAdmin(ctx)) {
23
+ await answerCallbackQuerySafe(ctx, { text: "Not authorized." });
24
+ return;
25
+ }
26
+ const chatId = ctx.chat?.id;
27
+ const messageId = ctx.callbackQuery?.message?.message_id;
28
+ if (chatId === undefined || messageId === undefined) {
29
+ await answerCallbackQuerySafe(ctx);
30
+ return;
31
+ }
32
+ const [, action, provider = ""] = data.split(":");
33
+
34
+ if (action === "refresh") {
35
+ await answerCallbackQuerySafe(ctx);
36
+ const panel = await currentAuthPanel();
37
+ await ctx.api
38
+ .editMessageText(chatId, messageId, panel.text, {
39
+ parse_mode: "HTML",
40
+ reply_markup: { inline_keyboard: panel.keyboard },
41
+ })
42
+ .catch(() => {});
43
+ return;
44
+ }
45
+
46
+ if (!isAuthProvider(provider)) {
47
+ await answerCallbackQuerySafe(ctx, { text: "Unknown provider" });
48
+ return;
49
+ }
50
+
51
+ if (action === "cancel") {
52
+ activeLoginFlow(provider)?.cancel();
53
+ await answerCallbackQuerySafe(ctx, { text: "Cancelled" });
54
+ return;
55
+ }
56
+
57
+ if (action === "login" || action === "resume") {
58
+ await answerCallbackQuerySafe(ctx, { text: "Starting sign-in…" });
59
+ await driveLogin(
60
+ ctx,
61
+ chatId,
62
+ messageId,
63
+ provider,
64
+ loginBinariesFrom(config),
65
+ );
66
+ return;
67
+ }
68
+
69
+ await answerCallbackQuerySafe(ctx, { text: "Invalid callback data" });
70
+ }
@@ -9,6 +9,8 @@
9
9
  * - `effort` — `effort:*`
10
10
  * - `metrics` — `metrics:*` (today ↔ all-time panel grain)
11
11
  * - `model` — `model:*` (menu / backend / browse controller)
12
+ * - `auth` — `auth:*` (backend sign-in panel, admin only)
13
+ * - `whatsapp` — `whatsapp:*` (WhatsApp link panel, admin only)
12
14
  *
13
15
  * `registerCallbacks` installs one `callback_query:data` listener that
14
16
  * dispatches on the data prefix, preserving the original order and the
@@ -25,6 +27,8 @@ import { handlePulseCallback } from "./pulse.js";
25
27
  import { handleEffortCallback } from "./effort.js";
26
28
  import { handleMetricsCallback } from "./metrics.js";
27
29
  import { handleModelCallback } from "./model.js";
30
+ import { handleAuthCallback } from "./auth.js";
31
+ import { handleWhatsAppCallback } from "./whatsapp.js";
28
32
 
29
33
  export { answerCallbackQuerySafe } from "./shared.js";
30
34
 
@@ -70,6 +74,18 @@ export function registerCallbacks(
70
74
  return;
71
75
  }
72
76
 
77
+ // Handle /auth (backend CLI sign-in) callbacks.
78
+ if (data.startsWith("auth:")) {
79
+ await handleAuthCallback(ctx, data, deps);
80
+ return;
81
+ }
82
+
83
+ // Handle /whatsapp (link status / pairing) callbacks.
84
+ if (data.startsWith("whatsapp:")) {
85
+ await handleWhatsAppCallback(ctx, data);
86
+ return;
87
+ }
88
+
73
89
  // Forward other callbacks to the AI backend
74
90
  handleCallbackQuery(ctx, bot, config);
75
91
  });
@@ -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
  }