mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:20:43 +00:00
fix(telegram): register commands for group scope + preserve topic thread params
This commit is contained in:
committed by
Peter Steinberger
parent
d3e5292551
commit
a147540b5f
@@ -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.
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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?.(
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user