diff --git a/extensions/telegram/src/bot.command-menu.test.ts b/extensions/telegram/src/bot.command-menu.test.ts new file mode 100644 index 00000000000..3f4c065afe1 --- /dev/null +++ b/extensions/telegram/src/bot.command-menu.test.ts @@ -0,0 +1,196 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + getLoadConfigMock, + listSkillCommandsForAgents, + setMyCommandsSpy, + telegramBotDepsForTest, + telegramBotRuntimeForTest, +} = await import("./bot.create-telegram-bot.test-harness.js"); + +let listNativeCommandSpecs: typeof import("../../../src/auto-reply/commands-registry.js").listNativeCommandSpecs; +let listNativeCommandSpecsForConfig: typeof import("../../../src/auto-reply/commands-registry.js").listNativeCommandSpecsForConfig; +let normalizeTelegramCommandName: typeof import("./command-config.js").normalizeTelegramCommandName; +let createTelegramBotBase: typeof import("./bot.js").createTelegramBot; +let setTelegramBotRuntimeForTest: typeof import("./bot.js").setTelegramBotRuntimeForTest; +let createTelegramBot: ( + opts: Parameters[0], +) => ReturnType; + +const loadConfig = getLoadConfigMock(); + +function createSignal() { + let resolve!: () => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +function waitForNextSetMyCommands() { + const synced = createSignal(); + setMyCommandsSpy.mockImplementationOnce(async () => { + synced.resolve(); + return undefined; + }); + return synced.promise; +} + +function resolveSkillCommands(config: Parameters[0]) { + void config; + return listSkillCommandsForAgents() as NonNullable< + Parameters[1] + >["skillCommands"]; +} + +describe("createTelegramBot command menu", () => { + beforeAll(async () => { + ({ listNativeCommandSpecs, listNativeCommandSpecsForConfig } = + await import("../../../src/auto-reply/commands-registry.js")); + ({ normalizeTelegramCommandName } = await import("./command-config.js")); + ({ createTelegramBot: createTelegramBotBase, setTelegramBotRuntimeForTest } = + await import("./bot.js")); + }); + + beforeEach(() => { + loadConfig.mockReturnValue({ + agents: { + defaults: { + envelopeTimezone: "utc", + }, + }, + channels: { + telegram: { dmPolicy: "open", allowFrom: ["*"] }, + }, + }); + setTelegramBotRuntimeForTest( + telegramBotRuntimeForTest as unknown as Parameters[0], + ); + createTelegramBot = (opts) => + createTelegramBotBase({ + ...opts, + telegramDeps: telegramBotDepsForTest, + }); + }); + + it("merges custom commands with native commands", async () => { + const config = { + channels: { + telegram: { + customCommands: [ + { command: "custom_backup", description: "Git backup" }, + { command: "/Custom_Generate", description: "Create an image" }, + ], + }, + }, + }; + loadConfig.mockReturnValue(config); + const commandsSynced = waitForNextSetMyCommands(); + + createTelegramBot({ + token: "tok", + config: { + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + execApprovals: { + enabled: true, + approvers: ["9"], + target: "dm", + }, + }, + }, + }, + }); + + await commandsSynced; + + const registered = setMyCommandsSpy.mock.calls.at(-1)?.[0] as Array<{ + command: string; + description: string; + }>; + const skillCommands = resolveSkillCommands(config); + const native = listNativeCommandSpecsForConfig(config, { skillCommands }).map((command) => ({ + command: normalizeTelegramCommandName(command.name), + description: command.description, + })); + expect(registered.slice(0, native.length)).toEqual(native); + }); + + it("ignores custom commands that collide with native commands", async () => { + const errorSpy = vi.fn(); + const config = { + channels: { + telegram: { + customCommands: [ + { command: "status", description: "Custom status" }, + { command: "custom_backup", description: "Git backup" }, + ], + }, + }, + }; + loadConfig.mockReturnValue(config); + const commandsSynced = waitForNextSetMyCommands(); + + createTelegramBot({ + token: "tok", + runtime: { + log: vi.fn(), + error: errorSpy, + exit: ((code: number) => { + throw new Error(`exit ${code}`); + }) as (code: number) => never, + }, + }); + + await commandsSynced; + + const registered = setMyCommandsSpy.mock.calls[0]?.[0] as Array<{ + command: string; + description: string; + }>; + const skillCommands = resolveSkillCommands(config); + const native = listNativeCommandSpecsForConfig(config, { skillCommands }).map((command) => ({ + command: normalizeTelegramCommandName(command.name), + description: command.description, + })); + const nativeStatus = native.find((command) => command.command === "status"); + expect(nativeStatus).toBeDefined(); + expect(registered).toContainEqual({ command: "custom_backup", description: "Git backup" }); + expect(registered).not.toContainEqual({ command: "status", description: "Custom status" }); + expect(registered.filter((command) => command.command === "status")).toEqual([nativeStatus]); + expect(errorSpy).toHaveBeenCalled(); + }); + + it("registers custom commands when native commands are disabled", async () => { + const config = { + commands: { native: false }, + channels: { + telegram: { + customCommands: [ + { command: "custom_backup", description: "Git backup" }, + { command: "custom_generate", description: "Create an image" }, + ], + }, + }, + }; + loadConfig.mockReturnValue(config); + const commandsSynced = waitForNextSetMyCommands(); + + createTelegramBot({ token: "tok" }); + + await commandsSynced; + + const registered = setMyCommandsSpy.mock.calls[0]?.[0] as Array<{ + command: string; + description: string; + }>; + expect(registered).toEqual([ + { command: "custom_backup", description: "Git backup" }, + { command: "custom_generate", description: "Create an image" }, + ]); + const reserved = new Set(listNativeCommandSpecs().map((command) => command.name)); + expect(registered.some((command) => reserved.has(command.command))).toBe(false); + }); +}); diff --git a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts index 1e785cbc28e..5682f08be5e 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts @@ -1,14 +1,11 @@ -import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { - createReplyDispatcher, - resetInboundDedupe, - type GetReplyOptions, - type MsgContext, - type ReplyPayload, -} from "openclaw/plugin-sdk/reply-runtime"; import type { MockFn } from "openclaw/plugin-sdk/testing"; import { beforeEach, vi } from "vitest"; +import { resolveDefaultModelForAgent } from "../../../src/agents/model-selection.js"; +import { resetInboundDedupe } from "../../../src/auto-reply/reply/inbound-dedupe.js"; +import { createReplyDispatcher } from "../../../src/auto-reply/reply/reply-dispatcher.js"; +import type { MsgContext } from "../../../src/auto-reply/templating.js"; +import type { GetReplyOptions, ReplyPayload } from "../../../src/auto-reply/types.js"; import type { TelegramBotDeps } from "./bot-deps.js"; type AnyMock = ReturnType; @@ -179,6 +176,12 @@ const buildModelsProviderData = modelProviderDataHoisted.buildModelsProviderData export const replySpy = replySpyHoisted.replySpy; export const dispatchReplyWithBufferedBlockDispatcher = dispatchReplyHoisted.dispatchReplyWithBufferedBlockDispatcher; +const menuSyncHoisted = vi.hoisted(() => ({ + syncTelegramMenuCommands: vi.fn(async ({ bot, commandsToRegister }) => { + await bot.api.setMyCommands(commandsToRegister); + }), +})); +export const syncTelegramMenuCommands = menuSyncHoisted.syncTelegramMenuCommands; function parseModelRef(raw: string): { provider?: string; model: string } { const trimmed = raw.trim(); @@ -368,6 +371,7 @@ export const telegramBotDepsForTest: TelegramBotDeps = { buildModelsProviderData: buildModelsProviderData as TelegramBotDeps["buildModelsProviderData"], listSkillCommandsForAgents: listSkillCommandsForAgents as TelegramBotDeps["listSkillCommandsForAgents"], + syncTelegramMenuCommands: syncTelegramMenuCommands as TelegramBotDeps["syncTelegramMenuCommands"], wasSentByBot: wasSentByBot as TelegramBotDeps["wasSentByBot"], resolveExecApproval: resolveExecApprovalSpy as NonNullable< TelegramBotDeps["resolveExecApproval"] @@ -477,6 +481,10 @@ beforeEach(() => { return await replySpy(dispatchParams.ctx, dispatchParams.replyOptions); }), ); + syncTelegramMenuCommands.mockReset(); + syncTelegramMenuCommands.mockImplementation(async ({ bot, commandsToRegister }) => { + await bot.api.setMyCommands(commandsToRegister); + }); sendAnimationSpy.mockReset(); sendAnimationSpy.mockResolvedValue({ message_id: 78 }); diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index caecafff36f..dba51905cff 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -5,8 +5,6 @@ import { registerPluginInteractiveHandler, } from "openclaw/plugin-sdk/plugin-runtime"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js"; -import { expectChannelInboundContextContract as expectInboundContextContract } from "./test-support/inbound-context-contract.js"; const { answerCallbackQuerySpy, commandSpy, @@ -29,10 +27,7 @@ const { wasSentByBot, } = await import("./bot.create-telegram-bot.test-harness.js"); -let listNativeCommandSpecs: typeof import("../../../src/auto-reply/commands-registry.js").listNativeCommandSpecs; -let listNativeCommandSpecsForConfig: typeof import("../../../src/auto-reply/commands-registry.js").listNativeCommandSpecsForConfig; let loadSessionStore: typeof import("../../../src/config/sessions.js").loadSessionStore; -let normalizeTelegramCommandName: typeof import("openclaw/plugin-sdk/config-runtime").normalizeTelegramCommandName; let createTelegramBotBase: typeof import("./bot.js").createTelegramBot; let setTelegramBotRuntimeForTest: typeof import("./bot.js").setTelegramBotRuntimeForTest; let createTelegramBot: ( @@ -59,15 +54,6 @@ function createSignal() { return { promise, resolve }; } -function waitForNextSetMyCommands() { - const synced = createSignal(); - setMyCommandsSpy.mockImplementationOnce(async () => { - synced.resolve(); - return undefined; - }); - return synced.promise; -} - function waitForReplyCalls(count: number) { const done = createSignal(); let seen = 0; @@ -82,20 +68,18 @@ function waitForReplyCalls(count: number) { return done.promise; } -function resolveSkillCommands(config: Parameters[0]) { - void config; - return listSkillCommandsForAgents() as NonNullable< - Parameters[1] - >["skillCommands"]; +async function loadEnvelopeTimestampHelpers() { + return await import("../../../test/helpers/envelope-timestamp.js"); +} + +async function loadInboundContextContract() { + return await import("./test-support/inbound-context-contract.js"); } const ORIGINAL_TZ = process.env.TZ; describe("createTelegramBot", () => { beforeAll(async () => { - ({ listNativeCommandSpecs, listNativeCommandSpecsForConfig } = - await import("../../../src/auto-reply/commands-registry.js")); ({ loadSessionStore } = await import("../../../src/config/sessions.js")); - ({ normalizeTelegramCommandName } = await import("openclaw/plugin-sdk/config-runtime")); ({ createTelegramBot: createTelegramBotBase, setTelegramBotRuntimeForTest } = await import("./bot.js")); }); @@ -129,127 +113,6 @@ describe("createTelegramBot", () => { }); }); - it("merges custom commands with native commands", async () => { - const config = { - channels: { - telegram: { - customCommands: [ - { command: "custom_backup", description: "Git backup" }, - { command: "/Custom_Generate", description: "Create an image" }, - ], - }, - }, - }; - loadConfig.mockReturnValue(config); - const commandsSynced = waitForNextSetMyCommands(); - - createTelegramBot({ - token: "tok", - config: { - channels: { - telegram: { - dmPolicy: "open", - allowFrom: ["*"], - execApprovals: { - enabled: true, - approvers: ["9"], - target: "dm", - }, - }, - }, - }, - }); - - await commandsSynced; - - const registered = setMyCommandsSpy.mock.calls.at(-1)?.[0] as Array<{ - command: string; - description: string; - }>; - const skillCommands = resolveSkillCommands(config); - const native = listNativeCommandSpecsForConfig(config, { skillCommands }).map((command) => ({ - command: normalizeTelegramCommandName(command.name), - description: command.description, - })); - expect(registered.slice(0, native.length)).toEqual(native); - }); - - it("ignores custom commands that collide with native commands", async () => { - const errorSpy = vi.fn(); - const config = { - channels: { - telegram: { - customCommands: [ - { command: "status", description: "Custom status" }, - { command: "custom_backup", description: "Git backup" }, - ], - }, - }, - }; - loadConfig.mockReturnValue(config); - const commandsSynced = waitForNextSetMyCommands(); - - createTelegramBot({ - token: "tok", - runtime: { - log: vi.fn(), - error: errorSpy, - exit: ((code: number) => { - throw new Error(`exit ${code}`); - }) as (code: number) => never, - }, - }); - - await commandsSynced; - - const registered = setMyCommandsSpy.mock.calls[0]?.[0] as Array<{ - command: string; - description: string; - }>; - const skillCommands = resolveSkillCommands(config); - const native = listNativeCommandSpecsForConfig(config, { skillCommands }).map((command) => ({ - command: normalizeTelegramCommandName(command.name), - description: command.description, - })); - const nativeStatus = native.find((command) => command.command === "status"); - expect(nativeStatus).toBeDefined(); - expect(registered).toContainEqual({ command: "custom_backup", description: "Git backup" }); - expect(registered).not.toContainEqual({ command: "status", description: "Custom status" }); - expect(registered.filter((command) => command.command === "status")).toEqual([nativeStatus]); - expect(errorSpy).toHaveBeenCalled(); - }); - - it("registers custom commands when native commands are disabled", async () => { - const config = { - commands: { native: false }, - channels: { - telegram: { - customCommands: [ - { command: "custom_backup", description: "Git backup" }, - { command: "custom_generate", description: "Create an image" }, - ], - }, - }, - }; - loadConfig.mockReturnValue(config); - const commandsSynced = waitForNextSetMyCommands(); - - createTelegramBot({ token: "tok" }); - - await commandsSynced; - - const registered = setMyCommandsSpy.mock.calls[0]?.[0] as Array<{ - command: string; - description: string; - }>; - expect(registered).toEqual([ - { command: "custom_backup", description: "Git backup" }, - { command: "custom_generate", description: "Create an image" }, - ]); - const reserved = new Set(listNativeCommandSpecs().map((command) => command.name)); - expect(registered.some((command) => reserved.has(command.command))).toBe(false); - }); - it("blocks callback_query when inline buttons are allowlist-only and sender not authorized", async () => { onSpy.mockClear(); replySpy.mockClear(); @@ -1195,6 +1058,9 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; + const { expectChannelInboundContextContract: expectInboundContextContract } = + await loadInboundContextContract(); + const { escapeRegExp, formatEnvelopeTimestamp } = await loadEnvelopeTimestampHelpers(); expectInboundContextContract(payload); const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z")); const timestampPattern = escapeRegExp(expectedTimestamp);