talon-agent 3.34.1 → 3.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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/history.ts +22 -1
- package/src/storage/repositories/history-repo.ts +14 -0
- package/src/storage/repositories/whatsapp-messages-repo.ts +101 -0
- package/src/storage/sql/history.sql +12 -0
- package/src/storage/sql/schema.sql +21 -0
- package/src/storage/sql/statements.generated.ts +50 -1
- package/src/storage/sql/whatsapp-messages.sql +29 -0
- 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
|
});
|