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,400 @@
1
+ /**
2
+ * Frontend-agnostic `/model`, `/effort` and backend-switch operations.
3
+ *
4
+ * Telegram and Discord drive these through inline buttons and select
5
+ * menus; a text-only frontend (WhatsApp) has to do the same work from a
6
+ * typed argument — "the third model in the list", "switch to codex",
7
+ * "effort high". The state changes are identical either way, so they
8
+ * live here, shaped as plain data + plain-text outcomes that every
9
+ * frontend can wrap in its own markup. Nothing in this module knows
10
+ * about a socket, a bot API, or a keyboard.
11
+ */
12
+
13
+ import type { TalonConfig } from "../../util/config.js";
14
+ import type { Backend } from "../../core/agent-runtime/capabilities.js";
15
+ import type { ReasoningEffortLevel } from "../../core/types.js";
16
+ import {
17
+ getChatSettings,
18
+ setChatBackend,
19
+ setChatEffort,
20
+ setChatModelForBackend,
21
+ type EffortLevel,
22
+ } from "../../storage/chat-settings.js";
23
+ import { resolveModelId as resolveModelName } from "../../core/models/catalog.js";
24
+ import { resolveActiveModelForChat } from "../../core/models/active-model.js";
25
+ import {
26
+ getBackendIdForChat,
27
+ hasBackendPool,
28
+ hasChatBackendOverride,
29
+ listAvailableBackends,
30
+ rebindChat,
31
+ releaseChat,
32
+ resolveChatBackend,
33
+ } from "../../core/engine/backend-controller/index.js";
34
+ import { resetSession } from "../../storage/sessions.js";
35
+ import { clearHistory } from "../../storage/history.js";
36
+ import { resetPulseCheckpoint } from "../../core/background/pulse.js";
37
+ import { logWarn } from "../../util/log.js";
38
+ import {
39
+ displayReasoningEffort,
40
+ getActiveReasoningLevels,
41
+ supportsReasoningLevel,
42
+ } from "./reasoning-levels.js";
43
+
44
+ export type ModelCommandDeps = {
45
+ config: TalonConfig;
46
+ gateway?: { backend: Backend | null };
47
+ };
48
+
49
+ /** Outcome of a state-changing command, ready to be sent as-is. */
50
+ export type CommandOutcome = { ok: boolean; text: string };
51
+
52
+ /** One selectable catalog entry, numbered for text pickers. */
53
+ type ModelChoice = {
54
+ /** 1-based position in the listing — what `/model <n>` refers to. */
55
+ index: number;
56
+ id: string;
57
+ displayName: string;
58
+ providerName: string;
59
+ free: boolean;
60
+ };
61
+
62
+ export type ModelOverview = {
63
+ backendId: string;
64
+ backendLabel: string;
65
+ hasBackendOverride: boolean;
66
+ backends: Array<{ id: string; label: string }>;
67
+ /** Resolved active model id, or null when the chat has none to run on. */
68
+ activeModel: string | null;
69
+ activeDisplay: string | null;
70
+ /** True when the chat pins a model other than the backend's default. */
71
+ hasModelOverride: boolean;
72
+ choices: ModelChoice[];
73
+ };
74
+
75
+ /** The backend + id serving a chat right now (pool-aware, test-safe). */
76
+ export function resolveChatBackendPair(
77
+ chatId: string,
78
+ deps: ModelCommandDeps,
79
+ ): { backend: Backend | null; backendId: string } {
80
+ return {
81
+ backend: resolveChatBackend(chatId, deps.gateway?.backend ?? null),
82
+ backendId: hasBackendPool()
83
+ ? getBackendIdForChat(chatId)
84
+ : deps.config.backend,
85
+ };
86
+ }
87
+
88
+ /**
89
+ * The selectable catalog of the chat's backend, numbered in catalog
90
+ * order. Same call on the listing and on the pick, so `/model 3` names
91
+ * the same entry the user just read. Empty for fixed-model backends.
92
+ */
93
+ async function listModelChoices(
94
+ backend: Backend | null,
95
+ ): Promise<ModelChoice[]> {
96
+ if (!backend?.models?.listModels) return [];
97
+ try {
98
+ const { models } = await backend.models.listModels("all");
99
+ return models
100
+ .filter((m) => m.selectable)
101
+ .map((m, i) => ({
102
+ index: i + 1,
103
+ id: m.id,
104
+ displayName: m.displayName,
105
+ providerName: m.providerName,
106
+ free: m.free === true,
107
+ }));
108
+ } catch (err) {
109
+ logWarn(
110
+ "settings",
111
+ `listModels failed: ${err instanceof Error ? err.message : String(err)}`,
112
+ );
113
+ return [];
114
+ }
115
+ }
116
+
117
+ /** Everything a text `/model` listing shows. */
118
+ export async function describeChatModels(
119
+ chatId: string,
120
+ deps: ModelCommandDeps,
121
+ ): Promise<ModelOverview> {
122
+ const { backend, backendId } = resolveChatBackendPair(chatId, deps);
123
+ const backends = listAvailableBackends(deps.config);
124
+ const backendLabel =
125
+ backends.find((b) => b.id === backendId)?.label ??
126
+ backend?.label ??
127
+ backendId;
128
+ const { model, ref, source } = await resolveActiveModelForChat(
129
+ chatId,
130
+ backend,
131
+ backendId,
132
+ deps.config,
133
+ );
134
+ return {
135
+ backendId,
136
+ backendLabel,
137
+ hasBackendOverride: hasBackendPool() && hasChatBackendOverride(chatId),
138
+ backends,
139
+ activeModel: model,
140
+ activeDisplay: ref?.displayName ?? model,
141
+ hasModelOverride: source === "override-valid",
142
+ choices: await listModelChoices(backend),
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Pin a model for the chat. `query` is a catalog id, a fuzzy name the
148
+ * backend resolves, or a 1-based number from `describeChatModels`.
149
+ * Mirrors Telegram's `/model <arg>`: the pick lands in the backend's
150
+ * own slot and the chat is pinned to that backend so a restart can't
151
+ * orphan the id.
152
+ */
153
+ export async function selectChatModel(
154
+ chatId: string,
155
+ query: string,
156
+ deps: ModelCommandDeps,
157
+ ): Promise<CommandOutcome> {
158
+ const { backend, backendId } = resolveChatBackendPair(chatId, deps);
159
+ let wanted = query.trim();
160
+ if (/^\d+$/.test(wanted)) {
161
+ const choices = await listModelChoices(backend);
162
+ const choice = choices[Number(wanted) - 1];
163
+ if (!choice) {
164
+ return {
165
+ ok: false,
166
+ text: choices.length
167
+ ? `No model #${wanted} — the list has ${choices.length} entries.`
168
+ : `This backend has no numbered model list; give a model id instead.`,
169
+ };
170
+ }
171
+ wanted = choice.id;
172
+ }
173
+
174
+ if (!backend?.models?.resolveModelInfo) {
175
+ const model = resolveModelName(wanted);
176
+ setChatModelForBackend(chatId, backendId, model);
177
+ setChatBackend(chatId, backendId);
178
+ return { ok: true, text: `Model set to ${model}.` };
179
+ }
180
+ const resolution = await backend.models.resolveModelInfo(wanted);
181
+ if (resolution.kind !== "exact") {
182
+ return {
183
+ ok: false,
184
+ text:
185
+ backend.models.formatModelError?.(wanted, resolution) ??
186
+ `No model matched "${wanted}".`,
187
+ };
188
+ }
189
+ if (!resolution.model.selectable) {
190
+ return {
191
+ ok: false,
192
+ text:
193
+ resolution.model.unavailableReason ??
194
+ `${resolution.model.providerName} is not connected.`,
195
+ };
196
+ }
197
+ setChatModelForBackend(chatId, backendId, resolution.storedValue);
198
+ setChatBackend(chatId, backendId);
199
+ const tag = resolution.model.free ? " · free" : "";
200
+ return {
201
+ ok: true,
202
+ text: `Model set to ${resolution.storedValue} (${resolution.model.providerName}${tag}).`,
203
+ };
204
+ }
205
+
206
+ /** Clear the chat's pick on its current backend; other backends' picks stay. */
207
+ export async function resetChatModel(
208
+ chatId: string,
209
+ deps: ModelCommandDeps,
210
+ ): Promise<CommandOutcome> {
211
+ const { backend, backendId } = resolveChatBackendPair(chatId, deps);
212
+ setChatModelForBackend(chatId, backendId, undefined);
213
+ const { model } = await resolveActiveModelForChat(
214
+ chatId,
215
+ backend,
216
+ backendId,
217
+ deps.config,
218
+ );
219
+ return {
220
+ ok: true,
221
+ text: model
222
+ ? `Model reset to default: ${model}.`
223
+ : `Model reset — no default available for backend ${backendId}; pick one with /model.`,
224
+ };
225
+ }
226
+
227
+ /** The backend an argument names, matched by id or label, else null. */
228
+ export function matchBackendArg(
229
+ arg: string,
230
+ config: TalonConfig,
231
+ ): { id: string; label: string } | null {
232
+ const wanted = arg.trim().toLowerCase();
233
+ if (!wanted) return null;
234
+ return (
235
+ listAvailableBackends(config).find(
236
+ (b) => b.id.toLowerCase() === wanted || b.label.toLowerCase() === wanted,
237
+ ) ?? null
238
+ );
239
+ }
240
+
241
+ /**
242
+ * Drop the session state a backend switch invalidates. Session ids are
243
+ * not portable across backends; each backend's remembered model pick IS
244
+ * kept, so switching back restores it. `keepHistory` is for frontends
245
+ * whose local history store is the only record of the chat.
246
+ */
247
+ function handOffChatSession(
248
+ chatId: string,
249
+ previous: Backend | null,
250
+ deps: ModelCommandDeps,
251
+ keepHistory: boolean,
252
+ ): void {
253
+ resetSession(chatId);
254
+ if (!keepHistory) clearHistory(chatId);
255
+ resetPulseCheckpoint(chatId);
256
+ previous?.sessions?.resetChat?.(chatId);
257
+ const next = resolveChatBackend(chatId, deps.gateway?.backend ?? null);
258
+ // Warming can take seconds on OpenCode/Kilo; the reply must not wait.
259
+ if (!next || next === previous) return;
260
+ void Promise.resolve(next.sessions?.warmSession?.(chatId)).catch((err) =>
261
+ logWarn(
262
+ "settings",
263
+ `[${chatId}] warm after backend switch failed: ${err instanceof Error ? err.message : String(err)}`,
264
+ ),
265
+ );
266
+ }
267
+
268
+ /** Model line for the post-switch confirmation. */
269
+ async function describeModelAfterSwitch(
270
+ chatId: string,
271
+ backendId: string,
272
+ deps: ModelCommandDeps,
273
+ ): Promise<string> {
274
+ const backend = resolveChatBackend(chatId, deps.gateway?.backend ?? null);
275
+ const { model } = await resolveActiveModelForChat(
276
+ chatId,
277
+ backend,
278
+ backendId,
279
+ deps.config,
280
+ );
281
+ return model ? `model: ${model}` : "no default model — /model to pick one";
282
+ }
283
+
284
+ /**
285
+ * Rebind the chat to another backend. Same sequence as Telegram's
286
+ * backend submenu; re-selecting the backend already in use only pins
287
+ * the choice and costs the session nothing.
288
+ */
289
+ export async function switchChatBackend(
290
+ chatId: string,
291
+ target: { id: string; label: string },
292
+ deps: ModelCommandDeps,
293
+ opts: { keepHistory?: boolean } = {},
294
+ ): Promise<CommandOutcome> {
295
+ const { backend: previous, backendId: previousId } = resolveChatBackendPair(
296
+ chatId,
297
+ deps,
298
+ );
299
+ if (previousId === target.id) {
300
+ setChatBackend(chatId, target.id);
301
+ return {
302
+ ok: true,
303
+ text: `Already on ${target.label} (${await describeModelAfterSwitch(chatId, target.id, deps)}).`,
304
+ };
305
+ }
306
+ const result = await rebindChat(chatId, target.id, deps.config);
307
+ if (!result.ok) {
308
+ return {
309
+ ok: false,
310
+ text: `Could not switch to ${target.label}: ${result.error ?? "rebind failed"}`,
311
+ };
312
+ }
313
+ setChatBackend(chatId, target.id);
314
+ handOffChatSession(chatId, previous, deps, opts.keepHistory === true);
315
+ return {
316
+ ok: true,
317
+ text: `Backend: ${target.label} (${await describeModelAfterSwitch(chatId, target.id, deps)}). Session started fresh.`,
318
+ };
319
+ }
320
+
321
+ /** Drop the per-chat backend override; the chat reverts to the default. */
322
+ export async function resetChatBackend(
323
+ chatId: string,
324
+ deps: ModelCommandDeps,
325
+ opts: { keepHistory?: boolean } = {},
326
+ ): Promise<CommandOutcome> {
327
+ const { backend: previous } = resolveChatBackendPair(chatId, deps);
328
+ await releaseChat(chatId);
329
+ setChatBackend(chatId, undefined);
330
+ handOffChatSession(chatId, previous, deps, opts.keepHistory === true);
331
+ const { backendId } = resolveChatBackendPair(chatId, deps);
332
+ return {
333
+ ok: true,
334
+ text: `Backend reset to default (${backendId}; ${await describeModelAfterSwitch(chatId, backendId, deps)}).`,
335
+ };
336
+ }
337
+
338
+ export type EffortOverview = {
339
+ current: string;
340
+ levels: ReasoningEffortLevel[];
341
+ activeModel: string | null;
342
+ backendId: string;
343
+ };
344
+
345
+ /** Current effort and the levels the active model accepts. */
346
+ export async function describeChatEffort(
347
+ chatId: string,
348
+ deps: ModelCommandDeps,
349
+ ): Promise<EffortOverview> {
350
+ const { backend, backendId } = resolveChatBackendPair(chatId, deps);
351
+ const reasoning = await getActiveReasoningLevels({
352
+ chatId,
353
+ backend,
354
+ backendId,
355
+ config: deps.config,
356
+ });
357
+ return {
358
+ current: displayReasoningEffort(
359
+ getChatSettings(chatId).effort,
360
+ reasoning.levels,
361
+ ),
362
+ levels: reasoning.levels,
363
+ activeModel: reasoning.activeModel,
364
+ backendId,
365
+ };
366
+ }
367
+
368
+ /** `/effort <level>` — validated against the active model's levels. */
369
+ export async function setChatEffortLevel(
370
+ chatId: string,
371
+ arg: string,
372
+ deps: ModelCommandDeps,
373
+ ): Promise<CommandOutcome> {
374
+ const level = arg.trim().toLowerCase();
375
+ if (level === "reset" || level === "default" || level === "adaptive") {
376
+ setChatEffort(chatId, undefined);
377
+ return {
378
+ ok: true,
379
+ text: "Effort reset to adaptive (model decides when to think).",
380
+ };
381
+ }
382
+ const { levels, activeModel, backendId } = await describeChatEffort(
383
+ chatId,
384
+ deps,
385
+ );
386
+ if (levels.length === 0) {
387
+ return {
388
+ ok: false,
389
+ text: `No reasoning levels available for ${activeModel ?? "the active model"} on backend ${backendId}.`,
390
+ };
391
+ }
392
+ if (!supportsReasoningLevel(level, levels)) {
393
+ return {
394
+ ok: false,
395
+ text: `Unknown level for this model. Valid: ${levels.join(", ")}, or adaptive.`,
396
+ };
397
+ }
398
+ setChatEffort(chatId, level as EffortLevel);
399
+ return { ok: true, text: `Effort set to ${level}.` };
400
+ }
@@ -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
  });