diff --git a/src/infra/outbound/message-action-runner.poll.test.ts b/src/infra/outbound/message-action-runner.poll.test.ts new file mode 100644 index 00000000000..43c7489c6fd --- /dev/null +++ b/src/infra/outbound/message-action-runner.poll.test.ts @@ -0,0 +1,196 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { slackPlugin } from "../../../extensions/slack/src/channel.js"; +import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { createTestRegistry } from "../../test-utils/channel-plugins.js"; + +const mocks = vi.hoisted(() => ({ + executePollAction: vi.fn(), +})); + +vi.mock("./outbound-send-service.js", async () => { + const actual = await vi.importActual( + "./outbound-send-service.js", + ); + return { + ...actual, + executePollAction: mocks.executePollAction, + }; +}); + +import { runMessageAction } from "./message-action-runner.js"; + +const slackConfig = { + channels: { + slack: { + botToken: "xoxb-test", + appToken: "xapp-test", + }, + }, +} as OpenClawConfig; + +const telegramConfig = { + channels: { + telegram: { + botToken: "telegram-test", + }, + }, +} as OpenClawConfig; + +async function runPollAction(params: { + cfg: OpenClawConfig; + actionParams: Record; + toolContext?: Record; +}) { + await runMessageAction({ + cfg: params.cfg, + action: "poll", + params: params.actionParams as never, + toolContext: params.toolContext as never, + }); + return mocks.executePollAction.mock.calls[0]?.[0] as + | { + durationSeconds?: number; + maxSelections?: number; + threadId?: string; + isAnonymous?: boolean; + ctx?: { params?: Record }; + } + | undefined; +} + +let createPluginRuntime: typeof import("../../plugins/runtime/index.js").createPluginRuntime; +let setSlackRuntime: typeof import("../../../extensions/slack/src/runtime.js").setSlackRuntime; +let setTelegramRuntime: typeof import("../../../extensions/telegram/src/runtime.js").setTelegramRuntime; + +describe("runMessageAction poll handling", () => { + beforeAll(async () => { + ({ createPluginRuntime } = await import("../../plugins/runtime/index.js")); + ({ setSlackRuntime } = await import("../../../extensions/slack/src/runtime.js")); + ({ setTelegramRuntime } = await import("../../../extensions/telegram/src/runtime.js")); + }); + + beforeEach(() => { + const runtime = createPluginRuntime(); + setSlackRuntime(runtime); + setTelegramRuntime(runtime); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "slack", + source: "test", + plugin: slackPlugin, + }, + { + pluginId: "telegram", + source: "test", + plugin: telegramPlugin, + }, + ]), + ); + mocks.executePollAction.mockResolvedValue({ + handledBy: "core", + payload: { ok: true }, + pollResult: { ok: true }, + }); + }); + + afterEach(() => { + setActivePluginRegistry(createTestRegistry([])); + mocks.executePollAction.mockReset(); + }); + + it.each([ + { + name: "requires at least two poll options", + cfg: telegramConfig, + actionParams: { + channel: "telegram", + target: "telegram:123", + pollQuestion: "Lunch?", + pollOption: ["Pizza"], + }, + message: /pollOption requires at least two values/i, + }, + { + name: "rejects durationSeconds outside telegram", + cfg: slackConfig, + actionParams: { + channel: "slack", + target: "#C12345678", + pollQuestion: "Lunch?", + pollOption: ["Pizza", "Sushi"], + pollDurationSeconds: 60, + }, + message: /pollDurationSeconds is only supported for Telegram polls/i, + }, + { + name: "rejects poll visibility outside telegram", + cfg: slackConfig, + actionParams: { + channel: "slack", + target: "#C12345678", + pollQuestion: "Lunch?", + pollOption: ["Pizza", "Sushi"], + pollPublic: true, + }, + message: /pollAnonymous\/pollPublic are only supported for Telegram polls/i, + }, + ])("$name", async ({ cfg, actionParams, message }) => { + await expect(runPollAction({ cfg, actionParams })).rejects.toThrow(message); + expect(mocks.executePollAction).not.toHaveBeenCalled(); + }); + + it("passes Telegram durationSeconds, visibility, and auto threadId to executePollAction", async () => { + const call = await runPollAction({ + cfg: telegramConfig, + actionParams: { + channel: "telegram", + target: "telegram:123", + pollQuestion: "Lunch?", + pollOption: ["Pizza", "Sushi"], + pollDurationSeconds: 90, + pollPublic: true, + }, + toolContext: { + currentChannelId: "telegram:123", + currentThreadTs: "42", + }, + }); + + expect(call?.durationSeconds).toBe(90); + expect(call?.isAnonymous).toBe(false); + expect(call?.threadId).toBe("42"); + expect(call?.ctx?.params?.threadId).toBe("42"); + }); + + it("expands maxSelections when pollMulti is enabled", async () => { + const call = await runPollAction({ + cfg: telegramConfig, + actionParams: { + channel: "telegram", + target: "telegram:123", + pollQuestion: "Lunch?", + pollOption: ["Pizza", "Sushi", "Soup"], + pollMulti: true, + }, + }); + + expect(call?.maxSelections).toBe(3); + }); + + it("defaults maxSelections to one choice when pollMulti is omitted", async () => { + const call = await runPollAction({ + cfg: telegramConfig, + actionParams: { + channel: "telegram", + target: "telegram:123", + pollQuestion: "Lunch?", + pollOption: ["Pizza", "Sushi", "Soup"], + }, + }); + + expect(call?.maxSelections).toBe(1); + }); +});