From 3034adfdb34ac3fee9bad6d73afd65838fc9154e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 30 Mar 2026 09:32:45 +0900 Subject: [PATCH] fix(commands): harden fast status and Telegram callbacks --- CHANGELOG.md | 2 + docs/concepts/model-providers.md | 2 +- docs/tools/slash-commands.md | 2 +- .../telegram/src/bot-handlers.runtime.ts | 9 ++++- .../src/bot-message-context.session.ts | 1 + .../telegram/src/bot-message-context.types.ts | 1 + .../telegram/src/bot-native-commands.test.ts | 29 ++++++++++++++- .../telegram/src/bot-native-commands.ts | 21 ++++++++++- .../src/bot.create-telegram-bot.test.ts | 37 +++++++++++++++++++ ...rrent-verbose-level-verbose-has-no.test.ts | 19 +++++++++- .../reply/directive-handling.impl.ts | 6 +-- src/auto-reply/status.test.ts | 2 +- src/auto-reply/status.ts | 2 +- 13 files changed, 121 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c88f833c506..a4412aaaa21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,8 @@ Docs: https://docs.openclaw.ai - Plugins/CLI: collect root-help plugin descriptors through a dedicated non-activating CLI metadata path so enabled plugins keep validated config semantics without triggering runtime-only plugin registration work, while preserving runtime CLI command registration for legacy channel plugins that still wire commands from full registration. (#57294) thanks @gumadeiras. - Anthropic/OAuth: inject `/fast` `service_tier` hints for direct `sk-ant-oat-*` requests so OAuth-authenticated Anthropic runs stop missing the same overload-routing signal as API-key traffic. Fixes #55758. Thanks @Cypherm and @vincentkoc. - Anthropic/service tiers: support explicit `serviceTier` model params for direct Anthropic requests and let them override `/fast` defaults when both are set. (#45453) Thanks @vincentkoc. +- Auto-reply/fast: accept `/fast status` on the directive-only path, align help/status text with the documented `status|on|off` syntax, and keep current-state replies consistent across command surfaces. Fixes #46095. Thanks @weissfl and @vincentkoc. +- Telegram/native commands: prefix native command menu callback payloads and preserve `CommandSource: "native"` when Telegram replays them through callback queries, so `/fast` and other native command menus keep working even when text-command routing is disabled. Thanks @vincentkoc. - Docs/anchors: fix broken English docs links and make Mint anchor audits run against the English-source docs tree. (#57039) thanks @velvet-shark. - Cron/announce: preserve all deliverable text payloads for announce mode instead of collapsing to the last chunk, so multi-line cron reports deliver in full to Telegram forum topics. diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index 430762c4eac..94de525b84f 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -164,7 +164,7 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** - Optional rotation: `ANTHROPIC_API_KEYS`, `ANTHROPIC_API_KEY_1`, `ANTHROPIC_API_KEY_2`, plus `OPENCLAW_LIVE_ANTHROPIC_KEY` (single override) - Example model: `anthropic/claude-opus-4-6` - CLI: `openclaw onboard --auth-choice token` (paste setup-token) or `openclaw models auth paste-token --provider anthropic` -- Direct API-key models support the shared `/fast` toggle and `params.fastMode`; OpenClaw maps that to Anthropic `service_tier` (`auto` vs `standard_only`) +- Direct public Anthropic requests support the shared `/fast` toggle and `params.fastMode`, including API-key and OAuth-authenticated traffic sent to `api.anthropic.com`; OpenClaw maps that to Anthropic `service_tier` (`auto` vs `standard_only`) - Policy note: setup-token support is technical compatibility; Anthropic has blocked some subscription usage outside Claude Code in the past. Verify current Anthropic terms and decide based on your risk tolerance. - Recommendation: Anthropic API key auth is the safer, recommended path over subscription setup-token auth. diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 429f476d662..c681adad59e 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -143,7 +143,7 @@ Notes: - ACP command reference and runtime behavior: [ACP Agents](/tools/acp-agents). - `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use. - `/fast on|off` persists a session override. Use the Sessions UI `inherit` option to clear it and fall back to config defaults. -- `/fast` is provider-specific: OpenAI/OpenAI Codex map it to `service_tier=priority` on native Responses endpoints, while direct Anthropic API-key requests map it to `service_tier=auto` or `standard_only`. See [OpenAI](/providers/openai) and [Anthropic](/providers/anthropic). +- `/fast` is provider-specific: OpenAI/OpenAI Codex map it to `service_tier=priority` on native Responses endpoints, while direct public Anthropic requests, including OAuth-authenticated traffic sent to `api.anthropic.com`, map it to `service_tier=auto` or `standard_only`. See [OpenAI](/providers/openai) and [Anthropic](/providers/anthropic). - Tool failure summaries are still shown when relevant, but detailed failure text is only included when `/verbose` is `on` or `full`. - `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats. - **Fast path:** command-only messages from allowlisted senders are handled immediately (bypass queue + model). diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts index d51e05ed8e7..6564e37fe0e 100644 --- a/extensions/telegram/src/bot-handlers.runtime.ts +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -51,7 +51,10 @@ import { resolveInboundMediaFileId, } from "./bot-handlers.media.js"; import type { TelegramMediaRef } from "./bot-message-context.js"; -import { RegisterTelegramHandlerParams } from "./bot-native-commands.js"; +import { + parseTelegramNativeCommandCallbackData, + RegisterTelegramHandlerParams, +} from "./bot-native-commands.js"; import { MEDIA_GROUP_TIMEOUT_MS, type MediaGroupEntry, @@ -1548,12 +1551,14 @@ export const registerTelegramHandlers = ({ return; } + const nativeCommandText = parseTelegramNativeCommandCallbackData(data); const syntheticMessage = buildSyntheticTextMessage({ base: withResolvedTelegramForumFlag(callbackMessage, isForum), from: callback.from, - text: data, + text: nativeCommandText ?? data, }); await processMessage(buildSyntheticContext(ctx, syntheticMessage), [], storeAllowFrom, { + commandSource: nativeCommandText ? "native" : undefined, forceWasMentioned: true, messageIdOverride: callback.id, }); diff --git a/extensions/telegram/src/bot-message-context.session.ts b/extensions/telegram/src/bot-message-context.session.ts index 7782b4fe0cb..12f37e7a74e 100644 --- a/extensions/telegram/src/bot-message-context.session.ts +++ b/extensions/telegram/src/bot-message-context.session.ts @@ -244,6 +244,7 @@ export async function buildTelegramInboundContextPayload(params: { StickerMediaIncluded: allMedia[0]?.stickerMetadata ? !stickerCacheHit : undefined, ...(locationData ? toLocationContext(locationData) : undefined), CommandAuthorized: commandAuthorized, + CommandSource: options?.commandSource, MessageThreadId: threadSpec.id, IsForum: isForum, OriginatingChannel: "telegram" as const, diff --git a/extensions/telegram/src/bot-message-context.types.ts b/extensions/telegram/src/bot-message-context.types.ts index 88fb9e27480..0d11107546e 100644 --- a/extensions/telegram/src/bot-message-context.types.ts +++ b/extensions/telegram/src/bot-message-context.types.ts @@ -16,6 +16,7 @@ export type TelegramMediaRef = { }; export type TelegramMessageContextOptions = { + commandSource?: "text" | "native"; forceWasMentioned?: boolean; messageIdOverride?: string; receivedAtMs?: number; diff --git a/extensions/telegram/src/bot-native-commands.test.ts b/extensions/telegram/src/bot-native-commands.test.ts index 2b3af148ca2..3710a5699a3 100644 --- a/extensions/telegram/src/bot-native-commands.test.ts +++ b/extensions/telegram/src/bot-native-commands.test.ts @@ -7,7 +7,9 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { pluginCommandMocks, resetPluginCommandMocks } from "./test-support/plugin-command.js"; let registerTelegramNativeCommands: typeof import("./bot-native-commands.js").registerTelegramNativeCommands; +let parseTelegramNativeCommandCallbackData: typeof import("./bot-native-commands.js").parseTelegramNativeCommandCallbackData; import { + createCommandBot, createNativeCommandTestParams, createPrivateCommandContext, deliverReplies, @@ -19,7 +21,8 @@ import { describe("registerTelegramNativeCommands", () => { beforeAll(async () => { vi.resetModules(); - ({ registerTelegramNativeCommands } = await import("./bot-native-commands.js")); + ({ registerTelegramNativeCommands, parseTelegramNativeCommandCallbackData } = + await import("./bot-native-commands.js")); }); beforeEach(() => { @@ -161,6 +164,30 @@ describe("registerTelegramNativeCommands", () => { expect(registeredCommands.some((entry) => entry.command === "custom-bad")).toBe(false); }); + it("prefixes native command menu callback data so callback handlers can preserve native routing", async () => { + const { bot, commandHandlers, sendMessage } = createCommandBot(); + + registerTelegramNativeCommands({ + ...createNativeCommandTestParams({}, { bot }), + }); + + const handler = commandHandlers.get("fast"); + expect(handler).toBeTruthy(); + await handler?.(createPrivateCommandContext()); + + const replyMarkup = sendMessage.mock.calls[0]?.[2]?.reply_markup as + | { inline_keyboard?: Array> } + | undefined; + const callbackData = replyMarkup?.inline_keyboard + ?.flat() + .map((button) => button.callback_data) + .filter(Boolean); + + expect(callbackData).toEqual(["tgcmd:/fast status", "tgcmd:/fast on", "tgcmd:/fast off"]); + expect(parseTelegramNativeCommandCallbackData("tgcmd:/fast status")).toBe("/fast status"); + expect(parseTelegramNativeCommandCallbackData("tgcmd:fast status")).toBeNull(); + }); + it("passes agent-scoped media roots for plugin command replies with media", async () => { const commandHandlers = new Map Promise>(); const sendMessage = vi.fn().mockResolvedValue(undefined); diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index ce3f3e2698c..b69754ee0be 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -86,6 +86,7 @@ import { resolveTelegramGroupPromptSettings } from "./group-config-helpers.js"; import { buildInlineKeyboard } from "./send.js"; const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again."; +const TELEGRAM_NATIVE_COMMAND_CALLBACK_PREFIX = "tgcmd:"; type TelegramNativeCommandContext = Context & { match?: string }; @@ -129,6 +130,22 @@ export type RegisterTelegramHandlerParams = { logger: ReturnType; }; +export function buildTelegramNativeCommandCallbackData(commandText: string): string { + return `${TELEGRAM_NATIVE_COMMAND_CALLBACK_PREFIX}${commandText}`; +} + +export function parseTelegramNativeCommandCallbackData(data?: string | null): string | null { + if (!data) { + return null; + } + const trimmed = data.trim(); + if (!trimmed.startsWith(TELEGRAM_NATIVE_COMMAND_CALLBACK_PREFIX)) { + return null; + } + const commandText = trimmed.slice(TELEGRAM_NATIVE_COMMAND_CALLBACK_PREFIX.length).trim(); + return commandText.startsWith("/") ? commandText : null; +} + export type RegisterTelegramNativeCommandsParams = { bot: Bot; cfg: OpenClawConfig; @@ -679,7 +696,9 @@ export const registerTelegramNativeCommands = ({ }; return { text: choice.label, - callback_data: buildCommandTextFromArgs(commandDefinition, args), + callback_data: buildTelegramNativeCommandCallbackData( + buildCommandTextFromArgs(commandDefinition, args), + ), }; }), ); diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index 5fbdc50a9be..8ce9ea03248 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -345,6 +345,43 @@ describe("createTelegramBot", () => { expect(payload.Body).toContain("cmd:option_a"); expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-1"); }); + it("preserves native command source for prefixed callback_query payloads", async () => { + loadConfig.mockReturnValue({ + commands: { text: false, native: true }, + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const callbackHandler = getOnHandler("callback_query") as ( + ctx: Record, + ) => Promise; + + await callbackHandler({ + callbackQuery: { + id: "cbq-native-1", + data: "tgcmd:/fast status", + from: { id: 9, first_name: "Ada", username: "ada_bot" }, + message: { + chat: { id: 1234, type: "private" }, + date: 1736380800, + message_id: 10, + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.CommandBody).toBe("/fast status"); + expect(payload.CommandSource).toBe("native"); + expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-native-1"); + }); it("reloads callback model routing bindings without recreating the bot", async () => { const buildModelsProviderDataMock = telegramBotDepsForTest.buildModelsProviderData as unknown as ReturnType; diff --git a/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts b/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts index df0c31a5673..1633321dc55 100644 --- a/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts @@ -142,7 +142,7 @@ describe("directive behavior", () => { }, }); expect(fastText).toContain("Current fast mode: on (config)"); - expect(fastText).toContain("Options: on, off."); + expect(fastText).toContain("Options: status, on, off."); const verboseText = await runCommand(home, "/verbose", { defaults: { verboseDefault: "on" }, @@ -200,6 +200,23 @@ describe("directive behavior", () => { expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); }); }); + it("treats /fast status like the no-argument status query", async () => { + await withTempHome(async (home) => { + const statusText = await runCommand(home, "/fast status", { + defaults: { + models: { + "anthropic/claude-opus-4-5": { + params: { fastMode: true }, + }, + }, + }, + }); + + expect(statusText).toContain("Current fast mode: on (config)"); + expect(statusText).toContain("Options: status, on, off."); + expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + }); + }); it("persists elevated toggles across /status and /elevated", async () => { await withTempHome(async (home) => { const storePath = sessionStorePath(home); diff --git a/src/auto-reply/reply/directive-handling.impl.ts b/src/auto-reply/reply/directive-handling.impl.ts index a54794b09fb..fc3fa0c7f43 100644 --- a/src/auto-reply/reply/directive-handling.impl.ts +++ b/src/auto-reply/reply/directive-handling.impl.ts @@ -200,7 +200,7 @@ export async function handleDirectiveOnly( }; } if (directives.hasFastDirective && directives.fastMode === undefined) { - if (!directives.rawFastMode) { + if (!directives.rawFastMode || directives.rawFastMode.toLowerCase() === "status") { const sourceSuffix = effectiveFastModeSource === "config" ? " (config)" @@ -210,12 +210,12 @@ export async function handleDirectiveOnly( return { text: withOptions( `Current fast mode: ${effectiveFastMode ? "on" : "off"}${sourceSuffix}.`, - "on, off", + "status, on, off", ), }; } return { - text: `Unrecognized fast mode "${directives.rawFastMode}". Valid levels: on, off.`, + text: `Unrecognized fast mode "${directives.rawFastMode}". Valid levels: status, on, off.`, }; } if (directives.hasReasoningDirective && !directives.reasoningLevel) { diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 4233cc8da19..bc36fc1b8a4 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -1309,7 +1309,7 @@ describe("buildHelpMessage", () => { }); it("includes /fast in help output", () => { - expect(buildHelpMessage()).toContain("/fast on|off"); + expect(buildHelpMessage()).toContain("/fast status|on|off"); }); }); diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 564b69e33a4..d611a45dfd9 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -842,7 +842,7 @@ export function buildHelpMessage(cfg?: OpenClawConfig): string { lines.push(" /new | /reset | /compact [instructions] | /stop"); lines.push(""); - const optionParts = ["/think ", "/model ", "/fast on|off", "/verbose on|off"]; + const optionParts = ["/think ", "/model ", "/fast status|on|off", "/verbose on|off"]; if (isCommandFlagEnabled(cfg, "config")) { optionParts.push("/config"); }