fix(commands): harden fast status and Telegram callbacks

This commit is contained in:
Vincent Koc 2026-03-30 09:32:45 +09:00
parent 8657b65b05
commit 3034adfdb3
13 changed files with 121 additions and 12 deletions

View File

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

View File

@ -164,7 +164,7 @@ OpenClaw ships with the piai 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.

View File

@ -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).

View File

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

View File

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

View File

@ -16,6 +16,7 @@ export type TelegramMediaRef = {
};
export type TelegramMessageContextOptions = {
commandSource?: "text" | "native";
forceWasMentioned?: boolean;
messageIdOverride?: string;
receivedAtMs?: number;

View File

@ -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<Array<{ callback_data?: string }>> }
| 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<string, (ctx: unknown) => Promise<void>>();
const sendMessage = vi.fn().mockResolvedValue(undefined);

View File

@ -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<typeof getChildLogger>;
};
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),
),
};
}),
);

View File

@ -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<string, unknown>,
) => Promise<void>;
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<typeof vi.fn>;

View File

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

View File

@ -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) {

View File

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

View File

@ -842,7 +842,7 @@ export function buildHelpMessage(cfg?: OpenClawConfig): string {
lines.push(" /new | /reset | /compact [instructions] | /stop");
lines.push("");
const optionParts = ["/think <level>", "/model <id>", "/fast on|off", "/verbose on|off"];
const optionParts = ["/think <level>", "/model <id>", "/fast status|on|off", "/verbose on|off"];
if (isCommandFlagEnabled(cfg, "config")) {
optionParts.push("/config");
}