fix(telegram): register commands for group scope + preserve topic thread params

This commit is contained in:
dae-sun
2026-02-02 02:35:55 +09:00
committed by Peter Steinberger
parent d3e5292551
commit a147540b5f
6 changed files with 199 additions and 50 deletions

View File

@@ -75,6 +75,7 @@ Docs: https://docs.openclaw.ai
- CLI backends/Claude: make live-session JSONL turn caps bounded and configurable via `reliability.outputLimits`, raising the default guard for tool-heavy Claude CLI turns while preserving memory limits. Fixes #75838. Thanks @hcordoba840.
- Telegram/DMs: keep incidental `message_thread_id` reply-with-quote metadata on the flat DM session by default while preserving opt-in DM topic isolation for configured topics, `dm.threadReplies`, and `direct.<chatId>.threadReplies`. Fixes #75975. Thanks @ProjectEvolutionEVE.
- Telegram/network: raise outbound text and typing Bot API request guards to 60 seconds, keep low grammY client timeouts from preempting those guards, let higher `timeoutSeconds` configs extend safe method guards, and retry timed-out typing indicators through the transport fallback without risking duplicate messages. Fixes #76013. Thanks @iaki1206.
- Telegram/native commands: register and clear command menus in both default and group-chat scopes, so `/status` and plugin commands stay available in forum topics. Fixes #74032; updates #6457. Thanks @dae-sun and @WouldenShyp.
- Providers/OpenAI: resolve `keychain:<service>:<account>` `OPENAI_API_KEY` refs before creating OpenAI Realtime browser sessions or voice bridges, with a bounded cached Keychain lookup. Fixes #72120. Thanks @ctbritt.
- Discord/gateway: reconnect when the gateway socket closes while waiting for the shared IDENTIFY concurrency window, instead of silently skipping IDENTIFY and leaving the bot online but unresponsive. Fixes #74617. Thanks @zeeskdr-ai.
- Voice Call: add `sessionScope: "per-call"` for fresh per-call agent memory while preserving the default per-phone caller history. Fixes #45280. Thanks @pondcountry.

View File

