diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts index 17ba2a29ac3..cafd720c977 100644 --- a/src/telegram/bot-handlers.ts +++ b/src/telegram/bot-handlers.ts @@ -1261,11 +1261,29 @@ export const registerTelegramHandlers = ({ if (modelCallback.type === "select") { const { provider, model } = modelCallback; + let resolvedProvider = provider; + if (!resolvedProvider) { + const matchingProviders = providers.filter((id) => byProvider.get(id)?.has(model)); + if (matchingProviders.length === 1) { + resolvedProvider = matchingProviders[0]; + } else { + const providerInfos: ProviderInfo[] = providers.map((p) => ({ + id: p, + count: byProvider.get(p)?.size ?? 0, + })); + const buttons = buildProviderKeyboard(providerInfos); + await editMessageWithButtons( + `Could not resolve model "${model}".\n\nSelect a provider:`, + buttons, + ); + return; + } + } // Process model selection as a synthetic message with /model command const syntheticMessage = buildSyntheticTextMessage({ base: callbackMessage, from: callback.from, - text: `/model ${provider}/${model}`, + text: `/model ${resolvedProvider}/${model}`, }); await processMessage(buildSyntheticContext(ctx, syntheticMessage), [], storeAllowFrom, { forceWasMentioned: true, diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index e667b3a60f4..ff869570e20 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -319,6 +319,107 @@ describe("createTelegramBot", () => { expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-4"); }); + it("routes compact model callbacks by inferring provider", async () => { + onSpy.mockClear(); + replySpy.mockClear(); + + const modelId = "us.anthropic.claude-3-5-sonnet-20240620-v1:0"; + + createTelegramBot({ + token: "tok", + config: { + agents: { + defaults: { + model: `bedrock/${modelId}`, + }, + }, + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + }, + }, + }, + }); + const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( + ctx: Record, + ) => Promise; + expect(callbackHandler).toBeDefined(); + + await callbackHandler({ + callbackQuery: { + id: "cbq-model-compact-1", + data: `mdl_sel/${modelId}`, + from: { id: 9, first_name: "Ada", username: "ada_bot" }, + message: { + chat: { id: 1234, type: "private" }, + date: 1736380800, + message_id: 14, + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0]?.[0]; + expect(payload?.Body).toContain(`/model amazon-bedrock/${modelId}`); + expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-model-compact-1"); + }); + + it("rejects ambiguous compact model callbacks and returns provider list", async () => { + onSpy.mockClear(); + replySpy.mockClear(); + editMessageTextSpy.mockClear(); + + createTelegramBot({ + token: "tok", + config: { + agents: { + defaults: { + model: "anthropic/shared-model", + models: { + "anthropic/shared-model": {}, + "openai/shared-model": {}, + }, + }, + }, + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + }, + }, + }, + }); + const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( + ctx: Record, + ) => Promise; + expect(callbackHandler).toBeDefined(); + + await callbackHandler({ + callbackQuery: { + id: "cbq-model-compact-2", + data: "mdl_sel/shared-model", + from: { id: 9, first_name: "Ada", username: "ada_bot" }, + message: { + chat: { id: 1234, type: "private" }, + date: 1736380800, + message_id: 15, + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + expect(editMessageTextSpy).toHaveBeenCalledTimes(1); + expect(editMessageTextSpy.mock.calls[0]?.[2]).toContain( + 'Could not resolve model "shared-model".', + ); + expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-model-compact-2"); + }); + it("includes sender identity in group envelope headers", async () => { onSpy.mockClear(); replySpy.mockClear(); diff --git a/src/telegram/model-buttons.test.ts b/src/telegram/model-buttons.test.ts index ac3ef5d5188..3650ed917d7 100644 --- a/src/telegram/model-buttons.test.ts +++ b/src/telegram/model-buttons.test.ts @@ -21,6 +21,14 @@ describe("parseModelCallbackData", () => { { type: "select", provider: "anthropic", model: "claude-sonnet-4-5" }, ], ["mdl_sel_openai/gpt-4/turbo", { type: "select", provider: "openai", model: "gpt-4/turbo" }], + [ + "mdl_sel/us.anthropic.claude-3-5-sonnet-20240620-v1:0", + { type: "select", model: "us.anthropic.claude-3-5-sonnet-20240620-v1:0" }, + ], + [ + "mdl_sel/anthropic/claude-3-7-sonnet", + { type: "select", model: "anthropic/claude-3-7-sonnet" }, + ], [" mdl_prov ", { type: "providers" }], ] as const; for (const [input, expected] of cases) { @@ -36,6 +44,7 @@ describe("parseModelCallbackData", () => { "mdl_invalid", "mdl_list_", "mdl_sel_noslash", + "mdl_sel/", ]; for (const input of invalid) { expect(parseModelCallbackData(input), input).toBeNull(); @@ -209,6 +218,18 @@ describe("buildModelsKeyboard", () => { } } }); + + it("uses compact selection callback when provider/model callback exceeds 64 bytes", () => { + const model = "us.anthropic.claude-3-5-sonnet-20240620-v1:0"; + const result = buildModelsKeyboard({ + provider: "amazon-bedrock", + models: [model], + currentPage: 1, + totalPages: 1, + }); + + expect(result[0]?.[0]?.callback_data).toBe(`mdl_sel/${model}`); + }); }); describe("buildBrowseProvidersButton", () => { diff --git a/src/telegram/model-buttons.ts b/src/telegram/model-buttons.ts index 86e54a07524..86e54b72827 100644 --- a/src/telegram/model-buttons.ts +++ b/src/telegram/model-buttons.ts @@ -4,7 +4,8 @@ * 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_sel_{provider/id} - select model (standard) + * - mdl_sel/{model} - select model (compact fallback when standard is >64 bytes) * - mdl_back - back to providers list */ @@ -13,7 +14,7 @@ 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: "select"; provider?: string; model: string } | { type: "back" }; export type ProviderInfo = { @@ -57,6 +58,18 @@ export function parseModelCallbackData(data: string): ParsedModelCallback | null } } + // mdl_sel/{model} (compact fallback) + const compactSelMatch = trimmed.match(/^mdl_sel\/(.+)$/); + if (compactSelMatch) { + const modelRef = compactSelMatch[1]; + if (modelRef) { + return { + type: "select", + model: modelRef, + }; + } + } + // mdl_sel_{provider/model} const selMatch = trimmed.match(/^mdl_sel_(.+)$/); if (selMatch) { @@ -133,8 +146,12 @@ export function buildModelsKeyboard(params: ModelsKeyboardParams): ButtonRow[] { : currentModel; for (const model of pageModels) { - const callbackData = `mdl_sel_${provider}/${model}`; - // Skip models that would exceed Telegram's callback_data limit + const fullCallbackData = `mdl_sel_${provider}/${model}`; + const callbackData = + Buffer.byteLength(fullCallbackData, "utf8") <= MAX_CALLBACK_DATA_BYTES + ? fullCallbackData + : `mdl_sel/${model}`; + // Skip models that still exceed Telegram's callback_data limit if (Buffer.byteLength(callbackData, "utf8") > MAX_CALLBACK_DATA_BYTES) { continue; }