From 16349b6e9312a4627fb3e3292511e298487e16ac Mon Sep 17 00:00:00 2001 From: Ermenegildo Fiorito Date: Tue, 3 Feb 2026 19:03:00 +0100 Subject: [PATCH] Telegram: add inline button model selection for /models and /model commands --- src/auto-reply/reply/commands-models.ts | 227 ++++++++++------ src/auto-reply/reply/commands-policy.test.ts | 31 ++- .../reply/directive-handling.impl.ts | 2 + .../reply/directive-handling.model.ts | 18 ++ .../reply/get-reply-directives-apply.ts | 1 + src/telegram/bot-handlers.ts | 110 ++++++++ src/telegram/model-buttons.test.ts | 244 ++++++++++++++++++ src/telegram/model-buttons.ts | 210 +++++++++++++++ 8 files changed, 757 insertions(+), 86 deletions(-) create mode 100644 src/telegram/model-buttons.test.ts create mode 100644 src/telegram/model-buttons.ts diff --git a/src/auto-reply/reply/commands-models.ts b/src/auto-reply/reply/commands-models.ts index dcef33d42..08d4b9508 100644 --- a/src/auto-reply/reply/commands-models.ts +++ b/src/auto-reply/reply/commands-models.ts @@ -10,10 +10,112 @@ import { resolveConfiguredModelRef, resolveModelRefFromString, } from "../../agents/model-selection.js"; +import { + buildModelsKeyboard, + buildProviderKeyboard, + calculateTotalPages, + getModelsPageSize, + type ProviderInfo, +} from "../../telegram/model-buttons.js"; const PAGE_SIZE_DEFAULT = 20; const PAGE_SIZE_MAX = 100; +export type ModelsProviderData = { + byProvider: Map>; + providers: string[]; + resolvedDefault: { provider: string; model: string }; +}; + +/** + * Build provider/model data from config and catalog. + * Exported for reuse by callback handlers. + */ +export async function buildModelsProviderData(cfg: OpenClawConfig): Promise { + const resolvedDefault = resolveConfiguredModelRef({ + cfg, + defaultProvider: DEFAULT_PROVIDER, + defaultModel: DEFAULT_MODEL, + }); + + const catalog = await loadModelCatalog({ config: cfg }); + const allowed = buildAllowedModelSet({ + cfg, + catalog, + defaultProvider: resolvedDefault.provider, + defaultModel: resolvedDefault.model, + }); + + const aliasIndex = buildModelAliasIndex({ + cfg, + defaultProvider: resolvedDefault.provider, + }); + + const byProvider = new Map>(); + const add = (p: string, m: string) => { + const key = normalizeProviderId(p); + const set = byProvider.get(key) ?? new Set(); + set.add(m); + byProvider.set(key, set); + }; + + const addRawModelRef = (raw?: string) => { + const trimmed = raw?.trim(); + if (!trimmed) { + return; + } + const resolved = resolveModelRefFromString({ + raw: trimmed, + defaultProvider: resolvedDefault.provider, + aliasIndex, + }); + if (!resolved) { + return; + } + add(resolved.ref.provider, resolved.ref.model); + }; + + const addModelConfigEntries = () => { + const modelConfig = cfg.agents?.defaults?.model; + if (typeof modelConfig === "string") { + addRawModelRef(modelConfig); + } else if (modelConfig && typeof modelConfig === "object") { + addRawModelRef(modelConfig.primary); + for (const fallback of modelConfig.fallbacks ?? []) { + addRawModelRef(fallback); + } + } + + const imageConfig = cfg.agents?.defaults?.imageModel; + if (typeof imageConfig === "string") { + addRawModelRef(imageConfig); + } else if (imageConfig && typeof imageConfig === "object") { + addRawModelRef(imageConfig.primary); + for (const fallback of imageConfig.fallbacks ?? []) { + addRawModelRef(fallback); + } + } + }; + + for (const entry of allowed.allowedCatalog) { + add(entry.provider, entry.id); + } + + // Include config-only allowlist keys that aren't in the curated catalog. + for (const raw of Object.keys(cfg.agents?.defaults?.models ?? {})) { + addRawModelRef(raw); + } + + // Ensure configured defaults/fallbacks/image models show up even when the + // curated catalog doesn't know about them (custom providers, dev builds, etc.). + add(resolvedDefault.provider, resolvedDefault.model); + addModelConfigEntries(); + + const providers = [...byProvider.keys()].toSorted(); + + return { byProvider, providers, resolvedDefault }; +} + function formatProviderLine(params: { provider: string; count: number }): string { return `- ${params.provider} (${params.count})`; } @@ -78,6 +180,8 @@ function parseModelsArgs(raw: string): { export async function resolveModelsCommandReply(params: { cfg: OpenClawConfig; commandBodyNormalized: string; + surface?: string; + currentModel?: string; }): Promise { const body = params.commandBodyNormalized.trim(); if (!body.startsWith("/models")) { @@ -87,88 +191,26 @@ export async function resolveModelsCommandReply(params: { const argText = body.replace(/^\/models\b/i, "").trim(); const { provider, page, pageSize, all } = parseModelsArgs(argText); - const resolvedDefault = resolveConfiguredModelRef({ - cfg: params.cfg, - defaultProvider: DEFAULT_PROVIDER, - defaultModel: DEFAULT_MODEL, - }); - - const catalog = await loadModelCatalog({ config: params.cfg }); - const allowed = buildAllowedModelSet({ - cfg: params.cfg, - catalog, - defaultProvider: resolvedDefault.provider, - defaultModel: resolvedDefault.model, - }); - - const aliasIndex = buildModelAliasIndex({ - cfg: params.cfg, - defaultProvider: resolvedDefault.provider, - }); - - const byProvider = new Map>(); - const add = (p: string, m: string) => { - const key = normalizeProviderId(p); - const set = byProvider.get(key) ?? new Set(); - set.add(m); - byProvider.set(key, set); - }; - - const addRawModelRef = (raw?: string) => { - const trimmed = raw?.trim(); - if (!trimmed) { - return; - } - const resolved = resolveModelRefFromString({ - raw: trimmed, - defaultProvider: resolvedDefault.provider, - aliasIndex, - }); - if (!resolved) { - return; - } - add(resolved.ref.provider, resolved.ref.model); - }; - - const addModelConfigEntries = () => { - const modelConfig = params.cfg.agents?.defaults?.model; - if (typeof modelConfig === "string") { - addRawModelRef(modelConfig); - } else if (modelConfig && typeof modelConfig === "object") { - addRawModelRef(modelConfig.primary); - for (const fallback of modelConfig.fallbacks ?? []) { - addRawModelRef(fallback); - } - } - - const imageConfig = params.cfg.agents?.defaults?.imageModel; - if (typeof imageConfig === "string") { - addRawModelRef(imageConfig); - } else if (imageConfig && typeof imageConfig === "object") { - addRawModelRef(imageConfig.primary); - for (const fallback of imageConfig.fallbacks ?? []) { - addRawModelRef(fallback); - } - } - }; - - for (const entry of allowed.allowedCatalog) { - add(entry.provider, entry.id); - } - - // Include config-only allowlist keys that aren't in the curated catalog. - for (const raw of Object.keys(params.cfg.agents?.defaults?.models ?? {})) { - addRawModelRef(raw); - } - - // Ensure configured defaults/fallbacks/image models show up even when the - // curated catalog doesn't know about them (custom providers, dev builds, etc.). - add(resolvedDefault.provider, resolvedDefault.model); - addModelConfigEntries(); - - const providers = [...byProvider.keys()].toSorted(); + const { byProvider, providers } = await buildModelsProviderData(params.cfg); + const isTelegram = params.surface === "telegram"; + // Provider list (no provider specified) if (!provider) { + // For Telegram: show buttons if there are providers + if (isTelegram && providers.length > 0) { + const providerInfos: ProviderInfo[] = providers.map((p) => ({ + id: p, + count: byProvider.get(p)?.size ?? 0, + })); + const buttons = buildProviderKeyboard(providerInfos); + const text = "Select a provider:"; + return { + text, + channelData: { telegram: { buttons } }, + }; + } + + // Text fallback for non-Telegram surfaces const lines: string[] = [ "Providers:", ...providers.map((p) => @@ -206,6 +248,29 @@ export async function resolveModelsCommandReply(params: { return { text: lines.join("\n") }; } + // For Telegram: use button-based model list with inline keyboard pagination + if (isTelegram) { + const telegramPageSize = getModelsPageSize(); + const totalPages = calculateTotalPages(total, telegramPageSize); + const safePage = Math.max(1, Math.min(page, totalPages)); + + const buttons = buildModelsKeyboard({ + provider, + models, + currentModel: params.currentModel, + currentPage: safePage, + totalPages, + pageSize: telegramPageSize, + }); + + const text = `Models (${provider}) — ${total} available`; + return { + text, + channelData: { telegram: { buttons } }, + }; + } + + // Text fallback for non-Telegram surfaces const effectivePageSize = all ? total : pageSize; const pageCount = effectivePageSize > 0 ? Math.ceil(total / effectivePageSize) : 1; const safePage = all ? 1 : Math.max(1, Math.min(page, pageCount)); @@ -251,6 +316,8 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma const reply = await resolveModelsCommandReply({ cfg: params.cfg, commandBodyNormalized: params.command.commandBodyNormalized, + surface: params.ctx.Surface, + currentModel: params.model ? `${params.provider}/${params.model}` : undefined, }); if (!reply) { return null; diff --git a/src/auto-reply/reply/commands-policy.test.ts b/src/auto-reply/reply/commands-policy.test.ts index 20c3f6828..aa747b24c 100644 --- a/src/auto-reply/reply/commands-policy.test.ts +++ b/src/auto-reply/reply/commands-policy.test.ts @@ -153,7 +153,7 @@ describe("/models command", () => { agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } }, } as unknown as OpenClawConfig; - it.each(["telegram", "discord", "whatsapp"])("lists providers on %s", async (surface) => { + it.each(["discord", "whatsapp"])("lists providers on %s (text)", async (surface) => { const params = buildParams("/models", cfg, { Provider: surface, Surface: surface }); const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); @@ -162,8 +162,20 @@ describe("/models command", () => { expect(result.reply?.text).toContain("Use: /models "); }); + it("lists providers on telegram (buttons)", async () => { + const params = buildParams("/models", cfg, { Provider: "telegram", Surface: "telegram" }); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toBe("Select a provider:"); + const buttons = (result.reply?.channelData as { telegram?: { buttons?: unknown[][] } }) + ?.telegram?.buttons; + expect(buttons).toBeDefined(); + expect(buttons?.length).toBeGreaterThan(0); + }); + it("lists provider models with pagination hints", async () => { - const params = buildParams("/models anthropic", cfg); + // Use discord surface for text-based output tests + const params = buildParams("/models anthropic", cfg, { Surface: "discord" }); const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("Models (anthropic)"); @@ -174,7 +186,8 @@ describe("/models command", () => { }); it("ignores page argument when all flag is present", async () => { - const params = buildParams("/models anthropic 3 all", cfg); + // Use discord surface for text-based output tests + const params = buildParams("/models anthropic 3 all", cfg, { Surface: "discord" }); const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("Models (anthropic)"); @@ -184,7 +197,8 @@ describe("/models command", () => { }); it("errors on out-of-range pages", async () => { - const params = buildParams("/models anthropic 4", cfg); + // Use discord surface for text-based output tests + const params = buildParams("/models anthropic 4", cfg, { Surface: "discord" }); const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("Page out of range"); @@ -213,11 +227,16 @@ describe("/models command", () => { }, } as unknown as OpenClawConfig; - const providerList = await handleCommands(buildParams("/models", customCfg)); + // Use discord surface for text-based output tests + const providerList = await handleCommands( + buildParams("/models", customCfg, { Surface: "discord" }), + ); expect(providerList.reply?.text).toContain("localai"); expect(providerList.reply?.text).toContain("visionpro"); - const result = await handleCommands(buildParams("/models localai", customCfg)); + const result = await handleCommands( + buildParams("/models localai", customCfg, { Surface: "discord" }), + ); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("Models (localai)"); expect(result.reply?.text).toContain("localai/ultra-chat"); diff --git a/src/auto-reply/reply/directive-handling.impl.ts b/src/auto-reply/reply/directive-handling.impl.ts index a4ebc46a4..463cb42d6 100644 --- a/src/auto-reply/reply/directive-handling.impl.ts +++ b/src/auto-reply/reply/directive-handling.impl.ts @@ -85,6 +85,7 @@ export async function handleDirectiveOnly(params: { currentVerboseLevel?: VerboseLevel; currentReasoningLevel?: ReasoningLevel; currentElevatedLevel?: ElevatedLevel; + surface?: string; }): Promise { const { directives, @@ -132,6 +133,7 @@ export async function handleDirectiveOnly(params: { aliasIndex, allowedModelCatalog, resetModelOverride, + surface: params.surface, }); if (modelInfo) { return modelInfo; diff --git a/src/auto-reply/reply/directive-handling.model.ts b/src/auto-reply/reply/directive-handling.model.ts index c517b5cb1..dc36c54fb 100644 --- a/src/auto-reply/reply/directive-handling.model.ts +++ b/src/auto-reply/reply/directive-handling.model.ts @@ -9,6 +9,7 @@ import { resolveConfiguredModelRef, resolveModelRefFromString, } from "../../agents/model-selection.js"; +import { buildBrowseProvidersButton } from "../../telegram/model-buttons.js"; import { shortenHomePath } from "../../utils.js"; import { resolveModelsCommandReply } from "./commands-models.js"; import { @@ -177,6 +178,7 @@ export async function maybeHandleModelDirectiveInfo(params: { aliasIndex: ModelAliasIndex; allowedModelCatalog: Array<{ provider: string; id?: string; name?: string }>; resetModelOverride: boolean; + surface?: string; }): Promise { if (!params.directives.hasModelDirective) { return undefined; @@ -213,6 +215,22 @@ export async function maybeHandleModelDirectiveInfo(params: { if (wantsSummary) { const current = `${params.provider}/${params.model}`; + const isTelegram = params.surface === "telegram"; + + if (isTelegram) { + const buttons = buildBrowseProvidersButton(); + return { + text: [ + `Current: ${current}`, + "", + "Tap below to browse models, or use:", + "/model to switch", + "/model status for details", + ].join("\n"), + channelData: { telegram: { buttons } }, + }; + } + return { text: [ `Current: ${current}`, diff --git a/src/auto-reply/reply/get-reply-directives-apply.ts b/src/auto-reply/reply/get-reply-directives-apply.ts index 4b926240f..0a75a339f 100644 --- a/src/auto-reply/reply/get-reply-directives-apply.ts +++ b/src/auto-reply/reply/get-reply-directives-apply.ts @@ -183,6 +183,7 @@ export async function applyInlineDirectiveOverrides(params: { currentVerboseLevel, currentReasoningLevel, currentElevatedLevel, + surface: ctx.Surface, }); let statusReply: ReplyPayload | undefined; if (directives.hasStatusDirective && allowTextCommands && command.isAuthorizedSender) { diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts index 1daac5690..74decd5f9 100644 --- a/src/telegram/bot-handlers.ts +++ b/src/telegram/bot-handlers.ts @@ -7,6 +7,7 @@ import { resolveInboundDebounceMs, } from "../auto-reply/inbound-debounce.js"; import { buildCommandsPaginationKeyboard } from "../auto-reply/reply/commands-info.js"; +import { buildModelsProviderData } from "../auto-reply/reply/commands-models.js"; import { listSkillCommandsForAgents } from "../auto-reply/skill-commands.js"; import { buildCommandsMessagePaginated } from "../auto-reply/status.js"; import { resolveChannelConfigWrites } from "../channels/plugins/config-writes.js"; @@ -22,6 +23,14 @@ import { resolveMedia } from "./bot/delivery.js"; import { resolveTelegramForumThreadId } from "./bot/helpers.js"; import { migrateTelegramGroupConfig } from "./group-migration.js"; import { resolveTelegramInlineButtonsScope } from "./inline-buttons.js"; +import { + buildModelsKeyboard, + buildProviderKeyboard, + calculateTotalPages, + getModelsPageSize, + parseModelCallbackData, + type ProviderInfo, +} from "./model-buttons.js"; import { buildInlineKeyboard } from "./send.js"; export const registerTelegramHandlers = ({ @@ -404,6 +413,107 @@ export const registerTelegramHandlers = ({ return; } + // Model selection callback handler (mdl_prov, mdl_list_*, mdl_sel_*, mdl_back) + const modelCallback = parseModelCallbackData(data); + if (modelCallback) { + const modelData = await buildModelsProviderData(cfg); + const { byProvider, providers } = modelData; + + const editMessageWithButtons = async ( + text: string, + buttons: ReturnType, + ) => { + const keyboard = buildInlineKeyboard(buttons); + try { + await bot.api.editMessageText( + callbackMessage.chat.id, + callbackMessage.message_id, + text, + keyboard ? { reply_markup: keyboard } : undefined, + ); + } catch (editErr) { + const errStr = String(editErr); + if (!errStr.includes("message is not modified")) { + throw editErr; + } + } + }; + + if (modelCallback.type === "providers" || modelCallback.type === "back") { + if (providers.length === 0) { + await editMessageWithButtons("No providers available.", []); + return; + } + const providerInfos: ProviderInfo[] = providers.map((p) => ({ + id: p, + count: byProvider.get(p)?.size ?? 0, + })); + const buttons = buildProviderKeyboard(providerInfos); + await editMessageWithButtons("Select a provider:", buttons); + return; + } + + if (modelCallback.type === "list") { + const { provider, page } = modelCallback; + const modelSet = byProvider.get(provider); + if (!modelSet || modelSet.size === 0) { + // Provider not found or no models - show providers list + const providerInfos: ProviderInfo[] = providers.map((p) => ({ + id: p, + count: byProvider.get(p)?.size ?? 0, + })); + const buttons = buildProviderKeyboard(providerInfos); + await editMessageWithButtons( + `Unknown provider: ${provider}\n\nSelect a provider:`, + buttons, + ); + return; + } + const models = [...modelSet].toSorted(); + const pageSize = getModelsPageSize(); + const totalPages = calculateTotalPages(models.length, pageSize); + const safePage = Math.max(1, Math.min(page, totalPages)); + + const buttons = buildModelsKeyboard({ + provider, + models, + currentPage: safePage, + totalPages, + pageSize, + }); + const text = `Models (${provider}) — ${models.length} available`; + await editMessageWithButtons(text, buttons); + return; + } + + if (modelCallback.type === "select") { + const { provider, model } = modelCallback; + // Process model selection as a synthetic message with /model command + const syntheticMessage: TelegramMessage = { + ...callbackMessage, + from: callback.from, + text: `/model ${provider}/${model}`, + caption: undefined, + caption_entities: undefined, + entities: undefined, + }; + const getFile = + typeof ctx.getFile === "function" ? ctx.getFile.bind(ctx) : async () => ({}); + await processMessage( + { message: syntheticMessage, me: ctx.me, getFile }, + [], + storeAllowFrom, + { + forceWasMentioned: true, + messageIdOverride: callback.id, + }, + ); + return; + } + + return; + } + const syntheticMessage: TelegramMessage = { ...callbackMessage, from: callback.from, diff --git a/src/telegram/model-buttons.test.ts b/src/telegram/model-buttons.test.ts new file mode 100644 index 000000000..69f93273e --- /dev/null +++ b/src/telegram/model-buttons.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, it } from "vitest"; +import { + buildModelsKeyboard, + buildProviderKeyboard, + buildBrowseProvidersButton, + calculateTotalPages, + getModelsPageSize, + parseModelCallbackData, + type ProviderInfo, +} from "./model-buttons.js"; + +describe("parseModelCallbackData", () => { + it("parses mdl_prov callback", () => { + const result = parseModelCallbackData("mdl_prov"); + expect(result).toEqual({ type: "providers" }); + }); + + it("parses mdl_back callback", () => { + const result = parseModelCallbackData("mdl_back"); + expect(result).toEqual({ type: "back" }); + }); + + it("parses mdl_list callback with provider and page", () => { + const result = parseModelCallbackData("mdl_list_anthropic_2"); + expect(result).toEqual({ type: "list", provider: "anthropic", page: 2 }); + }); + + it("parses mdl_list callback with hyphenated provider", () => { + const result = parseModelCallbackData("mdl_list_open-ai_1"); + expect(result).toEqual({ type: "list", provider: "open-ai", page: 1 }); + }); + + it("parses mdl_sel callback with provider/model", () => { + const result = parseModelCallbackData("mdl_sel_anthropic/claude-sonnet-4-5"); + expect(result).toEqual({ + type: "select", + provider: "anthropic", + model: "claude-sonnet-4-5", + }); + }); + + it("parses mdl_sel callback with nested model path", () => { + const result = parseModelCallbackData("mdl_sel_openai/gpt-4/turbo"); + expect(result).toEqual({ + type: "select", + provider: "openai", + model: "gpt-4/turbo", + }); + }); + + it("returns null for non-model callback data", () => { + expect(parseModelCallbackData("commands_page_1")).toBeNull(); + expect(parseModelCallbackData("other_callback")).toBeNull(); + expect(parseModelCallbackData("")).toBeNull(); + }); + + it("returns null for invalid mdl_ patterns", () => { + expect(parseModelCallbackData("mdl_invalid")).toBeNull(); + expect(parseModelCallbackData("mdl_list_")).toBeNull(); + expect(parseModelCallbackData("mdl_sel_noslash")).toBeNull(); + }); + + it("handles whitespace in callback data", () => { + expect(parseModelCallbackData(" mdl_prov ")).toEqual({ type: "providers" }); + }); +}); + +describe("buildProviderKeyboard", () => { + it("returns empty array for no providers", () => { + const result = buildProviderKeyboard([]); + expect(result).toEqual([]); + }); + + it("builds single provider as one row", () => { + const providers: ProviderInfo[] = [{ id: "anthropic", count: 5 }]; + const result = buildProviderKeyboard(providers); + expect(result).toHaveLength(1); + expect(result[0]).toHaveLength(1); + expect(result[0]?.[0]?.text).toBe("anthropic (5)"); + expect(result[0]?.[0]?.callback_data).toBe("mdl_list_anthropic_1"); + }); + + it("builds two providers per row", () => { + const providers: ProviderInfo[] = [ + { id: "anthropic", count: 5 }, + { id: "openai", count: 8 }, + ]; + const result = buildProviderKeyboard(providers); + expect(result).toHaveLength(1); + expect(result[0]).toHaveLength(2); + expect(result[0]?.[0]?.text).toBe("anthropic (5)"); + expect(result[0]?.[1]?.text).toBe("openai (8)"); + }); + + it("wraps to next row after two providers", () => { + const providers: ProviderInfo[] = [ + { id: "anthropic", count: 5 }, + { id: "openai", count: 8 }, + { id: "google", count: 3 }, + ]; + const result = buildProviderKeyboard(providers); + expect(result).toHaveLength(2); + expect(result[0]).toHaveLength(2); + expect(result[1]).toHaveLength(1); + expect(result[1]?.[0]?.text).toBe("google (3)"); + }); +}); + +describe("buildModelsKeyboard", () => { + it("shows back button for empty models", () => { + const result = buildModelsKeyboard({ + provider: "anthropic", + models: [], + currentPage: 1, + totalPages: 1, + }); + expect(result).toHaveLength(1); + expect(result[0]?.[0]?.text).toBe("<< Back"); + expect(result[0]?.[0]?.callback_data).toBe("mdl_back"); + }); + + it("shows models with one per row", () => { + const result = buildModelsKeyboard({ + provider: "anthropic", + models: ["claude-sonnet-4", "claude-opus-4"], + currentPage: 1, + totalPages: 1, + }); + // 2 model rows + back button + expect(result).toHaveLength(3); + expect(result[0]?.[0]?.text).toBe("claude-sonnet-4"); + expect(result[0]?.[0]?.callback_data).toBe("mdl_sel_anthropic/claude-sonnet-4"); + expect(result[1]?.[0]?.text).toBe("claude-opus-4"); + expect(result[2]?.[0]?.text).toBe("<< Back"); + }); + + it("marks current model with checkmark", () => { + const result = buildModelsKeyboard({ + provider: "anthropic", + models: ["claude-sonnet-4", "claude-opus-4"], + currentModel: "anthropic/claude-sonnet-4", + currentPage: 1, + totalPages: 1, + }); + expect(result[0]?.[0]?.text).toBe("claude-sonnet-4 ✓"); + expect(result[1]?.[0]?.text).toBe("claude-opus-4"); + }); + + it("shows pagination when multiple pages", () => { + const result = buildModelsKeyboard({ + provider: "anthropic", + models: ["model1", "model2"], + currentPage: 1, + totalPages: 3, + pageSize: 2, + }); + // 2 model rows + pagination row + back button + expect(result).toHaveLength(4); + const paginationRow = result[2]; + expect(paginationRow).toHaveLength(2); // no prev on first page + expect(paginationRow?.[0]?.text).toBe("1/3"); + expect(paginationRow?.[1]?.text).toBe("Next ▶"); + }); + + it("shows prev and next on middle pages", () => { + // 6 models with pageSize 2 = 3 pages + const result = buildModelsKeyboard({ + provider: "anthropic", + models: ["model1", "model2", "model3", "model4", "model5", "model6"], + currentPage: 2, + totalPages: 3, + pageSize: 2, + }); + // 2 model rows + pagination row + back button + expect(result).toHaveLength(4); + const paginationRow = result[2]; + expect(paginationRow).toHaveLength(3); + expect(paginationRow?.[0]?.text).toBe("◀ Prev"); + expect(paginationRow?.[1]?.text).toBe("2/3"); + expect(paginationRow?.[2]?.text).toBe("Next ▶"); + }); + + it("shows only prev on last page", () => { + // 6 models with pageSize 2 = 3 pages + const result = buildModelsKeyboard({ + provider: "anthropic", + models: ["model1", "model2", "model3", "model4", "model5", "model6"], + currentPage: 3, + totalPages: 3, + pageSize: 2, + }); + // 2 model rows + pagination row + back button + expect(result).toHaveLength(4); + const paginationRow = result[2]; + expect(paginationRow).toHaveLength(2); + expect(paginationRow?.[0]?.text).toBe("◀ Prev"); + expect(paginationRow?.[1]?.text).toBe("3/3"); + }); + + it("truncates long model IDs", () => { + const longModel = "this-is-a-very-long-model-name-that-exceeds-the-limit"; + const result = buildModelsKeyboard({ + provider: "anthropic", + models: [longModel], + currentPage: 1, + totalPages: 1, + }); + const text = result[0]?.[0]?.text; + expect(text?.startsWith("…")).toBe(true); + expect(text?.length).toBeLessThanOrEqual(39); // 38 max + possible checkmark + }); +}); + +describe("buildBrowseProvidersButton", () => { + it("returns browse providers button", () => { + const result = buildBrowseProvidersButton(); + expect(result).toHaveLength(1); + expect(result[0]).toHaveLength(1); + expect(result[0]?.[0]?.text).toBe("Browse providers"); + expect(result[0]?.[0]?.callback_data).toBe("mdl_prov"); + }); +}); + +describe("getModelsPageSize", () => { + it("returns default page size", () => { + expect(getModelsPageSize()).toBe(8); + }); +}); + +describe("calculateTotalPages", () => { + it("calculates pages correctly", () => { + expect(calculateTotalPages(0)).toBe(0); + expect(calculateTotalPages(1)).toBe(1); + expect(calculateTotalPages(8)).toBe(1); + expect(calculateTotalPages(9)).toBe(2); + expect(calculateTotalPages(16)).toBe(2); + expect(calculateTotalPages(17)).toBe(3); + }); + + it("uses custom page size", () => { + expect(calculateTotalPages(10, 5)).toBe(2); + expect(calculateTotalPages(11, 5)).toBe(3); + }); +}); diff --git a/src/telegram/model-buttons.ts b/src/telegram/model-buttons.ts new file mode 100644 index 000000000..84302de91 --- /dev/null +++ b/src/telegram/model-buttons.ts @@ -0,0 +1,210 @@ +/** + * Telegram inline button utilities for model selection. + * + * Callback data patterns (max 64 bytes for Telegram): + * - mdl_prov - show providers list + * - mdl_list_{prov}_{pg} - show models for provider (page N, 1-indexed) + * - mdl_sel_{provider/id} - select model + * - mdl_back - back to providers list + */ + +export type ButtonRow = Array<{ text: string; callback_data: string }>; + +export type ParsedModelCallback = + | { type: "providers" } + | { type: "list"; provider: string; page: number } + | { type: "select"; provider: string; model: string } + | { type: "back" }; + +export type ProviderInfo = { + id: string; + count: number; +}; + +export type ModelsKeyboardParams = { + provider: string; + models: string[]; + currentModel?: string; + currentPage: number; + totalPages: number; + pageSize?: number; +}; + +const MODELS_PAGE_SIZE = 8; + +/** + * Parse a model callback_data string into a structured object. + * Returns null if the data doesn't match a known pattern. + */ +export function parseModelCallbackData(data: string): ParsedModelCallback | null { + const trimmed = data.trim(); + if (!trimmed.startsWith("mdl_")) { + return null; + } + + if (trimmed === "mdl_prov" || trimmed === "mdl_back") { + return { type: trimmed === "mdl_prov" ? "providers" : "back" }; + } + + // mdl_list_{provider}_{page} + const listMatch = trimmed.match(/^mdl_list_([a-z0-9_-]+)_(\d+)$/i); + if (listMatch) { + const [, provider, pageStr] = listMatch; + const page = Number.parseInt(pageStr ?? "1", 10); + if (provider && Number.isFinite(page) && page >= 1) { + return { type: "list", provider, page }; + } + } + + // mdl_sel_{provider/model} + const selMatch = trimmed.match(/^mdl_sel_(.+)$/); + if (selMatch) { + const modelRef = selMatch[1]; + if (modelRef) { + const slashIndex = modelRef.indexOf("/"); + if (slashIndex > 0 && slashIndex < modelRef.length - 1) { + return { + type: "select", + provider: modelRef.slice(0, slashIndex), + model: modelRef.slice(slashIndex + 1), + }; + } + } + } + + return null; +} + +/** + * Build provider selection keyboard with 2 providers per row. + */ +export function buildProviderKeyboard(providers: ProviderInfo[]): ButtonRow[] { + if (providers.length === 0) { + return []; + } + + const rows: ButtonRow[] = []; + let currentRow: ButtonRow = []; + + for (const provider of providers) { + const button = { + text: `${provider.id} (${provider.count})`, + callback_data: `mdl_list_${provider.id}_1`, + }; + + currentRow.push(button); + + if (currentRow.length === 2) { + rows.push(currentRow); + currentRow = []; + } + } + + // Push any remaining button + if (currentRow.length > 0) { + rows.push(currentRow); + } + + return rows; +} + +/** + * Build model list keyboard with pagination and back button. + */ +export function buildModelsKeyboard(params: ModelsKeyboardParams): ButtonRow[] { + const { provider, models, currentModel, currentPage, totalPages } = params; + const pageSize = params.pageSize ?? MODELS_PAGE_SIZE; + + if (models.length === 0) { + return [[{ text: "<< Back", callback_data: "mdl_back" }]]; + } + + const rows: ButtonRow[] = []; + + // Calculate page slice + const startIndex = (currentPage - 1) * pageSize; + const endIndex = Math.min(startIndex + pageSize, models.length); + const pageModels = models.slice(startIndex, endIndex); + + // Model buttons - one per row + const currentModelId = currentModel?.includes("/") + ? currentModel.split("/").slice(1).join("/") + : currentModel; + + for (const model of pageModels) { + const isCurrentModel = model === currentModelId; + const displayText = truncateModelId(model, 38); + const text = isCurrentModel ? `${displayText} ✓` : displayText; + + rows.push([ + { + text, + callback_data: `mdl_sel_${provider}/${model}`, + }, + ]); + } + + // Pagination row + if (totalPages > 1) { + const paginationRow: ButtonRow = []; + + if (currentPage > 1) { + paginationRow.push({ + text: "◀ Prev", + callback_data: `mdl_list_${provider}_${currentPage - 1}`, + }); + } + + paginationRow.push({ + text: `${currentPage}/${totalPages}`, + callback_data: `mdl_list_${provider}_${currentPage}`, // noop + }); + + if (currentPage < totalPages) { + paginationRow.push({ + text: "Next ▶", + callback_data: `mdl_list_${provider}_${currentPage + 1}`, + }); + } + + rows.push(paginationRow); + } + + // Back button + rows.push([{ text: "<< Back", callback_data: "mdl_back" }]); + + return rows; +} + +/** + * Build "Browse providers" button for /model summary. + */ +export function buildBrowseProvidersButton(): ButtonRow[] { + return [[{ text: "Browse providers", callback_data: "mdl_prov" }]]; +} + +/** + * Truncate model ID for display, preserving end if too long. + */ +function truncateModelId(modelId: string, maxLen: number): string { + if (modelId.length <= maxLen) { + return modelId; + } + // Show last part with ellipsis prefix + return `…${modelId.slice(-(maxLen - 1))}`; +} + +/** + * Get page size for model list pagination. + */ +export function getModelsPageSize(): number { + return MODELS_PAGE_SIZE; +} + +/** + * Calculate total pages for a model list. + */ +export function calculateTotalPages(totalModels: number, pageSize?: number): number { + const size = pageSize ?? MODELS_PAGE_SIZE; + return size > 0 ? Math.ceil(totalModels / size) : 1; +}