@@ -152,12 +152,14 @@ describe("bot-native-command-menu", () => {
it("deletes stale commands before setting new menu", async () => {
const callOrder: string[] = [];
const deleteMyCommands = vi.fn(async () => {
callOrder.push("delete");
});
const setMyCommands = vi.fn(async () => {
callOrder.push("set");
const deleteMyCommands = vi.fn(async (options?: { scope?: { type?: string } }) => {
callOrder.push(options?.scope?.type ? `delete:${options.scope.type}` : "delete:default");
});
const setMyCommands = vi.fn(
async (_commands: unknown, options?: { scope?: { type?: string } }) => {
callOrder.push(options?.scope?.type ? `set:${options.scope.type}` : "set:default");
},
);
syncMenuCommandsWithMocks({
deleteMyCommands,
@@ -171,7 +173,35 @@ describe("bot-native-command-menu", () => {
expect(setMyCommands).toHaveBeenCalled();
});
expect(callOrder).toEqual(["delete", "set"]);
expect(callOrder).toEqual([
"delete:default",
"delete:all_group_chats",
"set:default",
"set:all_group_chats",
]);
});
it("registers the menu in default and group chat scopes", async () => {
const deleteMyCommands = vi.fn(async () => undefined);
const setMyCommands = vi.fn(async () => undefined);
const commands = [{ command: "cmd", description: "Command" }];
syncMenuCommandsWithMocks({
deleteMyCommands,
setMyCommands,
commandsToRegister: commands,
accountId: `test-scopes-${Date.now()}`,
botIdentity: "bot-a",
});
await vi.waitFor(() => {
expect(setMyCommands).toHaveBeenCalledTimes(2);
});
expect(setMyCommands).toHaveBeenCalledWith(commands);
expect(setMyCommands).toHaveBeenCalledWith(commands, {
scope: { type: "all_group_chats" },
});
});
it("produces a stable hash regardless of command order (#32017)", () => {
@@ -209,7 +239,7 @@ describe("bot-native-command-menu", () => {
});
await vi.waitFor(() => {
expect(setMyCommands).toHaveBeenCalledTimes(1);
expect(setMyCommands).toHaveBeenCalledTimes(2);
});
// Second sync with the same commands — hash is cached, should skip.
@@ -222,8 +252,8 @@ describe("bot-native-command-menu", () => {
botIdentity: "bot-a",
});
// setMyCommands should NOT have been called a second time.
expect(setMyCommands).toHaveBeenCalledTimes(1);
// setMyCommands should NOT have been called again for either scope.
expect(setMyCommands).toHaveBeenCalledTimes(2);
});
it("does not reuse cached hash across different bot identities", async () => {
@@ -241,7 +271,7 @@ describe("bot-native-command-menu", () => {
accountId,
botIdentity: "token-bot-a",
});
await vi.waitFor(() => expect(setMyCommands).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(setMyCommands).toHaveBeenCalledTimes(2));
syncMenuCommandsWithMocks({
deleteMyCommands,
@@ -251,7 +281,7 @@ describe("bot-native-command-menu", () => {
accountId,
botIdentity: "token-bot-b",
});
await vi.waitFor(() => expect(setMyCommands).toHaveBeenCalledTimes(2));
await vi.waitFor(() => expect(setMyCommands).toHaveBeenCalledTimes(4));
});
it("does not cache empty-menu hash when deleteMyCommands fails", async () => {
@@ -271,7 +301,7 @@ describe("bot-native-command-menu", () => {
accountId,
botIdentity: "bot-a",
});
await vi.waitFor(() => expect(deleteMyCommands).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(deleteMyCommands).toHaveBeenCalledTimes(2));
syncMenuCommandsWithMocks({
deleteMyCommands,
@@ -281,7 +311,7 @@ describe("bot-native-command-menu", () => {
accountId,
botIdentity: "bot-a",
});
await vi.waitFor(() => expect(deleteMyCommands).toHaveBeenCalledTimes(2));
await vi.waitFor(() => expect(deleteMyCommands).toHaveBeenCalledTimes(4));
});
it("retries with fewer commands on BOT_COMMANDS_TOO_MUCH", async () => {
@@ -307,12 +337,15 @@ describe("bot-native-command-menu", () => {
});
await vi.waitFor(() => {
expect(setMyCommands).toHaveBeenCalledTimes(2);
expect(setMyCommands).toHaveBeenCalledTimes(3);
});
const firstPayload = setMyCommands.mock.calls[0]?.[0] as Array<unknown>;
const secondPayload = setMyCommands.mock.calls[1]?.[0] as Array<unknown>;
const thirdPayload = setMyCommands.mock.calls[2]?.[0] as Array<unknown>;
expect(firstPayload).toHaveLength(100);
expect(secondPayload).toHaveLength(80);
expect(thirdPayload).toHaveLength(80);
expect(setMyCommands.mock.calls[2]?.[1]).toEqual({ scope: { type: "all_group_chats" } });
expect(runtimeLog).toHaveBeenCalledWith(
"Telegram rejected 100 commands (BOT_COMMANDS_TOO_MUCH); retrying with 80.",
);
@@ -343,7 +376,7 @@ describe("bot-native-command-menu", () => {
});
await vi.waitFor(() => {
expect(setMyCommands).toHaveBeenCalledTimes(2);
expect(setMyCommands).toHaveBeenCalledTimes(3);
});
expect(runtimeLog).toHaveBeenCalledWith(
"Telegram rejected 10 commands (BOT_COMMANDS_TOO_MUCH); retrying with 8.",

View File

@@ -16,11 +16,20 @@ type TelegramMenuCommand = {
description: string;
};
type TelegramCommandMenuScope =
| { label: "default"; options?: undefined }
| { label: "all_group_chats"; options: { scope: { type: "all_group_chats" } } };
type TelegramPluginCommandSpec = {
name: unknown;
description: unknown;
};
const TELEGRAM_COMMAND_MENU_SCOPES: readonly TelegramCommandMenuScope[] = [
{ label: "default" },
{ label: "all_group_chats", options: { scope: { type: "all_group_chats" } } },
];
function countTelegramCommandText(value: string): number {
return Array.from(value).length;
}
@@ -232,6 +241,57 @@ function writeCachedCommandHash(
syncedCommandHashes.set(key, hash);
}
function formatTelegramCommandScopeOperation(
operation: "deleteMyCommands" | "setMyCommands",
scope: TelegramCommandMenuScope,
): string {
return scope.label === "default" ? operation : `${operation}(${scope.label})`;
}
async function deleteTelegramMenuCommandsForScopes(params: {
bot: Bot;
runtime: RuntimeEnv;
}): Promise<boolean> {
const { bot, runtime } = params;
if (typeof bot.api.deleteMyCommands !== "function") {
return true;
}
let allDeleted = true;
for (const scope of TELEGRAM_COMMAND_MENU_SCOPES) {
const deleted = await withTelegramApiErrorLogging({
operation: formatTelegramCommandScopeOperation("deleteMyCommands", scope),
runtime,
fn: () =>
scope.options ? bot.api.deleteMyCommands(scope.options) : bot.api.deleteMyCommands(),
})
.then(() => true)
.catch(() => false);
allDeleted &&= deleted;
}
return allDeleted;
}
async function setTelegramMenuCommandsForScopes(params: {
bot: Bot;
runtime: RuntimeEnv;
commands: TelegramMenuCommand[];
shouldLog?: (err: unknown) => boolean;
}): Promise<void> {
const { bot, runtime, commands, shouldLog } = params;
for (const scope of TELEGRAM_COMMAND_MENU_SCOPES) {
await withTelegramApiErrorLogging({
operation: formatTelegramCommandScopeOperation("setMyCommands", scope),
runtime,
shouldLog,
fn: () =>
scope.options
? bot.api.setMyCommands(commands, scope.options)
: bot.api.setMyCommands(commands),
});
}
}
export function syncTelegramMenuCommands(params: {
bot: Bot;
runtime: RuntimeEnv;
@@ -253,22 +313,16 @@ export function syncTelegramMenuCommands(params: {
}
// Keep delete -> set ordering to avoid stale deletions racing after fresh registrations.
let deleteSucceeded = true;
if (typeof bot.api.deleteMyCommands === "function") {
deleteSucceeded = await withTelegramApiErrorLogging({
operation: "deleteMyCommands",
runtime,
fn: () => bot.api.deleteMyCommands(),
})
.then(() => true)
.catch(() => false);
}
const deleteSucceeded = await deleteTelegramMenuCommandsForScopes({ bot, runtime });
if (commandsToRegister.length === 0) {
if (!deleteSucceeded) {
runtime.log?.("telegram: deleteMyCommands failed; skipping empty-menu hash cache write");
return;
}
if (typeof bot.api.deleteMyCommands !== "function") {
await setTelegramMenuCommandsForScopes({ bot, runtime, commands: [] });
}
writeCachedCommandHash(accountId, botIdentity, currentHash);
return;
}
@@ -277,11 +331,11 @@ export function syncTelegramMenuCommands(params: {
const initialCommandCount = commandsToRegister.length;
while (retryCommands.length > 0) {
try {
await withTelegramApiErrorLogging({
operation: "setMyCommands",
await setTelegramMenuCommandsForScopes({
bot,
runtime,
commands: retryCommands,
shouldLog: (err) => !isBotCommandsTooMuchError(err),
fn: () => bot.api.setMyCommands(retryCommands),
});
if (retryCommands.length < initialCommandCount) {
runtime.log?.(

View File

@@ -294,6 +294,33 @@ describe("registerTelegramNativeCommands", () => {
expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found.");
});
it("replies to unmatched plugin commands in the originating forum topic", async () => {
const { handler, sendMessage } = registerPlugCommand();
pluginCommandMocks.matchPluginCommand.mockReturnValue(null as never);
await handler({
match: "",
message: {
message_id: 2,
date: Math.floor(Date.now() / 1000),
chat: {
id: -1001234567890,
type: "supergroup",
title: "Forum Group",
is_forum: true,
},
message_thread_id: 77,
from: { id: 200, username: "bob" },
},
});
expect(sendMessage).toHaveBeenCalledWith(
-1001234567890,
"Command not found.",
expect.objectContaining({ message_thread_id: 77 }),
);
});
it("uses nested streaming.block.enabled for native command block-streaming behavior", () => {
expect(
resolveTelegramNativeCommandDisableBlockStreaming({

View File

@@ -120,6 +120,15 @@ type TelegramCommandAuthResult = {
senderIsOwner: boolean;
};
type TelegramNativeCommandThreadContext = {
chatId: number;
isGroup: boolean;
isForum: boolean;
messageThreadId: number | undefined;
threadSpec: ReturnType<typeof resolveTelegramThreadSpec>;
threadParams: ReturnType<typeof buildTelegramThreadParams>;
};
let telegramNativeCommandDeliveryRuntimePromise:
| Promise<typeof import("./bot-native-commands.delivery.runtime.js")>
| undefined;
@@ -233,6 +242,40 @@ async function cleanupTelegramProgressPlaceholder(params: {
}
}
async function resolveTelegramNativeCommandThreadContext(params: {
msg: NonNullable<TelegramNativeCommandContext["message"]>;
bot: Bot;
}): Promise<TelegramNativeCommandThreadContext> {
const { msg, bot } = params;
const chatId = msg.chat.id;
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
const getChat =
typeof bot.api.getChat === "function"
? (bot.api.getChat.bind(bot.api) as TelegramGetChat)
: undefined;
const isForum = await resolveTelegramForumFlag({
chatId,
chatType: msg.chat.type,
isGroup,
isForum: extractTelegramForumFlag(msg.chat),
getChat,
});
const threadSpec = resolveTelegramThreadSpec({
isGroup,
isForum,
messageThreadId,
});
return {
chatId,
isGroup,
isForum,
messageThreadId,
threadSpec,
threadParams: buildTelegramThreadParams(threadSpec),
};
}
export type RegisterTelegramHandlerParams = {
cfg: OpenClawConfig;
accountId: string;
@@ -339,26 +382,8 @@ async function resolveTelegramCommandAuth(params: {
resolveTelegramGroupConfig,
requireAuth,
} = params;
const chatId = msg.chat.id;
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
const getChat =
typeof bot.api.getChat === "function"
? (bot.api.getChat.bind(bot.api) as TelegramGetChat)
: undefined;
const isForum = await resolveTelegramForumFlag({
chatId,
chatType: msg.chat.type,
isGroup,
isForum: extractTelegramForumFlag(msg.chat),
getChat,
});
const threadSpec = resolveTelegramThreadSpec({
isGroup,
isForum,
messageThreadId,
});
const threadParams = buildTelegramThreadParams(threadSpec) ?? {};
const { chatId, isGroup, isForum, messageThreadId, threadParams } =
await resolveTelegramNativeCommandThreadContext({ msg, bot });
const groupAllowContext = await resolveTelegramGroupAllowFromContext({
chatId,
accountId,
@@ -434,7 +459,7 @@ async function resolveTelegramCommandAuth(params: {
const sendAuthMessage = async (text: string) => {
await withTelegramApiErrorLogging({
operation: "sendMessage",
fn: () => bot.api.sendMessage(chatId, text, threadParams),
fn: () => bot.api.sendMessage(chatId, text, threadParams ?? {}),
});
return null;
};
@@ -1116,6 +1141,7 @@ export const registerTelegramNativeCommands = ({
const chatId = msg.chat.id;
const runtimeCfg = loadFreshRuntimeConfig();
const runtimeTelegramCfg = resolveFreshTelegramConfig(runtimeCfg);
const { threadParams } = await resolveTelegramNativeCommandThreadContext({ msg, bot });
const rawText = ctx.match?.trim() ?? "";
const commandBody = `/${pluginCommand.command}${rawText ? ` ${rawText}` : ""}`;
const nativeCommandRuntime = await loadTelegramNativeCommandRuntime();
@@ -1124,7 +1150,7 @@ export const registerTelegramNativeCommands = ({
await withTelegramApiErrorLogging({
operation: "sendMessage",
runtime,
fn: () => bot.api.sendMessage(chatId, "Command not found."),
fn: () => bot.api.sendMessage(chatId, "Command not found.", threadParams ?? {}),
});
return;
}
@@ -1286,5 +1312,10 @@ export const registerTelegramNativeCommands = ({
runtime,
fn: () => bot.api.setMyCommands([]),
}).catch(() => {});
withTelegramApiErrorLogging({
operation: "setMyCommands(all_group_chats)",
runtime,
fn: () => bot.api.setMyCommands([], { scope: { type: "all_group_chats" } }),
}).catch(() => {});
}
};

View File

@@ -2237,6 +2237,9 @@ describe("createTelegramBot", () => {
createTelegramBot({ token: "tok" });
expect(setMyCommandsSpy).toHaveBeenCalledWith([]);
expect(setMyCommandsSpy).toHaveBeenCalledWith([], {
scope: { type: "all_group_chats" },
});
});
it("handles requireMention when mentions do and do not resolve", async () => {
const cases = [