@llblab/pi-telegram 0.2.9 → 0.3.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/lib/menu.ts CHANGED
@@ -1,35 +1,112 @@
1
1
  /**
2
2
  * Telegram menu and inline-keyboard rendering helpers
3
- * Owns model resolution, menu state, and inline UI text and reply-markup generation for status, model, and thinking controls
3
+ * Owns menu state, inline UI text, and reply-markup generation for status, model, and thinking controls
4
4
  */
5
5
 
6
- import type { Model } from "@mariozechner/pi-ai";
6
+ import {
7
+ getCanonicalModelId,
8
+ isThinkingLevel,
9
+ type MenuModel,
10
+ modelsMatch,
11
+ parseTelegramCliScopedModelPatterns,
12
+ resolveScopedModelPatterns,
13
+ type ScopedTelegramModel,
14
+ sortScopedModels,
15
+ THINKING_LEVELS,
16
+ type ThinkingLevel,
17
+ } from "./model.ts";
18
+ const TELEGRAM_MODEL_MENU_CACHE_TTL_MS = 5000;
19
+ const TELEGRAM_MODEL_MENU_STATE_TTL_MS = 10 * 60 * 1000;
20
+ const MAX_STORED_TELEGRAM_MODEL_MENUS = 50;
7
21
 
8
- export type ThinkingLevel =
9
- | "off"
10
- | "minimal"
11
- | "low"
12
- | "medium"
13
- | "high"
14
- | "xhigh";
15
22
  export type TelegramModelScope = "all" | "scoped";
16
23
 
17
- export interface ScopedTelegramModel {
18
- model: Model<any>;
19
- thinkingLevel?: ThinkingLevel;
20
- }
21
-
22
- export interface TelegramModelMenuState {
24
+ export interface TelegramModelMenuState<TModel extends MenuModel = MenuModel> {
23
25
  chatId: number;
24
26
  messageId: number;
25
27
  page: number;
26
28
  scope: TelegramModelScope;
27
- scopedModels: ScopedTelegramModel[];
28
- allModels: ScopedTelegramModel[];
29
+ scopedModels: ScopedTelegramModel<TModel>[];
30
+ allModels: ScopedTelegramModel<TModel>[];
29
31
  note?: string;
30
32
  mode: "status" | "model" | "thinking";
31
33
  }
32
34
 
35
+ export interface StoredTelegramModelMenuState<
36
+ TModel extends MenuModel = MenuModel,
37
+ > {
38
+ state: TelegramModelMenuState<TModel>;
39
+ updatedAt: number;
40
+ }
41
+
42
+ export interface TelegramModelMenuStoreOptions {
43
+ maxAgeMs: number;
44
+ maxStoredMenus: number;
45
+ now?: number;
46
+ }
47
+
48
+ export interface CachedTelegramModelMenuInputs<
49
+ TModel extends MenuModel = MenuModel,
50
+ > {
51
+ expiresAt: number;
52
+ availableModels: TModel[];
53
+ configuredScopedModelPatterns: string[];
54
+ cliScopedModelPatterns?: string[];
55
+ }
56
+
57
+ export interface TelegramModelMenuInputCacheDeps<
58
+ TModel extends MenuModel = MenuModel,
59
+ > {
60
+ cacheTtlMs: number;
61
+ now?: number;
62
+ reloadSettings: () => Promise<void>;
63
+ refreshAvailableModels: () => TModel[];
64
+ getConfiguredScopedModelPatterns: () => string[] | undefined;
65
+ getCliScopedModelPatterns: () => string[] | undefined;
66
+ }
67
+
68
+ export interface TelegramModelMenuRuntimeContext<
69
+ TModel extends MenuModel = MenuModel,
70
+ > {
71
+ modelRegistry: {
72
+ refresh: () => void;
73
+ getAvailable: () => TModel[];
74
+ };
75
+ }
76
+
77
+ export interface TelegramModelMenuRuntimeOptions<
78
+ TContext extends TelegramModelMenuRuntimeContext<TModel>,
79
+ TModel extends MenuModel = MenuModel,
80
+ > {
81
+ chatId: number;
82
+ activeModel: TModel | undefined;
83
+ cachedInputs: CachedTelegramModelMenuInputs<TModel> | undefined;
84
+ cacheTtlMs: number;
85
+ ctx: TContext;
86
+ reloadSettings: () => Promise<void>;
87
+ getConfiguredScopedModelPatterns: () => string[] | undefined;
88
+ getCliScopedModelPatterns?: () => string[] | undefined;
89
+ }
90
+
91
+ export interface MenuSettingsManager {
92
+ reload: () => Promise<void>;
93
+ getEnabledModels: () => string[] | undefined;
94
+ }
95
+
96
+ export type TelegramModelMenuStateBuilderContext<
97
+ TModel extends MenuModel = MenuModel,
98
+ > = TelegramModelMenuRuntimeContext<TModel> & { cwd: string };
99
+
100
+ export interface TelegramModelMenuStateBuilderDeps<
101
+ TModel extends MenuModel = MenuModel,
102
+ TContext extends TelegramModelMenuStateBuilderContext<TModel> =
103
+ TelegramModelMenuStateBuilderContext<TModel>,
104
+ > {
105
+ runtime: TelegramModelMenuRuntime<TModel>;
106
+ createSettingsManager: (cwd: string) => MenuSettingsManager;
107
+ getActiveModel: (ctx: TContext) => TModel | undefined;
108
+ }
109
+
33
110
  export type TelegramReplyMarkup = {
34
111
  inline_keyboard: Array<Array<{ text: string; callback_data: string }>>;
35
112
  };
@@ -50,7 +127,7 @@ export interface TelegramMenuMessageRuntimeDeps {
50
127
  ) => Promise<number | undefined>;
51
128
  }
52
129
 
