diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cb140818e7..0f48718b146 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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..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::` `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. diff --git a/extensions/telegram/src/bot-native-command-menu.test.ts b/extensions/telegram/src/bot-native-command-menu.test.ts index 7cf8027de0f..24cf08e5916 100644 --- a/extensions/telegram/src/bot-native-command-menu.test.ts +++ b/extensions/telegram/src/bot-native-command-menu.test.ts @@ -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; const secondPayload = setMyCommands.mock.calls[1]?.[0] as Array; + const thirdPayload = setMyCommands.mock.calls[2]?.[0] as Array; 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.", diff --git a/extensions/telegram/src/bot-native-command-menu.ts b/extensions/telegram/src/bot-native-command-menu.ts index 6acae08cac3..96c76912d9f 100644 --- a/extensions/telegram/src/bot-native-command-menu.ts +++ b/extensions/telegram/src/bot-native-command-menu.ts @@ -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 { + 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 { + 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?.( diff --git a/extensions/telegram/src/bot-native-commands.test.ts b/extensions/telegram/src/bot-native-commands.test.ts index 75b15ca1ead..9bfccd49e9d 100644 --- a/extensions/telegram/src/bot-native-commands.test.ts +++ b/extensions/telegram/src/bot-native-commands.test.ts @@ -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({ diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index ddbbdfb30b6..5ea158d340d 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -120,6 +120,15 @@ type TelegramCommandAuthResult = { senderIsOwner: boolean; }; +type TelegramNativeCommandThreadContext = { + chatId: number; + isGroup: boolean; + isForum: boolean; + messageThreadId: number | undefined; + threadSpec: ReturnType; + threadParams: ReturnType; +}; + let telegramNativeCommandDeliveryRuntimePromise: | Promise | undefined; @@ -233,6 +242,40 @@ async function cleanupTelegramProgressPlaceholder(params: { } } +async function resolveTelegramNativeCommandThreadContext(params: { + msg: NonNullable; + bot: Bot; +}): Promise { + 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(() => {}); } }; diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index bb3a85e143e..0c7d4969d55 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -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 = [