fix(telegram): enable HTML formatting for model switch messages (#60042)

* fix(telegram): enable HTML formatting for model switch messages

The model switch confirmation message was displaying raw Markdown
(**text**) instead of bold formatting because parse_mode was not set.

Changes:
- Add optional extra parameter to editMessageWithButtons for parse_mode
- Change format from Markdown ** to HTML <b> tags
- Pass parse_mode: 'HTML' when editing model switch message

Fixes the issue where model names appeared as **provider/model**
instead of bold text in Telegram.

* fix(telegram): escape HTML entities in model switch confirmation

Add defensive `escapeHtml` helper to sanitize `selection.provider`
and `selection.model` before interpolating them into the HTML
callback message. This prevents potential API rejection (HTTP 400)
if future provider or model names contain `<`, `>`, or `&`.

Addresses review feedback on unescaped HTML interpolation.

* test(telegram): cover HTML model switch confirmation

---------

Co-authored-by: Frank Yang <frank.ekn@gmail.com>
This commit is contained in:
chi 2026-04-04 00:05:09 +08:00 committed by GitHub
parent 7c738ad036
commit 33e6a6724d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 91 additions and 3 deletions

View File

@ -57,6 +57,12 @@ Docs: https://docs.openclaw.ai
- Gateway/connect: omit admin-scoped config and auth metadata from lower-privilege `hello-ok` snapshots while preserving those fields for admin reconnects. (#58469) Thanks @eleqtrizit.
- iOS/canvas: restrict A2UI bridge trust to the bundled scaffold and exact capability-backed remote canvas URLs, so generic `canvas.navigate` and `canvas.present` loads no longer gain action-dispatch authority. (#58471) Thanks @eleqtrizit.
- Agents/tool policy: preserve restrictive plugin-only allowlists instead of silently widening access to core tools, and keep allowlist warnings aligned with the enforced policy. (#58476) Thanks @eleqtrizit.
- Telegram/native commands: clean up metadata-driven progress placeholders when replies fall back, edits fail, or local exec approval prompts are suppressed. (#59300) Thanks @jalehman.
- Matrix: allow secret-storage recreation during automatic repair bootstrap so clients that lose their recovery key can recover and persist new cross-signing keys. (#59846) Thanks @al3mart.
- Matrix/crypto persistence: capture and write the IndexedDB snapshot while holding the snapshot file lock so concurrent gateway and CLI persists cannot overwrite newer crypto state. (#59851) Thanks @al3mart.
- Telegram/media: keep inbound image attachments readable on upgraded installs where legacy state roots still differ from the managed config-dir media cache. (#59971) Thanks @neeravmakwana.
- Telegram/local Bot API: thread `channels.telegram.apiRoot` through buffered reply-media and album downloads so self-hosted Bot API file paths stop falling back to `api.telegram.org` and 404ing. (#59544) Thanks @SARAMALI15792.
- Telegram/replies: preserve explicit topic targets when `replyTo` is present while still inheriting the current topic for same-chat replies without an explicit topic. (#59634) Thanks @dashhuang.
- Hooks/session_end: preserve deterministic reason metadata for custom reset aliases and overlapping idle-plus-daily rollovers so plugins can rely on lifecycle reason reporting. (#59715) Thanks @jalehman.
- Tools/image generation: stop inferring unsupported resolution overrides for OpenAI reference-image edits when no explicit `size` or `resolution` is provided, so default edit flows no longer fail before the provider request is sent.
- Agents/sessions: release embedded runner session locks even when teardown cleanup throws, so timed-out or failed cleanup paths no longer leave sessions wedged until the stale-lock watchdog recovers them. (#59194) Thanks @samzong.
@ -72,6 +78,7 @@ Docs: https://docs.openclaw.ai
- Allowlist/commands: require owner access for `/allowlist add` and `/allowlist remove` so command-authorized non-owners cannot mutate persisted allowlists. (#59836) Thanks @eleqtrizit.
- Control UI/skills: clear stale ClawHub results immediately when the search query changes, so debounced searches cannot keep outdated install targets visible. Related #60134.
- Discord/ack reactions: keep automatic ACK reaction auth on the active hydrated Discord account so SecretRef-backed and non-default-account reactions stop falling back to stale default config resolution. (#60081) Thanks @FunJim.
- Telegram/model switching: render non-default `/model` callback confirmations with HTML formatting so Telegram shows the selected model in bold instead of raw `**...**` markers. (#60042) Thanks @GitZhangChi.
## 2026.4.2

View File

@ -1445,17 +1445,22 @@ export const registerTelegramHandlers = ({
const editMessageWithButtons = async (
text: string,
buttons: ReturnType<typeof buildProviderKeyboard>,
extra?: { parse_mode?: "HTML" | "Markdown" | "MarkdownV2" },
) => {
const keyboard = buildInlineKeyboard(buttons);
const editParams = keyboard ? { reply_markup: keyboard, ...extra } : extra;
try {
await editCallbackMessage(text, keyboard ? { reply_markup: keyboard } : undefined);
await editCallbackMessage(text, editParams);
} catch (editErr) {
const errStr = String(editErr);
if (errStr.includes("no text in the message")) {
try {
await deleteCallbackMessage();
} catch {}
await replyToCallbackChat(text, keyboard ? { reply_markup: keyboard } : undefined);
await replyToCallbackChat(
text,
keyboard ? { reply_markup: keyboard, ...extra } : extra,
);
} else if (!errStr.includes("message is not modified")) {
throw editErr;
}
@ -1585,12 +1590,15 @@ export const registerTelegramHandlers = ({
});
// Update message to show success with visual feedback
const escapeHtml = (text: string) =>
text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const actionText = isDefaultSelection
? "reset to default"
: `changed to **${selection.provider}/${selection.model}**`;
: `changed to <b>${escapeHtml(selection.provider)}/${escapeHtml(selection.model)}</b>`;
await editMessageWithButtons(
`✅ Model ${actionText}\n\nThis model will be used for your next message.`,
[], // Empty buttons = remove inline keyboard
{ parse_mode: "HTML" },
);
} catch (err) {
await editMessageWithButtons(`❌ Failed to change model: ${String(err)}`, []);

View File

@ -965,6 +965,79 @@ describe("createTelegramBot", () => {
}
});
it("formats non-default model selection confirmations with Telegram HTML parse mode", async () => {
onSpy.mockClear();
replySpy.mockClear();
editMessageTextSpy.mockClear();
const storePath = `/tmp/openclaw-telegram-model-html-${process.pid}-${Date.now()}.json`;
await rm(storePath, { force: true });
try {
const config = {
agents: {
defaults: {
model: "anthropic/claude-opus-4-6",
models: {
"anthropic/claude-opus-4-6": {},
"openai/gpt-5.4": {},
},
},
},
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
},
},
session: {
store: storePath,
},
} satisfies NonNullable<Parameters<typeof createTelegramBot>[0]["config"]>;
loadConfig.mockReturnValue(config);
createTelegramBot({
token: "tok",
config,
});
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-html-1",
data: "mdl_sel_openai/gpt-5.4",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 17,
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).not.toHaveBeenCalled();
expect(editMessageTextSpy).toHaveBeenCalledTimes(1);
expect(editMessageTextSpy).toHaveBeenCalledWith(
1234,
17,
`${CHECK_MARK_EMOJI} Model changed to <b>openai/gpt-5.4</b>\n\nThis model will be used for your next message.`,
expect.objectContaining({ parse_mode: "HTML" }),
);
const entry = Object.values(loadSessionStore(storePath, { skipCache: true }))[0];
expect(entry?.providerOverride).toBe("openai");
expect(entry?.modelOverride).toBe("gpt-5.4");
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-model-html-1");
} finally {
await rm(storePath, { force: true });
}
});
it("rejects ambiguous compact model callbacks and returns provider list", async () => {
onSpy.mockClear();
replySpy.mockClear();