53
- export interface TelegramMenuEffectPort {
130
+ export interface TelegramMenuEffectPort<TModel extends MenuModel = MenuModel> {
54
131
  answerCallbackQuery: (
55
132
  callbackQueryId: string,
56
133
  text?: string,
@@ -58,28 +135,37 @@ export interface TelegramMenuEffectPort {
58
135
  updateModelMenuMessage: () => Promise<void>;
59
136
  updateThinkingMenuMessage: () => Promise<void>;
60
137
  updateStatusMessage: () => Promise<void>;
61
- setModel: (model: Model<any>) => Promise<boolean>;
62
- setCurrentModel: (model: Model<any>) => void;
138
+ setModel: (model: TModel) => Promise<boolean>;
139
+ setCurrentModel: (model: TModel) => void;
63
140
  setThinkingLevel: (level: ThinkingLevel) => void;
64
141
  getCurrentThinkingLevel: () => ThinkingLevel;
65
- stagePendingModelSwitch: (selection: ScopedTelegramModel) => void;
142
+ stagePendingModelSwitch: (selection: ScopedTelegramModel<TModel>) => void;
66
143
  restartInterruptedTelegramTurn: (
67
- selection: ScopedTelegramModel,
144
+ selection: ScopedTelegramModel<TModel>,
68
145
  ) => Promise<boolean> | boolean;
69
146
  }
70
147
 
71
- export type TelegramStatusMenuCallbackDeps = Pick<
72
- TelegramMenuEffectPort,
148
+ export type TelegramStatusMenuCallbackDeps<
149
+ TModel extends MenuModel = MenuModel,
150
+ > = Pick<
151
+ TelegramMenuEffectPort<TModel>,
73
152
  "updateModelMenuMessage" | "updateThinkingMenuMessage" | "answerCallbackQuery"
74
153
  >;
75
154
 
76
- export type TelegramThinkingMenuCallbackDeps = Pick<
77
- TelegramMenuEffectPort,
78
- "setThinkingLevel" | "getCurrentThinkingLevel" | "updateStatusMessage" | "answerCallbackQuery"
155
+ export type TelegramThinkingMenuCallbackDeps<
156
+ TModel extends MenuModel = MenuModel,
157
+ > = Pick<
158
+ TelegramMenuEffectPort<TModel>,
159
+ | "setThinkingLevel"
160
+ | "getCurrentThinkingLevel"
161
+ | "updateStatusMessage"
162
+ | "answerCallbackQuery"
79
163
  >;
80
164
 
81
- export type TelegramModelMenuCallbackDeps = Pick<
82
- TelegramMenuEffectPort,
165
+ export type TelegramModelMenuCallbackDeps<
166
+ TModel extends MenuModel = MenuModel,
167
+ > = Pick<
168
+ TelegramMenuEffectPort<TModel>,
83
169
  | "updateModelMenuMessage"
84
170
  | "updateStatusMessage"
85
171
  | "answerCallbackQuery"
@@ -100,21 +186,313 @@ export interface TelegramMenuCallbackEntryDeps {
100
186
  ) => Promise<void>;
101
187
  }
102
188
 
103
- export const THINKING_LEVELS: readonly ThinkingLevel[] = [
104
- "off",
105
- "minimal",
106
- "low",
107
- "medium",
108
- "high",
109
- "xhigh",
110
- ];
189
+ export interface MenuCallbackQuery {
190
+ id: string;
191
+ data?: string;
192
+ message?: { message_id?: number };
193
+ }
194
+
195
+ export interface StoredTelegramMenuCallbackDeps<
196
+ TModel extends MenuModel = MenuModel,
197
+ > {
198
+ getStoredModelMenuState: (
199
+ messageId: number | undefined,
200
+ ) => TelegramModelMenuState<TModel> | undefined;
201
+ handleStatusAction: (
202
+ state: TelegramModelMenuState<TModel>,
203
+ ) => Promise<boolean>;
204
+ handleThinkingAction: (
205
+ state: TelegramModelMenuState<TModel>,
206
+ ) => Promise<boolean>;
207
+ handleModelAction: (
208
+ state: TelegramModelMenuState<TModel>,
209
+ ) => Promise<boolean>;
210
+ answerCallbackQuery: (
211
+ callbackQueryId: string,
212
+ text?: string,
213
+ ) => Promise<void>;
214
+ }
215
+
216
+ export interface TelegramMenuCallbackRuntimeDeps<
217
+ TContext,
218
+ TModel extends MenuModel = MenuModel,
219
+ > {
220
+ getStoredModelMenuState: (
221
+ messageId: number | undefined,
222
+ ) => TelegramModelMenuState<TModel> | undefined;
223
+ getActiveModel: (ctx: TContext) => TModel | undefined;
224
+ getThinkingLevel: () => ThinkingLevel;
225
+ setThinkingLevel: (level: ThinkingLevel) => void;
226
+ updateStatus: (ctx: TContext) => void;
227
+ updateModelMenuMessage: (
228
+ state: TelegramModelMenuState<TModel>,
229
+ ctx: TContext,
230
+ ) => Promise<void>;
231
+ updateThinkingMenuMessage: (
232
+ state: TelegramModelMenuState<TModel>,
233
+ ctx: TContext,
234
+ ) => Promise<void>;
235
+ updateStatusMessage: (
236
+ state: TelegramModelMenuState<TModel>,
237
+ ctx: TContext,
238
+ ) => Promise<void>;
239
+ answerCallbackQuery: (
240
+ callbackQueryId: string,
241
+ text?: string,
242
+ ) => Promise<void>;
243
+ isIdle: (ctx: TContext) => boolean;
244
+ hasActiveTelegramTurn: () => boolean;
245
+ hasAbortHandler: () => boolean;
246
+ hasActiveToolExecutions: () => boolean;
247
+ setModel: (model: TModel) => Promise<boolean>;
248
+ setCurrentModel: (model: TModel, ctx: TContext) => void;
249
+ stagePendingModelSwitch: (
250
+ selection: ScopedTelegramModel<TModel>,
251
+ ctx: TContext,
252
+ ) => void;
253
+ restartInterruptedTelegramTurn: (
254
+ selection: ScopedTelegramModel<TModel>,
255
+ ctx: TContext,
256
+ ) => Promise<boolean> | boolean;
257
+ }
258
+
259
+ export interface TelegramStatusMenuOpenDeps<
260
+ TModel extends MenuModel = MenuModel,
261
+ > {
262
+ isIdle: () => boolean;
263
+ sendBusyMessage: () => Promise<void>;
264
+ getModelMenuState: () => Promise<TelegramModelMenuState<TModel>>;
265
+ buildStatusHtml: () => string;
266
+ getActiveModel: () => TModel | undefined;
267
+ getThinkingLevel: () => ThinkingLevel;
268
+ sendStatusMenu: (
269
+ state: TelegramModelMenuState<TModel>,
270
+ statusHtml: string,
271
+ activeModel: TModel | undefined,
272
+ thinkingLevel: ThinkingLevel,
273
+ ) => Promise<number | undefined>;
274
+ storeModelMenuState: (state: TelegramModelMenuState<TModel>) => void;
275
+ }
276
+
277
+ export interface TelegramModelMenuOpenDeps<
278
+ TModel extends MenuModel = MenuModel,
279
+ > {
280
+ isIdle: () => boolean;
281
+ canOfferInFlightModelSwitch: () => boolean;
282
+ sendBusyMessage: () => Promise<void>;
283
+ sendNoModelsMessage: () => Promise<void>;
284
+ getModelMenuState: () => Promise<TelegramModelMenuState<TModel>>;
285
+ getActiveModel: () => TModel | undefined;
286
+ sendModelMenu: (
287
+ state: TelegramModelMenuState<TModel>,
288
+ activeModel: TModel | undefined,
289
+ ) => Promise<number | undefined>;
290
+ storeModelMenuState: (state: TelegramModelMenuState<TModel>) => void;
291
+ }
292
+
293
+ export interface TelegramMenuActionRuntimeDeps<
294
+ TContext,
295
+ TModel extends MenuModel = MenuModel,
296
+ > extends TelegramMenuMessageRuntimeDeps {
297
+ getModelMenuState: (
298
+ chatId: number,
299
+ ctx: TContext,
300
+ ) => Promise<TelegramModelMenuState<TModel>>;
301
+ getActiveModel: (ctx: TContext) => TModel | undefined;
302
+ getThinkingLevel: () => ThinkingLevel;
303
+ buildStatusHtml: (ctx: TContext) => string;
304
+ storeModelMenuState: (state: TelegramModelMenuState<TModel>) => void;
305
+ isIdle: (ctx: TContext) => boolean;
306
+ canOfferInFlightModelSwitch: (ctx: TContext) => boolean;
307
+ sendTextReply: (
308
+ chatId: number,
309
+ replyToMessageId: number,
310
+ text: string,
311
+ ) => Promise<unknown>;
312
+ }
313
+
314
+ export interface TelegramMenuActionRuntime<
315
+ TContext,
316
+ TModel extends MenuModel = MenuModel,
317
+ > {
318
+ updateModelMenuMessage: (
319
+ state: TelegramModelMenuState<TModel>,
320
+ ctx: TContext,
321
+ ) => Promise<void>;
322
+ updateThinkingMenuMessage: (
323
+ state: TelegramModelMenuState<TModel>,
324
+ ctx: TContext,
325
+ ) => Promise<void>;
326
+ updateStatusMessage: (
327
+ state: TelegramModelMenuState<TModel>,
328
+ ctx: TContext,
329
+ ) => Promise<void>;
330
+ sendStatusMessage: (
331
+ chatId: number,
332
+ replyToMessageId: number,
333
+ ctx: TContext,
334
+ ) => Promise<void>;
335
+ openModelMenu: (
336
+ chatId: number,
337
+ replyToMessageId: number,
338
+ ctx: TContext,
339
+ ) => Promise<void>;
340
+ }
341
+
111
342
  export const TELEGRAM_MODEL_PAGE_SIZE = 6;
343
+
344
+ export function pruneStoredTelegramModelMenus<
345
+ TModel extends MenuModel = MenuModel,
346
+ >(
347
+ menus: Map<number, StoredTelegramModelMenuState<TModel>>,
348
+ options: TelegramModelMenuStoreOptions,
349
+ ): void {
350
+ const now = options.now ?? Date.now();
351
+ for (const [messageId, entry] of menus.entries()) {
352
+ if (now - entry.updatedAt <= options.maxAgeMs) continue;
353
+ menus.delete(messageId);
354
+ }
355
+ while (menus.size > options.maxStoredMenus) {
356
+ const oldestMessageId = menus.keys().next().value as number | undefined;
357
+ if (oldestMessageId === undefined) return;
358
+ menus.delete(oldestMessageId);
359
+ }
360
+ }
361
+
362
+ export function storeTelegramModelMenuState<
363
+ TModel extends MenuModel = MenuModel,
364
+ >(
365
+ menus: Map<number, StoredTelegramModelMenuState<TModel>>,
366
+ state: TelegramModelMenuState<TModel>,
367
+ options: TelegramModelMenuStoreOptions,
368
+ ): void {
369
+ const now = options.now ?? Date.now();
370
+ pruneStoredTelegramModelMenus(menus, { ...options, now });
371
+ menus.set(state.messageId, { state, updatedAt: now });
372
+ pruneStoredTelegramModelMenus(menus, { ...options, now });
373
+ }
374
+
375
+ export function getStoredTelegramModelMenuState<
376
+ TModel extends MenuModel = MenuModel,
377
+ >(
378
+ menus: Map<number, StoredTelegramModelMenuState<TModel>>,
379
+ messageId: number | undefined,
380
+ options: TelegramModelMenuStoreOptions,
381
+ ): TelegramModelMenuState<TModel> | undefined {
382
+ if (messageId === undefined) return undefined;
383
+ const now = options.now ?? Date.now();
384
+ pruneStoredTelegramModelMenus(menus, { ...options, now });
385
+ const entry = menus.get(messageId);
386
+ if (!entry) return undefined;
387
+ menus.delete(messageId);
388
+ entry.updatedAt = now;
389
+ menus.set(messageId, entry);
390
+ return entry.state;
391
+ }
392
+
393
+ export interface TelegramModelMenuRuntime<
394
+ TModel extends MenuModel = MenuModel,
395
+ > {
396
+ storeState: (state: TelegramModelMenuState<TModel>) => void;
397
+ getState: (
398
+ messageId: number | undefined,
399
+ ) => TelegramModelMenuState<TModel> | undefined;
400
+ clear: () => void;
401
+ buildState: <TContext extends TelegramModelMenuRuntimeContext<TModel>>(
402
+ options: Omit<
403
+ TelegramModelMenuRuntimeOptions<TContext, TModel>,
404
+ "cachedInputs" | "cacheTtlMs"
405
+ >,
406
+ ) => Promise<TelegramModelMenuState<TModel>>;
407
+ }
408
+
409
+ export function createTelegramModelMenuRuntime<
410
+ TModel extends MenuModel = MenuModel,
411
+ >(
412
+ options: Partial<TelegramModelMenuStoreOptions> = {},
413
+ ): TelegramModelMenuRuntime<TModel> {
414
+ const menus = new Map<number, StoredTelegramModelMenuState<TModel>>();
415
+ let cachedInputs: CachedTelegramModelMenuInputs<TModel> | undefined;
416
+ const getStoreOptions = (): TelegramModelMenuStoreOptions => ({
417
+ maxAgeMs: options.maxAgeMs ?? TELEGRAM_MODEL_MENU_STATE_TTL_MS,
418
+ maxStoredMenus: options.maxStoredMenus ?? MAX_STORED_TELEGRAM_MODEL_MENUS,
419
+ now: options.now,
420
+ });
421
+ return {
422
+ storeState: (state) => {
423
+ storeTelegramModelMenuState(menus, state, getStoreOptions());
424
+ },
425
+ getState: (messageId) =>
426
+ getStoredTelegramModelMenuState(menus, messageId, getStoreOptions()),
427
+ clear: () => {
428
+ menus.clear();
429
+ cachedInputs = undefined;
430
+ },
431
+ buildState: async (stateOptions) => {
432
+ const result = await buildTelegramModelMenuStateRuntime({
433
+ ...stateOptions,
434
+ cachedInputs,
435
+ cacheTtlMs: TELEGRAM_MODEL_MENU_CACHE_TTL_MS,
436
+ });
437
+ cachedInputs = result.cachedInputs;
438
+ return result.state;
439
+ },
440
+ };
441
+ }
442
+
443
+ export function createTelegramModelMenuStateBuilder<
444
+ TModel extends MenuModel = MenuModel,
445
+ TContext extends TelegramModelMenuStateBuilderContext<TModel> =
446
+ TelegramModelMenuStateBuilderContext<TModel>,
447
+ >(
448
+ deps: TelegramModelMenuStateBuilderDeps<TModel, TContext>,
449
+ ): (chatId: number, ctx: TContext) => Promise<TelegramModelMenuState<TModel>> {
450
+ return async (chatId, ctx) => {
451
+ const settingsManager = deps.createSettingsManager(ctx.cwd);
452
+ return deps.runtime.buildState({
453
+ chatId,
454
+ activeModel: deps.getActiveModel(ctx),
455
+ ctx,
456
+ reloadSettings: () => settingsManager.reload(),
457
+ getConfiguredScopedModelPatterns: () =>
458
+ settingsManager.getEnabledModels(),
459
+ });
460
+ };
461
+ }
462
+
463
+ export async function resolveCachedTelegramModelMenuInputs<
464
+ TModel extends MenuModel = MenuModel,
465
+ >(
466
+ cachedInputs: CachedTelegramModelMenuInputs<TModel> | undefined,
467
+ deps: TelegramModelMenuInputCacheDeps<TModel>,
468
+ ): Promise<CachedTelegramModelMenuInputs<TModel>> {
469
+ const now = deps.now ?? Date.now();
470
+ if (cachedInputs && cachedInputs.expiresAt > now) return cachedInputs;
471
+ await deps.reloadSettings();
472
+ const availableModels = deps.refreshAvailableModels();
473
+ const cliScopedModelPatterns = deps.getCliScopedModelPatterns();
474
+ const configuredScopedModelPatterns =
475
+ cliScopedModelPatterns ?? deps.getConfiguredScopedModelPatterns() ?? [];
476
+ return {
477
+ expiresAt: now + deps.cacheTtlMs,
478
+ availableModels,
479
+ configuredScopedModelPatterns,
480
+ cliScopedModelPatterns,
481
+ };
482
+ }
483
+
484
+ function getTelegramCliScopedModelPatterns(): string[] | undefined {
485
+ return parseTelegramCliScopedModelPatterns(process.argv.slice(2));
486
+ }
487
+
112
488
  export const MODEL_MENU_TITLE = "<b>Choose a model:</b>";
113
489
 
114
- export interface BuildTelegramModelMenuStateParams {
490
+ export interface BuildTelegramModelMenuStateParams<
491
+ TModel extends MenuModel = MenuModel,
492
+ > {
115
493
  chatId: number;
116
- activeModel: Model<any> | undefined;
117
- availableModels: Model<any>[];
494
+ activeModel: TModel | undefined;
495
+ availableModels: TModel[];
118
496
  configuredScopedModelPatterns: string[];
119
497
  cliScopedModelPatterns?: string[];
120
498
  }
@@ -130,16 +508,16 @@ export type TelegramMenuCallbackAction =
130
508
  };
131
509
 
132
510
  export type TelegramMenuMutationResult = "invalid" | "unchanged" | "changed";
133
- export type TelegramMenuSelectionResult =
511
+ export type TelegramMenuSelectionResult<TModel extends MenuModel = MenuModel> =
134
512
  | { kind: "invalid" }
135
513
  | { kind: "missing" }
136
- | { kind: "selected"; selection: ScopedTelegramModel };
514
+ | { kind: "selected"; selection: ScopedTelegramModel<TModel> };
137
515
 
138
- export interface TelegramModelMenuPage {
516
+ export interface TelegramModelMenuPage<TModel extends MenuModel = MenuModel> {
139
517
  page: number;
140
518
  pageCount: number;
141
519
  start: number;
142
- items: ScopedTelegramModel[];
520
+ items: ScopedTelegramModel<TModel>[];
143
521
  }
144
522
 
145
523
  export interface TelegramMenuRenderPayload {
@@ -149,243 +527,46 @@ export interface TelegramMenuRenderPayload {
149
527
  replyMarkup: TelegramReplyMarkup;
150
528
  }
151
529
 
152
- export type TelegramModelCallbackPlan =
530
+ export type TelegramModelCallbackPlan<TModel extends MenuModel = MenuModel> =
153
531
  | { kind: "ignore" }
154
532
  | { kind: "answer"; text?: string }
155
533
  | { kind: "update-menu"; text?: string }
156
534
  | {
157
535
  kind: "refresh-status";
158
- selection: ScopedTelegramModel;
536
+ selection: ScopedTelegramModel<TModel>;
159
537
  callbackText: string;
160
538
  shouldApplyThinkingLevel: boolean;
161
539
  }
162
540
  | {
163
541
  kind: "switch-model";
164
- selection: ScopedTelegramModel;
542
+ selection: ScopedTelegramModel<TModel>;
165
543
  mode: "idle" | "restart-now" | "restart-after-tool";
166
544
  callbackText: string;
167
545
  };
168
546
 
169
- export interface BuildTelegramModelCallbackPlanParams {
547
+ export interface BuildTelegramModelCallbackPlanParams<
548
+ TModel extends MenuModel = MenuModel,
549
+ > {
170
550
  data: string | undefined;
171
- state: TelegramModelMenuState;
172
- activeModel: Model<any> | undefined;
551
+ state: TelegramModelMenuState<TModel>;
552
+ activeModel: TModel | undefined;
173
553
  currentThinkingLevel: ThinkingLevel;
174
554
  isIdle: boolean;
175
555
  canRestartBusyRun: boolean;
176
556
  hasActiveToolExecutions: boolean;
177
557
  }
178
558
 
179
- export function modelsMatch(
180
- a: Pick<Model<any>, "provider" | "id"> | undefined,
181
- b: Pick<Model<any>, "provider" | "id"> | undefined,
182
- ): boolean {
183
- return !!a && !!b && a.provider === b.provider && a.id === b.id;
184
- }
185
-
186
- export function getCanonicalModelId(
187
- model: Pick<Model<any>, "provider" | "id">,
188
- ): string {
189
- return `${model.provider}/${model.id}`;
190
- }
191
-
192
- export function isThinkingLevel(value: string): value is ThinkingLevel {
193
- return THINKING_LEVELS.includes(value as ThinkingLevel);
194
- }
195
-
196
- function escapeRegex(text: string): string {
197
- return text.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
198
- }
199
-
200
- function globMatches(text: string, pattern: string): boolean {
201
- let regex = "^";
202
- for (let i = 0; i < pattern.length; i++) {
203
- const char = pattern[i];
204
- if (char === "*") {
205
- regex += ".*";
206
- continue;
207
- }
208
- if (char === "?") {
209
- regex += ".";
210
- continue;
211
- }
212
- if (char === "[") {
213
- const end = pattern.indexOf("]", i + 1);
214
- if (end !== -1) {
215
- const content = pattern.slice(i + 1, end);
216
- regex += content.startsWith("!")
217
- ? `[^${content.slice(1)}]`
218
- : `[${content}]`;
219
- i = end;
220
- continue;
221
- }
222
- }
223
- regex += escapeRegex(char);
224
- }
225
- regex += "$";
226
- return new RegExp(regex, "i").test(text);
227
- }
228
-
229
- function isAliasModelId(id: string): boolean {
230
- if (id.endsWith("-latest")) return true;
231
- return !/-\d{8}$/.test(id);
232
- }
233
-
234
- function findExactModelReferenceMatch(
235
- modelReference: string,
236
- availableModels: Model<any>[],
237
- ): Model<any> | undefined {
238
- const trimmedReference = modelReference.trim();
239
- if (!trimmedReference) return undefined;
240
- const normalizedReference = trimmedReference.toLowerCase();
241
- const canonicalMatches = availableModels.filter(
242
- (model) => getCanonicalModelId(model).toLowerCase() === normalizedReference,
243
- );
244
- if (canonicalMatches.length === 1) return canonicalMatches[0];
245
- if (canonicalMatches.length > 1) return undefined;
246
- const slashIndex = trimmedReference.indexOf("/");
247
- if (slashIndex !== -1) {
248
- const provider = trimmedReference.substring(0, slashIndex).trim();
249
- const modelId = trimmedReference.substring(slashIndex + 1).trim();
250
- if (provider && modelId) {
251
- const providerMatches = availableModels.filter(
252
- (model) =>
253
- model.provider.toLowerCase() === provider.toLowerCase() &&
254
- model.id.toLowerCase() === modelId.toLowerCase(),
255
- );
256
- if (providerMatches.length === 1) return providerMatches[0];
257
- if (providerMatches.length > 1) return undefined;
258
- }
259
- }
260
- const idMatches = availableModels.filter(
261
- (model) => model.id.toLowerCase() === normalizedReference,
262
- );
263
- return idMatches.length === 1 ? idMatches[0] : undefined;
264
- }
265
-
266
- function tryMatchScopedModel(
267
- modelPattern: string,
268
- availableModels: Model<any>[],
269
- ): Model<any> | undefined {
270
- const exactMatch = findExactModelReferenceMatch(
271
- modelPattern,
272
- availableModels,
273
- );
274
- if (exactMatch) return exactMatch;
275
- const matches = availableModels.filter(
276
- (model) =>
277
- model.id.toLowerCase().includes(modelPattern.toLowerCase()) ||
278
- model.name?.toLowerCase().includes(modelPattern.toLowerCase()),
279
- );
280
- if (matches.length === 0) return undefined;
281
- const aliases = matches.filter((model) => isAliasModelId(model.id));
282
- const datedVersions = matches.filter((model) => !isAliasModelId(model.id));
283
- if (aliases.length > 0) {
284
- aliases.sort((a, b) => b.id.localeCompare(a.id));
285
- return aliases[0];
286
- }
287
- datedVersions.sort((a, b) => b.id.localeCompare(a.id));
288
- return datedVersions[0];
289
- }
290
-
291
- function parseScopedModelPattern(
292
- pattern: string,
293
- availableModels: Model<any>[],
294
- ): { model: Model<any> | undefined; thinkingLevel?: ThinkingLevel } {
295
- const exactMatch = tryMatchScopedModel(pattern, availableModels);
296
- if (exactMatch) {
297
- return { model: exactMatch, thinkingLevel: undefined };
298
- }
299
- const lastColonIndex = pattern.lastIndexOf(":");
300
- if (lastColonIndex === -1) {
301
- return { model: undefined, thinkingLevel: undefined };
302
- }
303
- const prefix = pattern.substring(0, lastColonIndex);
304
- const suffix = pattern.substring(lastColonIndex + 1);
305
- if (isThinkingLevel(suffix)) {
306
- const result = parseScopedModelPattern(prefix, availableModels);
307
- if (result.model) {
308
- return { model: result.model, thinkingLevel: suffix };
309
- }
310
- return result;
311
- }
312
- return parseScopedModelPattern(prefix, availableModels);
313
- }
314
-
315
- export function resolveScopedModelPatterns(
316
- patterns: string[],
317
- availableModels: Model<any>[],
318
- ): ScopedTelegramModel[] {
319
- const resolved: ScopedTelegramModel[] = [];
320
- const seen = new Set<string>();
321
- for (const pattern of patterns) {
322
- if (
323
- pattern.includes("*") ||
324
- pattern.includes("?") ||
325
- pattern.includes("[")
326
- ) {
327
- const colonIndex = pattern.lastIndexOf(":");
328
- let globPattern = pattern;
329
- let thinkingLevel: ThinkingLevel | undefined;
330
- if (colonIndex !== -1) {
331
- const suffix = pattern.substring(colonIndex + 1);
332
- if (isThinkingLevel(suffix)) {
333
- thinkingLevel = suffix;
334
- globPattern = pattern.substring(0, colonIndex);
335
- }
336
- }
337
- const matches = availableModels.filter(
338
- (model) =>
339
- globMatches(getCanonicalModelId(model), globPattern) ||
340
- globMatches(model.id, globPattern),
341
- );
342
- for (const model of matches) {
343
- const key = getCanonicalModelId(model);
344
- if (seen.has(key)) continue;
345
- seen.add(key);
346
- resolved.push({ model, thinkingLevel });
347
- }
348
- continue;
349
- }
350
- const matched = parseScopedModelPattern(pattern, availableModels);
351
- if (!matched.model) continue;
352
- const key = getCanonicalModelId(matched.model);
353
- if (seen.has(key)) continue;
354
- seen.add(key);
355
- resolved.push({
356
- model: matched.model,
357
- thinkingLevel: matched.thinkingLevel,
358
- });
359
- }
360
- return resolved;
361
- }
362
-
363
- export function sortScopedModels(
364
- models: ScopedTelegramModel[],
365
- currentModel: Model<any> | undefined,
366
- ): ScopedTelegramModel[] {
367
- const sorted = [...models];
368
- sorted.sort((a, b) => {
369
- const aIsCurrent = modelsMatch(a.model, currentModel);
370
- const bIsCurrent = modelsMatch(b.model, currentModel);
371
- if (aIsCurrent && !bIsCurrent) return -1;
372
- if (!aIsCurrent && bIsCurrent) return 1;
373
- const providerCompare = a.model.provider.localeCompare(b.model.provider);
374
- if (providerCompare !== 0) return providerCompare;
375
- return a.model.id.localeCompare(b.model.id);
376
- });
377
- return sorted;
378
- }
379
-
380
559
  function truncateTelegramButtonLabel(label: string, maxLength = 56): string {
381
560
  return label.length <= maxLength
382
561
  ? label
383
562
  : `${label.slice(0, maxLength - 1)}…`;
384
563
  }
385
564
 
386
- export function formatScopedModelButtonText(
387
- entry: ScopedTelegramModel,
388
- currentModel: Model<any> | undefined,
565
+ export function formatScopedModelButtonText<
566
+ TModel extends MenuModel = MenuModel,
567
+ >(
568
+ entry: ScopedTelegramModel<TModel>,
569
+ currentModel: TModel | undefined,
389
570
  ): string {
390
571
  let label = `${modelsMatch(entry.model, currentModel) ? "✅ " : ""}${entry.model.id} [${entry.model.provider}]`;
391
572
  if (entry.thinkingLevel) {
@@ -398,17 +579,19 @@ export function formatStatusButtonLabel(label: string, value: string): string {
398
579
  return truncateTelegramButtonLabel(`${label}: ${value}`, 64);
399
580
  }
400
581
 
401
- export function getModelMenuItems(
402
- state: TelegramModelMenuState,
403
- ): ScopedTelegramModel[] {
582
+ export function getModelMenuItems<TModel extends MenuModel = MenuModel>(
583
+ state: TelegramModelMenuState<TModel>,
584
+ ): ScopedTelegramModel<TModel>[] {
404
585
  return state.scope === "scoped" && state.scopedModels.length > 0
405
586
  ? state.scopedModels
406
587
  : state.allModels;
407
588
  }
408
589
 
409
- export function buildTelegramModelMenuState(
410
- params: BuildTelegramModelMenuStateParams,
411
- ): TelegramModelMenuState {
590
+ export function buildTelegramModelMenuState<
591
+ TModel extends MenuModel = MenuModel,
592
+ >(
593
+ params: BuildTelegramModelMenuStateParams<TModel>,
594
+ ): TelegramModelMenuState<TModel> {
412
595
  const allModels = sortScopedModels(
413
596
  params.availableModels.map((model) => ({ model })),
414
597
  params.activeModel,
@@ -444,6 +627,42 @@ export function buildTelegramModelMenuState(
444
627
  };
445
628
  }
446
629
 
630
+ export async function buildTelegramModelMenuStateRuntime<
631
+ TContext extends TelegramModelMenuRuntimeContext<TModel>,
632
+ TModel extends MenuModel = MenuModel,
633
+ >(
634
+ options: TelegramModelMenuRuntimeOptions<TContext, TModel>,
635
+ ): Promise<{
636
+ state: TelegramModelMenuState<TModel>;
637
+ cachedInputs: CachedTelegramModelMenuInputs<TModel>;
638
+ }> {
639
+ const cachedInputs = await resolveCachedTelegramModelMenuInputs(
640
+ options.cachedInputs,
641
+ {
642
+ cacheTtlMs: options.cacheTtlMs,
643
+ reloadSettings: options.reloadSettings,
644
+ refreshAvailableModels: () => {
645
+ options.ctx.modelRegistry.refresh();
646
+ return options.ctx.modelRegistry.getAvailable();
647
+ },
648
+ getConfiguredScopedModelPatterns:
649
+ options.getConfiguredScopedModelPatterns,
650
+ getCliScopedModelPatterns:
651
+ options.getCliScopedModelPatterns ?? getTelegramCliScopedModelPatterns,
652
+ },
653
+ );
654
+ return {
655
+ cachedInputs,
656
+ state: buildTelegramModelMenuState({
657
+ chatId: options.chatId,
658
+ activeModel: options.activeModel,
659
+ availableModels: cachedInputs.availableModels,
660
+ configuredScopedModelPatterns: cachedInputs.configuredScopedModelPatterns,
661
+ cliScopedModelPatterns: cachedInputs.cliScopedModelPatterns,
662
+ }),
663
+ };
664
+ }
665
+
447
666
  export function parseTelegramMenuCallbackAction(
448
667
  data: string | undefined,
449
668
  ): TelegramMenuCallbackAction {
@@ -493,10 +712,10 @@ export function applyTelegramModelPageSelection(
493
712
  return "changed";
494
713
  }
495
714
 
496
- export function getTelegramModelSelection(
497
- state: TelegramModelMenuState,
715
+ export function getTelegramModelSelection<TModel extends MenuModel = MenuModel>(
716
+ state: TelegramModelMenuState<TModel>,
498
717
  value: string | undefined,
499
- ): TelegramMenuSelectionResult {
718
+ ): TelegramMenuSelectionResult<TModel> {
500
719
  const index = Number(value);
501
720
  if (!Number.isFinite(index)) return { kind: "invalid" };
502
721
  const selection = getModelMenuItems(state)[index];
@@ -504,18 +723,23 @@ export function getTelegramModelSelection(
504
723
  return { kind: "selected", selection };
505
724
  }
506
725
 
507
- export function buildTelegramModelCallbackPlan(
508
- params: BuildTelegramModelCallbackPlanParams,
509
- ): TelegramModelCallbackPlan {
726
+ export function buildTelegramModelCallbackPlan<
727
+ TModel extends MenuModel = MenuModel,
728
+ >(
729
+ params: BuildTelegramModelCallbackPlanParams<TModel>,
730
+ ): TelegramModelCallbackPlan<TModel> {
510
731
  const action = parseTelegramMenuCallbackAction(params.data);
511
732
  if (action.kind !== "model") return { kind: "ignore" };
512
733
  if (action.action === "noop") return { kind: "answer" };
513
734
  if (action.action === "scope") {
514
- const result = applyTelegramModelScopeSelection(params.state, action.value);
515
- if (result === "invalid") {
735
+ const scopeResult = applyTelegramModelScopeSelection(
736
+ params.state,
737
+ action.value,
738
+ );
739
+ if (scopeResult === "invalid") {
516
740
  return { kind: "answer", text: "Unknown model scope." };
517
741
  }
518
- if (result === "unchanged") {
742
+ if (scopeResult === "unchanged") {
519
743
  return { kind: "answer" };
520
744
  }
521
745
  return {
@@ -524,11 +748,14 @@ export function buildTelegramModelCallbackPlan(
524
748
  };
525
749
  }
526
750
  if (action.action === "page") {
527
- const result = applyTelegramModelPageSelection(params.state, action.value);
528
- if (result === "invalid") {
751
+ const pageResult = applyTelegramModelPageSelection(
752
+ params.state,
753
+ action.value,
754
+ );
755
+ if (pageResult === "invalid") {
529
756
  return { kind: "answer", text: "Invalid page." };
530
757
  }
531
- if (result === "unchanged") {
758
+ if (pageResult === "unchanged") {
532
759
  return { kind: "answer" };
533
760
  }
534
761
  return { kind: "update-menu" };
@@ -577,6 +804,45 @@ export function buildTelegramModelCallbackPlan(
577
804
  };
578
805
  }
579
806
 
807
+ export async function openTelegramStatusMenu<
808
+ TModel extends MenuModel = MenuModel,
809
+ >(deps: TelegramStatusMenuOpenDeps<TModel>): Promise<void> {
810
+ if (!deps.isIdle()) {
811
+ await deps.sendBusyMessage();
812
+ return;
813
+ }
814
+ const state = await deps.getModelMenuState();
815
+ const messageId = await deps.sendStatusMenu(
816
+ state,
817
+ deps.buildStatusHtml(),
818
+ deps.getActiveModel(),
819
+ deps.getThinkingLevel(),
820
+ );
821
+ if (messageId === undefined) return;
822
+ state.messageId = messageId;
823
+ state.mode = "status";
824
+ deps.storeModelMenuState(state);
825
+ }
826
+
827
+ export async function openTelegramModelMenu<
828
+ TModel extends MenuModel = MenuModel,
829
+ >(deps: TelegramModelMenuOpenDeps<TModel>): Promise<void> {
830
+ if (!deps.isIdle() && !deps.canOfferInFlightModelSwitch()) {
831
+ await deps.sendBusyMessage();
832
+ return;
833
+ }
834
+ const state = await deps.getModelMenuState();
835
+ if (state.allModels.length === 0) {
836
+ await deps.sendNoModelsMessage();
837
+ return;
838
+ }
839
+ const messageId = await deps.sendModelMenu(state, deps.getActiveModel());
840
+ if (messageId === undefined) return;
841
+ state.messageId = messageId;
842
+ state.mode = "model";
843
+ deps.storeModelMenuState(state);
844
+ }
845
+
580
846
  export async function handleTelegramMenuCallbackEntry(
581
847
  callbackQueryId: string,
582
848
  data: string | undefined,
@@ -588,7 +854,10 @@ export async function handleTelegramMenuCallbackEntry(
588
854
  return;
589
855
  }
590
856
  if (!state) {
591
- await deps.answerCallbackQuery(callbackQueryId, "Interactive message expired.");
857
+ await deps.answerCallbackQuery(
858
+ callbackQueryId,
859
+ "Interactive message expired.",
860
+ );
592
861
  return;
593
862
  }
594
863
  const handled =
@@ -600,10 +869,197 @@ export async function handleTelegramMenuCallbackEntry(
600
869
  }
601
870
  }
602
871
 
603
- export async function handleTelegramModelMenuCallbackAction(
872
+ export async function handleStoredTelegramMenuCallback<
873
+ TModel extends MenuModel = MenuModel,
874
+ >(
875
+ query: MenuCallbackQuery,
876
+ deps: StoredTelegramMenuCallbackDeps<TModel>,
877
+ ): Promise<void> {
878
+ const state = deps.getStoredModelMenuState(query.message?.message_id);
879
+ await handleTelegramMenuCallbackEntry(query.id, query.data, state, {
880
+ handleStatusAction: async () => {
881
+ if (!state) return false;
882
+ return deps.handleStatusAction(state);
883
+ },
884
+ handleThinkingAction: async () => {
885
+ if (!state) return false;
886
+ return deps.handleThinkingAction(state);
887
+ },
888
+ handleModelAction: async () => {
889
+ if (!state) return false;
890
+ return deps.handleModelAction(state);
891
+ },
892
+ answerCallbackQuery: deps.answerCallbackQuery,
893
+ });
894
+ }
895
+
896
+ export interface TelegramMenuCallbackRuntimeAdapterDeps<
897
+ TContext,
898
+ TModel extends MenuModel = MenuModel,
899
+ > {
900
+ getStoredModelMenuState: (
901
+ messageId: number | undefined,
902
+ ) => TelegramModelMenuState<TModel> | undefined;
903
+ getActiveModel: (ctx: TContext) => TModel | undefined;
904
+ getThinkingLevel: () => ThinkingLevel;
905
+ setThinkingLevel: (level: ThinkingLevel) => void;
906
+ updateStatus: (ctx: TContext, error?: string) => void;
907
+ updateModelMenuMessage: (
908
+ state: TelegramModelMenuState<TModel>,
909
+ ctx: TContext,
910
+ ) => Promise<void>;
911
+ updateThinkingMenuMessage: (
912
+ state: TelegramModelMenuState<TModel>,
913
+ ctx: TContext,
914
+ ) => Promise<void>;
915
+ updateStatusMessage: (
916
+ state: TelegramModelMenuState<TModel>,
917
+ ctx: TContext,
918
+ ) => Promise<void>;
919
+ answerCallbackQuery: (
920
+ callbackQueryId: string,
921
+ text?: string,
922
+ ) => Promise<void>;
923
+ isIdle: (ctx: TContext) => boolean;
924
+ hasActiveTelegramTurn: () => boolean;
925
+ hasAbortHandler: () => boolean;
926
+ getActiveToolExecutions: () => number;
927
+ setModel: (model: TModel) => Promise<boolean>;
928
+ setCurrentModel: (model: TModel, ctx: TContext) => void;
929
+ stagePendingModelSwitch: (
930
+ selection: ScopedTelegramModel<TModel>,
931
+ ctx: TContext,
932
+ ) => void;
933
+ restartInterruptedTelegramTurn: (
934
+ selection: ScopedTelegramModel<TModel>,
935
+ ctx: TContext,
936
+ ) => Promise<boolean> | boolean;
937
+ }
938
+
939
+ export function createTelegramMenuCallbackHandler<
940
+ TQuery extends MenuCallbackQuery,
941
+ TContext,
942
+ TModel extends MenuModel = MenuModel,
943
+ >(
944
+ deps: TelegramMenuCallbackRuntimeDeps<TContext, TModel>,
945
+ ): (query: TQuery, ctx: TContext) => Promise<void> {
946
+ return (query, ctx) => handleTelegramMenuCallbackRuntime(query, ctx, deps);
947
+ }
948
+
949
+ export function createTelegramMenuCallbackHandlerForContext<
950
+ TQuery extends MenuCallbackQuery,
951
+ TContext,
952
+ TModel extends MenuModel = MenuModel,
953
+ >(
954
+ deps: TelegramMenuCallbackRuntimeAdapterDeps<TContext, TModel>,
955
+ ): (query: TQuery, ctx: TContext) => Promise<void> {
956
+ return createTelegramMenuCallbackHandler<TQuery, TContext, TModel>({
957
+ getStoredModelMenuState: deps.getStoredModelMenuState,
958
+ getActiveModel: deps.getActiveModel,
959
+ getThinkingLevel: deps.getThinkingLevel,
960
+ setThinkingLevel: deps.setThinkingLevel,
961
+ updateStatus: deps.updateStatus,
962
+ updateModelMenuMessage: deps.updateModelMenuMessage,
963
+ updateThinkingMenuMessage: deps.updateThinkingMenuMessage,
964
+ updateStatusMessage: deps.updateStatusMessage,
965
+ answerCallbackQuery: deps.answerCallbackQuery,
966
+ isIdle: deps.isIdle,
967
+ hasActiveTelegramTurn: deps.hasActiveTelegramTurn,
968
+ hasAbortHandler: deps.hasAbortHandler,
969
+ hasActiveToolExecutions: () => deps.getActiveToolExecutions() > 0,
970
+ setModel: deps.setModel,
971
+ setCurrentModel: deps.setCurrentModel,
972
+ stagePendingModelSwitch: deps.stagePendingModelSwitch,
973
+ restartInterruptedTelegramTurn: deps.restartInterruptedTelegramTurn,
974
+ });
975
+ }
976
+
977
+ export async function handleTelegramMenuCallbackRuntime<
978
+ TQuery extends MenuCallbackQuery,
979
+ TContext,
980
+ TModel extends MenuModel = MenuModel,
981
+ >(
982
+ query: TQuery,
983
+ ctx: TContext,
984
+ deps: TelegramMenuCallbackRuntimeDeps<TContext, TModel>,
985
+ ): Promise<void> {
986
+ await handleStoredTelegramMenuCallback(query, {
987
+ getStoredModelMenuState: deps.getStoredModelMenuState,
988
+ handleStatusAction: async (state) =>
989
+ handleTelegramStatusMenuCallbackAction(
990
+ query.id,
991
+ query.data,
992
+ deps.getActiveModel(ctx),
993
+ {
994
+ updateModelMenuMessage: () => deps.updateModelMenuMessage(state, ctx),
995
+ updateThinkingMenuMessage: () =>
996
+ deps.updateThinkingMenuMessage(state, ctx),
997
+ answerCallbackQuery: deps.answerCallbackQuery,
998
+ },
999
+ ),
1000
+ handleThinkingAction: async (state) =>
1001
+ handleTelegramThinkingMenuCallbackAction(
1002
+ query.id,
1003
+ query.data,
1004
+ deps.getActiveModel(ctx),
1005
+ {
1006
+ setThinkingLevel: (level) => {
1007
+ deps.setThinkingLevel(level);
1008
+ deps.updateStatus(ctx);
1009
+ },
1010
+ getCurrentThinkingLevel: deps.getThinkingLevel,
1011
+ updateStatusMessage: () => deps.updateStatusMessage(state, ctx),
1012
+ answerCallbackQuery: deps.answerCallbackQuery,
1013
+ },
1014
+ ),
1015
+ handleModelAction: async (state) => {
1016
+ try {
1017
+ return await handleTelegramModelMenuCallbackAction(
1018
+ query.id,
1019
+ {
1020
+ data: query.data,
1021
+ state,
1022
+ activeModel: deps.getActiveModel(ctx),
1023
+ currentThinkingLevel: deps.getThinkingLevel(),
1024
+ isIdle: deps.isIdle(ctx),
1025
+ canRestartBusyRun:
1026
+ deps.hasActiveTelegramTurn() && deps.hasAbortHandler(),
1027
+ hasActiveToolExecutions: deps.hasActiveToolExecutions(),
1028
+ },
1029
+ {
1030
+ updateModelMenuMessage: () =>
1031
+ deps.updateModelMenuMessage(state, ctx),
1032
+ updateStatusMessage: () => deps.updateStatusMessage(state, ctx),
1033
+ answerCallbackQuery: deps.answerCallbackQuery,
1034
+ setModel: deps.setModel,
1035
+ setCurrentModel: (model) => deps.setCurrentModel(model, ctx),
1036
+ setThinkingLevel: (level) => {
1037
+ deps.setThinkingLevel(level);
1038
+ deps.updateStatus(ctx);
1039
+ },
1040
+ stagePendingModelSwitch: (selection) => {
1041
+ deps.stagePendingModelSwitch(selection, ctx);
1042
+ },
1043
+ restartInterruptedTelegramTurn: (selection) =>
1044
+ deps.restartInterruptedTelegramTurn(selection, ctx),
1045
+ },
1046
+ );
1047
+ } catch (error) {
1048
+ const message = error instanceof Error ? error.message : String(error);
1049
+ await deps.answerCallbackQuery(query.id, message);
1050
+ return true;
1051
+ }
1052
+ },
1053
+ answerCallbackQuery: deps.answerCallbackQuery,
1054
+ });
1055
+ }
1056
+
1057
+ export async function handleTelegramModelMenuCallbackAction<
1058
+ TModel extends MenuModel = MenuModel,
1059
+ >(
604
1060
  callbackQueryId: string,
605
- params: BuildTelegramModelCallbackPlanParams,
606
- deps: TelegramModelMenuCallbackDeps,
1061
+ params: BuildTelegramModelCallbackPlanParams<TModel>,
1062
+ deps: TelegramModelMenuCallbackDeps<TModel>,
607
1063
  ): Promise<boolean> {
608
1064
  const plan = buildTelegramModelCallbackPlan(params);
609
1065
  if (plan.kind === "ignore") return false;
@@ -656,7 +1112,7 @@ export async function handleTelegramModelMenuCallbackAction(
656
1112
  export async function handleTelegramStatusMenuCallbackAction(
657
1113
  callbackQueryId: string,
658
1114
  data: string | undefined,
659
- activeModel: Model<any> | undefined,
1115
+ activeModel: MenuModel | undefined,
660
1116
  deps: TelegramStatusMenuCallbackDeps,
661
1117
  ): Promise<boolean> {
662
1118
  const action = parseTelegramMenuCallbackAction(data);
@@ -683,7 +1139,7 @@ export async function handleTelegramStatusMenuCallbackAction(
683
1139
  export async function handleTelegramThinkingMenuCallbackAction(
684
1140
  callbackQueryId: string,
685
1141
  data: string | undefined,
686
- activeModel: Model<any> | undefined,
1142
+ activeModel: MenuModel | undefined,
687
1143
  deps: TelegramThinkingMenuCallbackDeps,
688
1144
  ): Promise<boolean> {
689
1145
  const action = parseTelegramMenuCallbackAction(data);
@@ -709,7 +1165,7 @@ export async function handleTelegramThinkingMenuCallbackAction(
709
1165
  }
710
1166
 
711
1167
  export function buildThinkingMenuText(
712
- activeModel: Model<any> | undefined,
1168
+ activeModel: MenuModel | undefined,
713
1169
  currentThinkingLevel: ThinkingLevel,
714
1170
  ): string {
715
1171
  const lines = ["Choose a thinking level"];
@@ -738,7 +1194,7 @@ export function getTelegramModelMenuPage(
738
1194
 
739
1195
  export function buildModelMenuReplyMarkup(
740
1196
  state: TelegramModelMenuState,
741
- currentModel: Model<any> | undefined,
1197
+ currentModel: MenuModel | undefined,
742
1198
  pageSize: number,
743
1199
  ): TelegramReplyMarkup {
744
1200
  const menuPage = getTelegramModelMenuPage(state, pageSize);
@@ -791,7 +1247,7 @@ export function buildThinkingMenuReplyMarkup(
791
1247
  }
792
1248
 
793
1249
  export function buildStatusReplyMarkup(
794
- activeModel: Model<any> | undefined,
1250
+ activeModel: MenuModel | undefined,
795
1251
  currentThinkingLevel: ThinkingLevel,
796
1252
  ): TelegramReplyMarkup {
797
1253
  const rows: Array<Array<{ text: string; callback_data: string }>> = [];
@@ -817,7 +1273,7 @@ export function buildStatusReplyMarkup(
817
1273
 
818
1274
  export function buildTelegramModelMenuRenderPayload(
819
1275
  state: TelegramModelMenuState,
820
- activeModel: Model<any> | undefined,
1276
+ activeModel: MenuModel | undefined,
821
1277
  ): TelegramMenuRenderPayload {
822
1278
  return {
823
1279
  nextMode: "model",
@@ -832,7 +1288,7 @@ export function buildTelegramModelMenuRenderPayload(
832
1288
  }
833
1289
 
834
1290
  export function buildTelegramThinkingMenuRenderPayload(
835
- activeModel: Model<any> | undefined,
1291
+ activeModel: MenuModel | undefined,
836
1292
  currentThinkingLevel: ThinkingLevel,
837
1293
  ): TelegramMenuRenderPayload {
838
1294
  return {
@@ -845,7 +1301,7 @@ export function buildTelegramThinkingMenuRenderPayload(
845
1301
 
846
1302
  export function buildTelegramStatusMenuRenderPayload(
847
1303
  statusText: string,
848
- activeModel: Model<any> | undefined,
1304
+ activeModel: MenuModel | undefined,
849
1305
  currentThinkingLevel: ThinkingLevel,
850
1306
  ): TelegramMenuRenderPayload {
851
1307
  return {
@@ -856,96 +1312,222 @@ export function buildTelegramStatusMenuRenderPayload(
856
1312
  };
857
1313
  }
858
1314
 
859
- export async function updateTelegramModelMenuMessage(
1315
+ export interface TelegramMenuActionRuntimeWithStateBuilderDeps<
1316
+ TModel extends MenuModel = MenuModel,
1317
+ TContext extends TelegramModelMenuStateBuilderContext<TModel> =
1318
+ TelegramModelMenuStateBuilderContext<TModel>,
1319
+ >
1320
+ extends
1321
+ Omit<TelegramMenuActionRuntimeDeps<TContext, TModel>, "getModelMenuState">,
1322
+ TelegramModelMenuStateBuilderDeps<TModel, TContext> {}
1323
+
1324
+ export function createTelegramMenuActionRuntimeWithStateBuilder<
1325
+ TModel extends MenuModel = MenuModel,
1326
+ TContext extends TelegramModelMenuStateBuilderContext<TModel> =
1327
+ TelegramModelMenuStateBuilderContext<TModel>,
1328
+ >(
1329
+ deps: TelegramMenuActionRuntimeWithStateBuilderDeps<TModel, TContext>,
1330
+ ): TelegramMenuActionRuntime<TContext, TModel> {
1331
+ return createTelegramMenuActionRuntime({
1332
+ getModelMenuState: createTelegramModelMenuStateBuilder({
1333
+ runtime: deps.runtime,
1334
+ createSettingsManager: deps.createSettingsManager,
1335
+ getActiveModel: deps.getActiveModel,
1336
+ }),
1337
+ getActiveModel: deps.getActiveModel,
1338
+ getThinkingLevel: deps.getThinkingLevel,
1339
+ buildStatusHtml: deps.buildStatusHtml,
1340
+ storeModelMenuState: deps.storeModelMenuState,
1341
+ isIdle: deps.isIdle,
1342
+ canOfferInFlightModelSwitch: deps.canOfferInFlightModelSwitch,
1343
+ sendTextReply: deps.sendTextReply,
1344
+ editInteractiveMessage: deps.editInteractiveMessage,
1345
+ sendInteractiveMessage: deps.sendInteractiveMessage,
1346
+ });
1347
+ }
1348
+
1349
+ export function createTelegramMenuActionRuntime<
1350
+ TContext,
1351
+ TModel extends MenuModel = MenuModel,
1352
+ >(
1353
+ deps: TelegramMenuActionRuntimeDeps<TContext, TModel>,
1354
+ ): TelegramMenuActionRuntime<TContext, TModel> {
1355
+ return {
1356
+ updateModelMenuMessage: (state, ctx) =>
1357
+ updateTelegramModelMenuMessage(state, deps.getActiveModel(ctx), deps),
1358
+ updateThinkingMenuMessage: (state, ctx) =>
1359
+ updateTelegramThinkingMenuMessage(
1360
+ state,
1361
+ deps.getActiveModel(ctx),
1362
+ deps.getThinkingLevel(),
1363
+ deps,
1364
+ ),
1365
+ updateStatusMessage: (state, ctx) =>
1366
+ updateTelegramStatusMessage(
1367
+ state,
1368
+ deps.buildStatusHtml(ctx),
1369
+ deps.getActiveModel(ctx),
1370
+ deps.getThinkingLevel(),
1371
+ deps,
1372
+ ),
1373
+ sendStatusMessage: (chatId, replyToMessageId, ctx) =>
1374
+ openTelegramStatusMenu({
1375
+ isIdle: () => deps.isIdle(ctx),
1376
+ sendBusyMessage: async () => {
1377
+ await deps.sendTextReply(
1378
+ chatId,
1379
+ replyToMessageId,
1380
+ "Cannot open status while pi is busy. Send /stop first.",
1381
+ );
1382
+ },
1383
+ getModelMenuState: () => deps.getModelMenuState(chatId, ctx),
1384
+ buildStatusHtml: () => deps.buildStatusHtml(ctx),
1385
+ getActiveModel: () => deps.getActiveModel(ctx),
1386
+ getThinkingLevel: deps.getThinkingLevel,
1387
+ sendStatusMenu: (state, statusHtml, activeModel, thinkingLevel) =>
1388
+ sendTelegramStatusMessage(
1389
+ state,
1390
+ statusHtml,
1391
+ activeModel,
1392
+ thinkingLevel,
1393
+ deps,
1394
+ ),
1395
+ storeModelMenuState: deps.storeModelMenuState,
1396
+ }),
1397
+ openModelMenu: (chatId, replyToMessageId, ctx) =>
1398
+ openTelegramModelMenu({
1399
+ isIdle: () => deps.isIdle(ctx),
1400
+ canOfferInFlightModelSwitch: () =>
1401
+ deps.canOfferInFlightModelSwitch(ctx),
1402
+ sendBusyMessage: async () => {
1403
+ await deps.sendTextReply(
1404
+ chatId,
1405
+ replyToMessageId,
1406
+ "Cannot switch model while pi is busy. Send /stop first.",
1407
+ );
1408
+ },
1409
+ sendNoModelsMessage: async () => {
1410
+ await deps.sendTextReply(
1411
+ chatId,
1412
+ replyToMessageId,
1413
+ "No available models with configured auth.",
1414
+ );
1415
+ },
1416
+ getModelMenuState: () => deps.getModelMenuState(chatId, ctx),
1417
+ getActiveModel: () => deps.getActiveModel(ctx),
1418
+ sendModelMenu: (state, activeModel) =>
1419
+ sendTelegramModelMenuMessage(state, activeModel, deps),
1420
+ storeModelMenuState: deps.storeModelMenuState,
1421
+ }),
1422
+ };
1423
+ }
1424
+
1425
+ function applyTelegramMenuRenderPayload(
860
1426
  state: TelegramModelMenuState,
861
- activeModel: Model<any> | undefined,
1427
+ payload: TelegramMenuRenderPayload,
1428
+ ): TelegramMenuRenderPayload {
1429
+ state.mode = payload.nextMode;
1430
+ return payload;
1431
+ }
1432
+
1433
+ async function editTelegramMenuMessage(
1434
+ state: TelegramModelMenuState,
1435
+ payload: TelegramMenuRenderPayload,
862
1436
  deps: TelegramMenuMessageRuntimeDeps,
863
1437
  ): Promise<void> {
864
- const payload = buildTelegramModelMenuRenderPayload(state, activeModel);
865
- state.mode = payload.nextMode;
1438
+ const appliedPayload = applyTelegramMenuRenderPayload(state, payload);
866
1439
  await deps.editInteractiveMessage(
867
1440
  state.chatId,
868
1441
  state.messageId,
869
- payload.text,
870
- payload.mode,
871
- payload.replyMarkup,
1442
+ appliedPayload.text,
1443
+ appliedPayload.mode,
1444
+ appliedPayload.replyMarkup,
1445
+ );
1446
+ }
1447
+
1448
+ function sendTelegramMenuMessage(
1449
+ state: TelegramModelMenuState,
1450
+ payload: TelegramMenuRenderPayload,
1451
+ deps: TelegramMenuMessageRuntimeDeps,
1452
+ ): Promise<number | undefined> {
1453
+ const appliedPayload = applyTelegramMenuRenderPayload(state, payload);
1454
+ return deps.sendInteractiveMessage(
1455
+ state.chatId,
1456
+ appliedPayload.text,
1457
+ appliedPayload.mode,
1458
+ appliedPayload.replyMarkup,
1459
+ );
1460
+ }
1461
+
1462
+ export async function updateTelegramModelMenuMessage(
1463
+ state: TelegramModelMenuState,
1464
+ activeModel: MenuModel | undefined,
1465
+ deps: TelegramMenuMessageRuntimeDeps,
1466
+ ): Promise<void> {
1467
+ await editTelegramMenuMessage(
1468
+ state,
1469
+ buildTelegramModelMenuRenderPayload(state, activeModel),
1470
+ deps,
872
1471
  );
873
1472
  }
874
1473
 
875
1474
  export async function updateTelegramThinkingMenuMessage(
876
1475
  state: TelegramModelMenuState,
877
- activeModel: Model<any> | undefined,
1476
+ activeModel: MenuModel | undefined,
878
1477
  currentThinkingLevel: ThinkingLevel,
879
1478
  deps: TelegramMenuMessageRuntimeDeps,
880
1479
  ): Promise<void> {
881
- const payload = buildTelegramThinkingMenuRenderPayload(
882
- activeModel,
883
- currentThinkingLevel,
884
- );
885
- state.mode = payload.nextMode;
886
- await deps.editInteractiveMessage(
887
- state.chatId,
888
- state.messageId,
889
- payload.text,
890
- payload.mode,
891
- payload.replyMarkup,
1480
+ await editTelegramMenuMessage(
1481
+ state,
1482
+ buildTelegramThinkingMenuRenderPayload(activeModel, currentThinkingLevel),
1483
+ deps,
892
1484
  );
893
1485
  }
894
1486
 
895
1487
  export async function updateTelegramStatusMessage(
896
1488
  state: TelegramModelMenuState,
897
1489
  statusText: string,
898
- activeModel: Model<any> | undefined,
1490
+ activeModel: MenuModel | undefined,
899
1491
  currentThinkingLevel: ThinkingLevel,
900
1492
  deps: TelegramMenuMessageRuntimeDeps,
901
1493
  ): Promise<void> {
902
- const payload = buildTelegramStatusMenuRenderPayload(
903
- statusText,
904
- activeModel,
905
- currentThinkingLevel,
906
- );
907
- state.mode = payload.nextMode;
908
- await deps.editInteractiveMessage(
909
- state.chatId,
910
- state.messageId,
911
- payload.text,
912
- payload.mode,
913
- payload.replyMarkup,
1494
+ await editTelegramMenuMessage(
1495
+ state,
1496
+ buildTelegramStatusMenuRenderPayload(
1497
+ statusText,
1498
+ activeModel,
1499
+ currentThinkingLevel,
1500
+ ),
1501
+ deps,
914
1502
  );
915
1503
  }
916
1504
 
917
- export async function sendTelegramStatusMessage(
1505
+ export function sendTelegramStatusMessage(
918
1506
  state: TelegramModelMenuState,
919
1507
  statusText: string,
920
- activeModel: Model<any> | undefined,
1508
+ activeModel: MenuModel | undefined,
921
1509
  currentThinkingLevel: ThinkingLevel,
922
1510
  deps: TelegramMenuMessageRuntimeDeps,
923
1511
  ): Promise<number | undefined> {
924
- const payload = buildTelegramStatusMenuRenderPayload(
925
- statusText,
926
- activeModel,
927
- currentThinkingLevel,
928
- );
929
- state.mode = payload.nextMode;
930
- return deps.sendInteractiveMessage(
931
- state.chatId,
932
- payload.text,
933
- payload.mode,
934
- payload.replyMarkup,
1512
+ return sendTelegramMenuMessage(
1513
+ state,
1514
+ buildTelegramStatusMenuRenderPayload(
1515
+ statusText,
1516
+ activeModel,
1517
+ currentThinkingLevel,
1518
+ ),
1519
+ deps,
935
1520
  );
936
1521
  }
937
1522
 
938
- export async function sendTelegramModelMenuMessage(
1523
+ export function sendTelegramModelMenuMessage(
939
1524
  state: TelegramModelMenuState,
940
- activeModel: Model<any> | undefined,
1525
+ activeModel: MenuModel | undefined,
941
1526
  deps: TelegramMenuMessageRuntimeDeps,
942
1527
  ): Promise<number | undefined> {
943
- const payload = buildTelegramModelMenuRenderPayload(state, activeModel);
944
- state.mode = payload.nextMode;
945
- return deps.sendInteractiveMessage(
946
- state.chatId,
947
- payload.text,
948
- payload.mode,
949
- payload.replyMarkup,
1528
+ return sendTelegramMenuMessage(
1529
+ state,
1530
+ buildTelegramModelMenuRenderPayload(state, activeModel),
1531
+ deps,
950
1532
  );
951
1533
  }