Telegram: support compact model callback fallback

This commit is contained in:
bmendonca3 2026-03-02 08:49:51 -07:00 committed by Peter Steinberger
parent c582a54554
commit 54eb13893f
4 changed files with 162 additions and 5 deletions

View File

@ -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,

View File

@ -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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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();

View File

@ -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", () => {

View File

@ -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;
}