diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts deleted file mode 100644 index 80c19987cbc..00000000000 --- a/src/channels/plugins/actions/actions.test.ts +++ /dev/null @@ -1,1429 +0,0 @@ -import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../../config/config.js"; -import type { ChannelMessageActionAdapter } from "../types.js"; - -const actionResult = (): AgentToolResult => ({ - content: [], - details: { ok: true }, -}); - -const handleDiscordAction = vi.hoisted(() => vi.fn(async (..._args: unknown[]) => actionResult())); -const handleTelegramAction = vi.hoisted(() => vi.fn(async (..._args: unknown[]) => actionResult())); -const sendReactionSignal = vi.hoisted(() => vi.fn(async (..._args: unknown[]) => ({ ok: true }))); -const removeReactionSignal = vi.hoisted(() => vi.fn(async (..._args: unknown[]) => ({ ok: true }))); -const handleSlackAction = vi.hoisted(() => vi.fn(async (..._args: unknown[]) => actionResult())); - -vi.mock("../../../../extensions/discord/src/actions/runtime.js", () => ({ - handleDiscordAction: async (...args: unknown[]) => await handleDiscordAction(...args), -})); - -vi.mock("../../../../extensions/signal/src/send-reactions.js", () => ({ - sendReactionSignal: async (...args: unknown[]) => await sendReactionSignal(...args), - removeReactionSignal: async (...args: unknown[]) => await removeReactionSignal(...args), -})); - -let discordMessageActions: typeof import("../../../../extensions/discord/runtime-api.js").discordMessageActions; -let handleDiscordMessageAction: typeof import("./discord/handle-action.js").handleDiscordMessageAction; -let telegramMessageActions: typeof import("../../../../extensions/telegram/runtime-api.js").telegramMessageActions; -let signalMessageActions: typeof import("../../../../extensions/signal/api.js").signalMessageActions; -let createSlackActions: typeof import("../../../../extensions/slack/test-api.js").createSlackActions; -let telegramTestApiModule: typeof import("../../../../extensions/telegram/test-api.js"); - -function getDescribedActions(params: { - describeMessageTool?: ChannelMessageActionAdapter["describeMessageTool"]; - cfg: OpenClawConfig; -}) { - return [...(params.describeMessageTool?.({ cfg: params.cfg })?.actions ?? [])]; -} - -function telegramCfg(): OpenClawConfig { - return { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; -} - -type TelegramActionInput = Parameters>[0]; - -async function runTelegramAction( - action: TelegramActionInput["action"], - params: TelegramActionInput["params"], - options?: { cfg?: OpenClawConfig; accountId?: string }, -) { - const cfg = options?.cfg ?? telegramCfg(); - const handleAction = telegramMessageActions.handleAction; - if (!handleAction) { - throw new Error("telegram handleAction unavailable"); - } - await handleAction({ - channel: "telegram", - action, - params, - cfg, - accountId: options?.accountId, - }); - return { cfg }; -} - -type SignalActionInput = Parameters>[0]; - -async function runSignalAction( - action: SignalActionInput["action"], - params: SignalActionInput["params"], - options?: { - cfg?: OpenClawConfig; - accountId?: string; - toolContext?: SignalActionInput["toolContext"]; - }, -) { - const cfg = - options?.cfg ?? ({ channels: { signal: { account: "+15550001111" } } } as OpenClawConfig); - const handleAction = signalMessageActions.handleAction; - if (!handleAction) { - throw new Error("signal handleAction unavailable"); - } - await handleAction({ - channel: "signal", - action, - params, - cfg, - accountId: options?.accountId, - toolContext: options?.toolContext, - }); - return { cfg }; -} - -function slackHarness() { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - const actions = createSlackActions("slack", { - invoke: async (action, invokeCfg, toolContext) => - await handleSlackAction(action, invokeCfg, toolContext), - }); - return { cfg, actions }; -} - -type SlackActionInput = Parameters< - NonNullable["handleAction"]> ->[0]; - -async function runSlackAction( - action: SlackActionInput["action"], - params: SlackActionInput["params"], -) { - const { cfg, actions } = slackHarness(); - await actions.handleAction?.({ - channel: "slack", - action, - cfg, - params, - }); - return { cfg, actions }; -} - -function expectFirstSlackAction(expected: Record) { - const [params] = handleSlackAction.mock.calls[0] ?? []; - expect(params).toMatchObject(expected); -} - -function expectModerationActions(actions: string[]) { - expect(actions).toContain("timeout"); - expect(actions).toContain("kick"); - expect(actions).toContain("ban"); -} - -function expectChannelCreateAction(actions: string[], expected: boolean) { - if (expected) { - expect(actions).toContain("channel-create"); - return; - } - expect(actions).not.toContain("channel-create"); -} - -function createSignalAccountOverrideCfg(): OpenClawConfig { - return { - channels: { - signal: { - actions: { reactions: false }, - accounts: { - work: { account: "+15550001111", actions: { reactions: true } }, - }, - }, - }, - } as OpenClawConfig; -} - -function createDiscordModerationOverrideCfg(params?: { - channelsEnabled?: boolean; -}): OpenClawConfig { - const accountActions = params?.channelsEnabled - ? { moderation: true, channels: true } - : { moderation: true }; - return { - channels: { - discord: { - actions: { channels: false }, - accounts: { - vime: { token: "d1", actions: accountActions }, - }, - }, - }, - } as OpenClawConfig; -} - -async function expectSignalActionRejected( - params: Record, - error: RegExp, - cfg: OpenClawConfig, -) { - const handleAction = signalMessageActions.handleAction; - if (!handleAction) { - throw new Error("signal handleAction unavailable"); - } - await expect( - handleAction({ - channel: "signal", - action: "react", - params, - cfg, - accountId: undefined, - }), - ).rejects.toThrow(error); -} - -async function expectSlackSendRejected(params: Record, error: RegExp) { - const { cfg, actions } = slackHarness(); - await expect( - actions.handleAction?.({ - channel: "slack", - action: "send", - cfg, - params, - }), - ).rejects.toThrow(error); - expect(handleSlackAction).not.toHaveBeenCalled(); -} - -beforeAll(async () => { - vi.resetModules(); - ({ discordMessageActions } = await import("../../../../extensions/discord/runtime-api.js")); - ({ handleDiscordMessageAction } = await import("./discord/handle-action.js")); - ({ telegramMessageActions } = await import("../../../../extensions/telegram/runtime-api.js")); - telegramTestApiModule = await import("../../../../extensions/telegram/test-api.js"); - ({ signalMessageActions } = await import("../../../../extensions/signal/api.js")); - ({ createSlackActions } = await import("../../../../extensions/slack/test-api.js")); -}); - -beforeEach(() => { - vi.clearAllMocks(); - vi.restoreAllMocks(); - telegramTestApiModule.telegramMessageActionRuntime.handleTelegramAction = async (...args) => - await handleTelegramAction(...args); -}); - -describe("discord message actions", () => { - it("derives discord action listings from channel and moderation gates", () => { - const cases = [ - { - name: "defaults", - cfg: { channels: { discord: { token: "d0" } } } as OpenClawConfig, - expectUploads: true, - expectChannelCreate: true, - expectModeration: false, - }, - { - name: "disabled channel actions", - cfg: { - channels: { discord: { token: "d0", actions: { channels: false } } }, - } as OpenClawConfig, - expectUploads: true, - expectChannelCreate: false, - expectModeration: false, - }, - { - name: "single account enables moderation", - cfg: { - channels: { - discord: { - accounts: { - vime: { token: "d1", actions: { moderation: true } }, - }, - }, - }, - } as OpenClawConfig, - expectUploads: true, - expectChannelCreate: true, - expectModeration: true, - }, - { - name: "one of many accounts enables moderation", - cfg: { - channels: { - discord: { - accounts: { - ops: { token: "d1", actions: { moderation: true } }, - chat: { token: "d2" }, - }, - }, - }, - } as OpenClawConfig, - expectUploads: true, - expectChannelCreate: true, - expectModeration: true, - }, - { - name: "all accounts omit moderation", - cfg: { - channels: { - discord: { - accounts: { - ops: { token: "d1" }, - chat: { token: "d2" }, - }, - }, - }, - } as OpenClawConfig, - expectUploads: true, - expectChannelCreate: true, - expectModeration: false, - }, - { - name: "account moderation override inherits disabled top-level channels", - cfg: createDiscordModerationOverrideCfg(), - expectUploads: true, - expectChannelCreate: false, - expectModeration: true, - }, - { - name: "account override re-enables top-level disabled channels", - cfg: createDiscordModerationOverrideCfg({ channelsEnabled: true }), - expectUploads: true, - expectChannelCreate: true, - expectModeration: true, - }, - ] as const; - - for (const testCase of cases) { - const actions = getDescribedActions({ - describeMessageTool: discordMessageActions.describeMessageTool, - cfg: testCase.cfg, - }); - if (testCase.expectUploads) { - expect(actions, testCase.name).toContain("emoji-upload"); - expect(actions, testCase.name).toContain("sticker-upload"); - } - expectChannelCreateAction(actions, testCase.expectChannelCreate); - if (testCase.expectModeration) { - expectModerationActions(actions); - } else { - expect(actions, testCase.name).not.toContain("timeout"); - expect(actions, testCase.name).not.toContain("kick"); - expect(actions, testCase.name).not.toContain("ban"); - } - } - }); -}); - -describe("handleDiscordMessageAction", () => { - const embeds = [{ title: "Legacy", description: "Use components v2." }]; - const forwardingCases = [ - { - name: "forwards context accountId for send", - input: { - action: "send" as const, - params: { to: "channel:123", message: "hi" }, - accountId: "ops", - }, - expected: { - action: "sendMessage", - accountId: "ops", - to: "channel:123", - content: "hi", - }, - }, - { - name: "forwards legacy embeds for send", - input: { - action: "send" as const, - params: { to: "channel:123", message: "hi", embeds }, - }, - expected: { - action: "sendMessage", - to: "channel:123", - content: "hi", - embeds, - }, - }, - { - name: "falls back to params accountId when context missing", - input: { - action: "poll" as const, - params: { - to: "channel:123", - pollQuestion: "Ready?", - pollOption: ["Yes", "No"], - accountId: "marve", - }, - }, - expected: { - action: "poll", - accountId: "marve", - to: "channel:123", - question: "Ready?", - answers: ["Yes", "No"], - }, - }, - { - name: "parses string booleans for discord poll adapter params", - input: { - action: "poll" as const, - params: { - to: "channel:123", - pollQuestion: "Ready?", - pollOption: ["Yes", "No"], - pollMulti: "true", - }, - }, - expected: { - action: "poll", - to: "channel:123", - question: "Ready?", - answers: ["Yes", "No"], - allowMultiselect: true, - }, - }, - { - name: "rejects partially numeric poll duration for discord poll adapter params", - input: { - action: "poll" as const, - params: { - to: "channel:123", - pollQuestion: "Ready?", - pollOption: ["Yes", "No"], - pollDurationHours: "24h", - }, - }, - expected: { - action: "poll", - to: "channel:123", - question: "Ready?", - answers: ["Yes", "No"], - durationHours: undefined, - }, - }, - { - name: "forwards accountId for thread replies", - input: { - action: "thread-reply" as const, - params: { channelId: "123", message: "hi" }, - accountId: "ops", - }, - expected: { - action: "threadReply", - accountId: "ops", - channelId: "123", - content: "hi", - }, - }, - { - name: "accepts threadId for thread replies (tool compatibility)", - input: { - action: "thread-reply" as const, - params: { - threadId: "999", - channelId: "123", - message: "hi", - }, - accountId: "ops", - }, - expected: { - action: "threadReply", - accountId: "ops", - channelId: "999", - content: "hi", - }, - }, - { - name: "forwards thread-create message as content", - input: { - action: "thread-create" as const, - params: { - to: "channel:123456789", - threadName: "Forum thread", - message: "Initial forum post body", - }, - }, - expected: { - action: "threadCreate", - channelId: "123456789", - name: "Forum thread", - content: "Initial forum post body", - }, - }, - { - name: "forwards thread edit fields for channel-edit", - input: { - action: "channel-edit" as const, - params: { - channelId: "123456789", - archived: true, - locked: false, - autoArchiveDuration: 1440, - }, - }, - expected: { - action: "channelEdit", - channelId: "123456789", - archived: true, - locked: false, - autoArchiveDuration: 1440, - }, - }, - ] as const; - - for (const testCase of forwardingCases) { - it(testCase.name, async () => { - await handleDiscordMessageAction({ - ...testCase.input, - cfg: {} as OpenClawConfig, - }); - - const call = handleDiscordAction.mock.calls.at(-1); - expect(call?.[0]).toEqual(expect.objectContaining(testCase.expected)); - expect(call?.[1]).toEqual(expect.any(Object)); - }); - } - - it("uses trusted requesterSenderId for moderation and ignores params senderUserId", async () => { - await handleDiscordMessageAction({ - action: "timeout", - params: { - guildId: "guild-1", - userId: "user-2", - durationMin: 5, - senderUserId: "spoofed-admin-id", - }, - cfg: {} as OpenClawConfig, - requesterSenderId: "trusted-sender-id", - toolContext: { currentChannelProvider: "discord" }, - }); - - const call = handleDiscordAction.mock.calls.at(-1); - expect(call?.[0]).toEqual( - expect.objectContaining({ - action: "timeout", - guildId: "guild-1", - userId: "user-2", - durationMinutes: 5, - senderUserId: "trusted-sender-id", - }), - ); - expect(call?.[1]).toEqual(expect.any(Object)); - }); - - it("handles discord reaction messageId resolution", async () => { - const cases = [ - { - name: "falls back to toolContext.currentMessageId", - run: async () => { - await handleDiscordMessageAction({ - action: "react", - params: { - channelId: "123", - emoji: "ok", - }, - cfg: {} as OpenClawConfig, - toolContext: { currentMessageId: "9001" }, - }); - }, - assert: () => { - const call = handleDiscordAction.mock.calls.at(-1); - expect(call?.[0]).toEqual( - expect.objectContaining({ - action: "react", - channelId: "123", - messageId: "9001", - emoji: "ok", - }), - ); - }, - }, - { - name: "rejects when no message id source is available", - run: async () => { - await expect( - handleDiscordMessageAction({ - action: "react", - params: { - channelId: "123", - emoji: "ok", - }, - cfg: {} as OpenClawConfig, - }), - ).rejects.toThrow(/messageId required/i); - }, - assert: () => { - expect(handleDiscordAction).not.toHaveBeenCalled(); - }, - }, - ] as const; - - for (const testCase of cases) { - handleDiscordAction.mockClear(); - await testCase.run(); - testCase.assert(); - } - }); -}); - -describe("telegramMessageActions", () => { - it("computes poll/topic action availability from telegram config gates", () => { - for (const testCase of [ - { - name: "configured telegram enables poll", - cfg: telegramCfg(), - expectPoll: true, - expectTopicEdit: true, - }, - { - name: "topic edit gate enables topic-edit", - cfg: { - channels: { - telegram: { - botToken: "tok", - actions: { editForumTopic: true }, - }, - }, - } as OpenClawConfig, - expectPoll: true, - expectTopicEdit: true, - }, - { - name: "sendMessage disabled hides poll", - cfg: { - channels: { - telegram: { - botToken: "tok", - actions: { sendMessage: false }, - }, - }, - } as OpenClawConfig, - expectPoll: false, - expectTopicEdit: true, - }, - { - name: "poll gate disabled hides poll", - cfg: { - channels: { - telegram: { - botToken: "tok", - actions: { poll: false }, - }, - }, - } as OpenClawConfig, - expectPoll: false, - expectTopicEdit: true, - }, - { - name: "split account gates do not expose poll", - cfg: { - channels: { - telegram: { - accounts: { - senderOnly: { - botToken: "tok-send", - actions: { - sendMessage: true, - poll: false, - }, - }, - pollOnly: { - botToken: "tok-poll", - actions: { - sendMessage: false, - poll: true, - }, - }, - }, - }, - }, - } as OpenClawConfig, - expectPoll: false, - expectTopicEdit: true, - }, - ]) { - const actions = getDescribedActions({ - describeMessageTool: telegramMessageActions.describeMessageTool, - cfg: testCase.cfg, - }); - if (testCase.expectPoll) { - expect(actions, testCase.name).toContain("poll"); - } else { - expect(actions, testCase.name).not.toContain("poll"); - } - if (testCase.expectTopicEdit) { - expect(actions, testCase.name).toContain("topic-edit"); - } else { - expect(actions, testCase.name).not.toContain("topic-edit"); - } - } - }); - - it("lists sticker actions only when enabled by config", () => { - const cases = [ - { - name: "default config", - cfg: telegramCfg(), - expectSticker: false, - }, - { - name: "per-account sticker enabled", - cfg: { - channels: { - telegram: { - accounts: { - media: { botToken: "tok", actions: { sticker: true } }, - }, - }, - }, - } as OpenClawConfig, - expectSticker: true, - }, - { - name: "all accounts omit sticker", - cfg: { - channels: { - telegram: { - accounts: { - a: { botToken: "tok1" }, - b: { botToken: "tok2" }, - }, - }, - }, - } as OpenClawConfig, - expectSticker: false, - }, - ] as const; - - for (const testCase of cases) { - const actions = getDescribedActions({ - describeMessageTool: telegramMessageActions.describeMessageTool, - cfg: testCase.cfg, - }); - if (testCase.expectSticker) { - expect(actions, testCase.name).toContain("sticker"); - expect(actions, testCase.name).toContain("sticker-search"); - } else { - expect(actions, testCase.name).not.toContain("sticker"); - expect(actions, testCase.name).not.toContain("sticker-search"); - } - } - }); - - it("forwards telegram action aliases into the runtime interface", async () => { - const cases = [ - { - name: "media-only send preserves asVoice", - action: "send" as const, - params: { - to: "123", - media: "https://example.com/voice.ogg", - asVoice: true, - }, - expectedPayload: expect.objectContaining({ - action: "sendMessage", - to: "123", - media: "https://example.com/voice.ogg", - asVoice: true, - }), - }, - { - name: "media-only send preserves asDocument", - action: "send" as const, - params: { - to: "123", - media: "https://example.com/photo.jpg", - asDocument: true, - }, - expectedPayload: expect.objectContaining({ - action: "sendMessage", - to: "123", - media: "https://example.com/photo.jpg", - asDocument: true, - }), - }, - { - name: "explicit forceDocument false beats asDocument alias", - action: "send" as const, - params: { - to: "123", - media: "https://example.com/photo.jpg", - forceDocument: false, - asDocument: true, - }, - expectedPayload: expect.objectContaining({ - action: "sendMessage", - to: "123", - media: "https://example.com/photo.jpg", - forceDocument: false, - }), - }, - { - name: "silent send forwards silent flag", - action: "send" as const, - params: { - to: "456", - message: "Silent notification test", - silent: true, - }, - expectedPayload: expect.objectContaining({ - action: "sendMessage", - to: "456", - message: "Silent notification test", - silent: true, - }), - }, - { - name: "edit maps to editMessage", - action: "edit" as const, - params: { - chatId: "123", - messageId: 42, - message: "Updated", - buttons: [], - }, - expectedPayload: { - action: "editMessage", - chatId: "123", - messageId: 42, - message: "Updated", - buttons: [], - accountId: undefined, - }, - }, - { - name: "poll maps to telegram poll action", - action: "poll" as const, - params: { - to: "123", - pollQuestion: "Ready?", - pollOption: ["Yes", "No"], - pollMulti: true, - pollDurationSeconds: 60, - pollPublic: true, - replyTo: 55, - threadId: 77, - silent: true, - }, - expectedPayload: { - action: "poll", - to: "123", - pollQuestion: "Ready?", - pollOption: ["Yes", "No"], - pollMulti: true, - pollDurationSeconds: 60, - pollPublic: true, - replyTo: 55, - threadId: 77, - silent: true, - accountId: undefined, - }, - }, - { - name: "poll forwards raw alias flags to telegram runtime", - action: "poll" as const, - params: { - to: "123", - pollQuestion: "Ready?", - pollOption: ["Yes", "No"], - pollMulti: "true", - pollPublic: "true", - silent: "true", - }, - expectedPayload: { - action: "poll", - to: "123", - pollQuestion: "Ready?", - pollOption: ["Yes", "No"], - pollMulti: "true", - pollPublic: "true", - silent: "true", - accountId: undefined, - }, - }, - { - name: "poll forwards duration strings for runtime validation", - action: "poll" as const, - params: { - to: "123", - pollQuestion: "Ready?", - pollOption: ["Yes", "No"], - pollDurationSeconds: "60s", - }, - expectedPayload: { - action: "poll", - to: "123", - pollQuestion: "Ready?", - pollOption: ["Yes", "No"], - pollDurationSeconds: "60s", - accountId: undefined, - }, - }, - { - name: "topic-create maps to createForumTopic", - action: "topic-create" as const, - params: { - to: "telegram:group:-1001234567890:topic:271", - name: "Build Updates", - }, - expectedPayload: { - action: "createForumTopic", - to: "telegram:group:-1001234567890:topic:271", - name: "Build Updates", - accountId: undefined, - }, - }, - { - name: "topic-edit maps to editForumTopic", - action: "topic-edit" as const, - params: { - to: "telegram:group:-1001234567890:topic:271", - threadId: 271, - name: "Build Updates", - iconCustomEmojiId: "emoji-123", - }, - expectedPayload: { - action: "editForumTopic", - to: "telegram:group:-1001234567890:topic:271", - threadId: 271, - name: "Build Updates", - iconCustomEmojiId: "emoji-123", - accountId: undefined, - }, - }, - ] as const; - - for (const testCase of cases) { - handleTelegramAction.mockClear(); - const { cfg } = await runTelegramAction(testCase.action, testCase.params); - expect(handleTelegramAction, testCase.name).toHaveBeenCalledWith( - testCase.expectedPayload, - cfg, - expect.objectContaining({ mediaLocalRoots: undefined }), - ); - } - }); - - it("inherits top-level reaction gate when account overrides sticker only", () => { - const cfg = { - channels: { - telegram: { - actions: { reactions: false }, - accounts: { - media: { botToken: "tok", actions: { sticker: true } }, - }, - }, - }, - } as OpenClawConfig; - const actions = getDescribedActions({ - describeMessageTool: telegramMessageActions.describeMessageTool, - cfg, - }); - - expect(actions).toContain("sticker"); - expect(actions).toContain("sticker-search"); - expect(actions).not.toContain("react"); - }); - - it("normalizes telegram reaction message identifiers before dispatch", async () => { - const cfg = telegramCfg(); - for (const testCase of [ - { - name: "numeric channelId/messageId", - params: { - channelId: 123, - messageId: 456, - emoji: "ok", - }, - toolContext: undefined, - expectedChannelField: "channelId", - expectedChannelValue: "123", - expectedMessageId: "456", - }, - { - name: "snake_case message_id", - params: { - channelId: 123, - message_id: "456", - emoji: "ok", - }, - toolContext: undefined, - expectedChannelField: "channelId", - expectedChannelValue: "123", - expectedMessageId: "456", - }, - { - name: "toolContext fallback", - params: { - chatId: "123", - emoji: "ok", - }, - toolContext: { currentMessageId: "9001" }, - expectedChannelField: "chatId", - expectedChannelValue: "123", - expectedMessageId: "9001", - }, - { - name: "missing messageId soft-falls through to telegram-actions", - params: { - chatId: "123", - emoji: "ok", - }, - toolContext: undefined, - expectedChannelField: "chatId", - expectedChannelValue: "123", - expectedMessageId: undefined, - }, - ] as const) { - handleTelegramAction.mockClear(); - await expect( - telegramMessageActions.handleAction?.({ - channel: "telegram", - action: "react", - params: testCase.params, - cfg, - accountId: undefined, - toolContext: testCase.toolContext, - }), - ).resolves.toBeDefined(); - - expect(handleTelegramAction, testCase.name).toHaveBeenCalledTimes(1); - const call = handleTelegramAction.mock.calls[0]?.[0]; - if (!call) { - throw new Error("missing telegram action call"); - } - const callPayload = call as Record; - expect(callPayload.action, testCase.name).toBe("react"); - expect(String(callPayload[testCase.expectedChannelField]), testCase.name).toBe( - testCase.expectedChannelValue, - ); - if (testCase.expectedMessageId === undefined) { - expect(callPayload.messageId, testCase.name).toBeUndefined(); - } else { - expect(String(callPayload.messageId), testCase.name).toBe(testCase.expectedMessageId); - } - } - }); -}); - -it("forwards trusted mediaLocalRoots for send actions", async () => { - const cases = [ - { - name: "discord", - run: async () => { - await handleDiscordMessageAction({ - action: "send", - params: { to: "channel:123", message: "hi", media: "/tmp/file.png" }, - cfg: {} as OpenClawConfig, - mediaLocalRoots: ["/tmp/agent-root"], - }); - }, - assert: () => { - expect(handleDiscordAction).toHaveBeenCalledWith( - expect.objectContaining({ - action: "sendMessage", - mediaUrl: "/tmp/file.png", - }), - expect.any(Object), - expect.objectContaining({ mediaLocalRoots: ["/tmp/agent-root"] }), - ); - }, - clear: () => handleDiscordAction.mockClear(), - }, - { - name: "telegram", - run: async () => { - const cfg = telegramCfg(); - await telegramMessageActions.handleAction?.({ - channel: "telegram", - action: "send", - params: { - to: "123", - media: "/tmp/voice.ogg", - }, - cfg, - mediaLocalRoots: ["/tmp/agent-root"], - }); - }, - assert: () => { - expect(handleTelegramAction).toHaveBeenCalledWith( - expect.objectContaining({ - action: "sendMessage", - media: "/tmp/voice.ogg", - }), - expect.any(Object), - expect.objectContaining({ mediaLocalRoots: ["/tmp/agent-root"] }), - ); - }, - clear: () => handleTelegramAction.mockClear(), - }, - ] as const; - - for (const testCase of cases) { - testCase.clear(); - await testCase.run(); - testCase.assert(); - } -}); - -describe("signalMessageActions", () => { - it("lists actions based on account presence and reaction gates", () => { - const cases = [ - { - name: "no configured accounts", - cfg: {} as OpenClawConfig, - expected: [], - }, - { - name: "reactions disabled", - cfg: { - channels: { signal: { account: "+15550001111", actions: { reactions: false } } }, - } as OpenClawConfig, - expected: ["send"], - }, - { - name: "account-level reactions enabled", - cfg: createSignalAccountOverrideCfg(), - expected: ["send", "react"], - }, - ] as const; - - for (const testCase of cases) { - expect( - signalMessageActions.describeMessageTool?.({ cfg: testCase.cfg })?.actions ?? [], - testCase.name, - ).toEqual(testCase.expected); - } - }); - - it("skips send for plugin dispatch", () => { - expect(signalMessageActions.supportsAction?.({ action: "send" })).toBe(false); - expect(signalMessageActions.supportsAction?.({ action: "react" })).toBe(true); - }); - - it("blocks reactions when action gate is disabled", async () => { - const cfg = { - channels: { signal: { account: "+15550001111", actions: { reactions: false } } }, - } as OpenClawConfig; - await expectSignalActionRejected( - { to: "+15550001111", messageId: "123", emoji: "✅" }, - /actions\.reactions/, - cfg, - ); - }); - - it("maps reaction targets into signal sendReaction calls", async () => { - const cases = [ - { - name: "uses account-level actions when enabled", - cfg: createSignalAccountOverrideCfg(), - accountId: "work", - params: { to: "+15550001111", messageId: "123", emoji: "👍" }, - expectedRecipient: "+15550001111", - expectedTimestamp: 123, - expectedEmoji: "👍", - expectedOptions: { accountId: "work" }, - }, - { - name: "normalizes uuid recipients", - cfg: { channels: { signal: { account: "+15550001111" } } } as OpenClawConfig, - accountId: undefined, - params: { - recipient: "uuid:123e4567-e89b-12d3-a456-426614174000", - messageId: "123", - emoji: "🔥", - }, - expectedRecipient: "123e4567-e89b-12d3-a456-426614174000", - expectedTimestamp: 123, - expectedEmoji: "🔥", - expectedOptions: {}, - }, - { - name: "passes groupId and targetAuthor for group reactions", - cfg: { channels: { signal: { account: "+15550001111" } } } as OpenClawConfig, - accountId: undefined, - params: { - to: "signal:group:group-id", - targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000", - messageId: "123", - emoji: "✅", - }, - expectedRecipient: "", - expectedTimestamp: 123, - expectedEmoji: "✅", - expectedOptions: { - groupId: "group-id", - targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000", - }, - toolContext: undefined, - }, - { - name: "falls back to toolContext.currentMessageId when messageId is omitted", - cfg: { channels: { signal: { account: "+15550001111" } } } as OpenClawConfig, - accountId: undefined, - params: { to: "+15559999999", emoji: "🔥" }, - expectedRecipient: "+15559999999", - expectedTimestamp: 1737630212345, - expectedEmoji: "🔥", - expectedOptions: {}, - toolContext: { currentMessageId: "1737630212345" }, - }, - ] as const; - - for (const testCase of cases) { - sendReactionSignal.mockClear(); - await runSignalAction("react", testCase.params, { - cfg: testCase.cfg, - accountId: testCase.accountId, - toolContext: "toolContext" in testCase ? testCase.toolContext : undefined, - }); - expect(sendReactionSignal, testCase.name).toHaveBeenCalledWith( - testCase.expectedRecipient, - testCase.expectedTimestamp, - testCase.expectedEmoji, - expect.objectContaining({ - cfg: testCase.cfg, - ...testCase.expectedOptions, - }), - ); - } - }); - - it("rejects invalid signal reaction inputs before dispatch", async () => { - const cfg = { - channels: { signal: { account: "+15550001111" } }, - } as OpenClawConfig; - for (const testCase of [ - { - params: { to: "+15559999999", emoji: "✅" }, - error: /messageId.*required/, - }, - { - params: { to: "signal:group:group-id", messageId: "123", emoji: "✅" }, - error: /targetAuthor/, - }, - ] as const) { - await expectSignalActionRejected(testCase.params, testCase.error, cfg); - } - }); -}); - -describe("slack actions adapter", () => { - it("forwards slack action params", async () => { - const cases = [ - { - action: "read" as const, - params: { - channelId: "C1", - threadId: "171234.567", - }, - expected: { - action: "readMessages", - channelId: "C1", - threadId: "171234.567", - }, - }, - { - action: "emoji-list" as const, - params: { - limit: "2.9", - }, - expected: { - action: "emojiList", - limit: 2, - }, - }, - { - action: "send" as const, - params: { - to: "channel:C1", - message: "", - blocks: JSON.stringify([{ type: "divider" }]), - }, - expected: { - action: "sendMessage", - to: "channel:C1", - content: "", - blocks: [{ type: "divider" }], - }, - }, - { - action: "send" as const, - params: { - to: "channel:C1", - message: "", - blocks: [{ type: "section", text: { type: "mrkdwn", text: "hi" } }], - }, - expected: { - action: "sendMessage", - to: "channel:C1", - content: "", - blocks: [{ type: "section", text: { type: "mrkdwn", text: "hi" } }], - }, - }, - { - action: "edit" as const, - params: { - channelId: "C1", - messageId: "171234.567", - message: "", - blocks: JSON.stringify([{ type: "divider" }]), - }, - expected: { - action: "editMessage", - channelId: "C1", - messageId: "171234.567", - content: "", - blocks: [{ type: "divider" }], - }, - }, - { - action: "edit" as const, - params: { - channelId: "C1", - messageId: "171234.567", - message: "", - blocks: [{ type: "section", text: { type: "mrkdwn", text: "updated" } }], - }, - expected: { - action: "editMessage", - channelId: "C1", - messageId: "171234.567", - content: "", - blocks: [{ type: "section", text: { type: "mrkdwn", text: "updated" } }], - }, - }, - { - action: "send" as const, - params: { - to: "channel:C1", - message: "", - media: "https://example.com/image.png", - }, - expected: { - action: "sendMessage", - to: "channel:C1", - content: "", - mediaUrl: "https://example.com/image.png", - }, - absentKeys: ["blocks"], - }, - { - action: "upload-file" as const, - params: { - to: "user:U1", - filePath: "/tmp/report.png", - initialComment: "fresh build", - filename: "build.png", - title: "Build Screenshot", - threadId: "171234.567", - }, - expected: { - action: "uploadFile", - to: "user:U1", - filePath: "/tmp/report.png", - initialComment: "fresh build", - filename: "build.png", - title: "Build Screenshot", - threadTs: "171234.567", - }, - }, - { - action: "upload-file" as const, - params: { - to: "channel:C1", - path: "/tmp/path-alias.txt", - message: "path alias", - }, - expected: { - action: "uploadFile", - to: "channel:C1", - filePath: "/tmp/path-alias.txt", - initialComment: "path alias", - }, - }, - ] as const; - - for (const testCase of cases) { - handleSlackAction.mockClear(); - await runSlackAction(testCase.action, testCase.params); - expectFirstSlackAction(testCase.expected); - const [params] = handleSlackAction.mock.calls[0] ?? []; - const absentKeys = "absentKeys" in testCase ? testCase.absentKeys : undefined; - for (const key of absentKeys ?? []) { - expect(params).not.toHaveProperty(key); - } - } - }); - - it("rejects invalid Slack payloads before dispatch", async () => { - const cases = [ - { - name: "invalid JSON", - action: "send" as const, - params: { - to: "channel:C1", - message: "", - blocks: "{bad-json", - }, - error: /blocks must be valid JSON/i, - }, - { - name: "empty blocks", - action: "send" as const, - params: { - to: "channel:C1", - message: "", - blocks: "[]", - }, - error: /at least one block/i, - }, - { - name: "blocks with media", - action: "send" as const, - params: { - to: "channel:C1", - message: "", - media: "https://example.com/image.png", - blocks: JSON.stringify([{ type: "divider" }]), - }, - error: /does not support blocks with media/i, - }, - { - name: "edit missing message and blocks", - action: "edit" as const, - params: { - channelId: "C1", - messageId: "171234.567", - message: "", - }, - error: /edit requires message or blocks/i, - }, - ] as const; - - for (const testCase of cases) { - handleSlackAction.mockClear(); - if (testCase.action === "send") { - await expectSlackSendRejected(testCase.params, testCase.error); - } else { - const { cfg, actions } = slackHarness(); - await expect( - actions.handleAction?.({ - channel: "slack", - action: "edit", - cfg, - params: testCase.params, - }), - ).rejects.toThrow(testCase.error); - } - expect(handleSlackAction, testCase.name).not.toHaveBeenCalled(); - } - }); -}); diff --git a/src/plugins/voice-call.plugin.test.ts b/src/plugins/voice-call.plugin.test.ts deleted file mode 100644 index 184fc27304b..00000000000 --- a/src/plugins/voice-call.plugin.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { Command } from "commander"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -let runtimeStub: { - config: { toNumber?: string }; - manager: { - initiateCall: ReturnType; - continueCall: ReturnType; - speak: ReturnType; - endCall: ReturnType; - getCall: ReturnType; - getCallByProviderCallId: ReturnType; - }; - stop: ReturnType; -}; - -vi.mock("../../extensions/voice-call/runtime-entry.js", () => ({ - createVoiceCallRuntime: vi.fn(async () => runtimeStub), -})); - -import plugin from "../../extensions/voice-call/index.js"; - -const noopLogger = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), -}; - -type Registered = { - methods: Map; - tools: unknown[]; -}; -type RegisterVoiceCall = (api: Record) => void | Promise; -type RegisterCliContext = { - program: Command; - config: Record; - workspaceDir?: string; - logger: typeof noopLogger; -}; - -function captureStdout() { - let output = ""; - const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => { - output += String(chunk); - return true; - }) as typeof process.stdout.write); - return { - output: () => output, - restore: () => writeSpy.mockRestore(), - }; -} -function setup(config: Record): Registered { - const methods = new Map(); - const tools: unknown[] = []; - void plugin.register({ - id: "voice-call", - name: "Voice Call", - description: "test", - version: "0", - source: "test", - config: {}, - pluginConfig: config, - runtime: { tts: { textToSpeechTelephony: vi.fn() } } as unknown as Parameters< - typeof plugin.register - >[0]["runtime"], - logger: noopLogger, - registerGatewayMethod: (method: string, handler: unknown) => methods.set(method, handler), - registerTool: (tool: unknown) => tools.push(tool), - registerCli: () => {}, - registerService: () => {}, - resolvePath: (p: string) => p, - } as unknown as Parameters[0]); - return { methods, tools }; -} - -async function registerVoiceCallCli(program: Command) { - const { register } = plugin as unknown as { - register: RegisterVoiceCall; - }; - await register({ - id: "voice-call", - name: "Voice Call", - description: "test", - version: "0", - source: "test", - config: {}, - pluginConfig: { provider: "mock" }, - runtime: { tts: { textToSpeechTelephony: vi.fn() } }, - logger: noopLogger, - registerGatewayMethod: () => {}, - registerTool: () => {}, - registerCli: (fn: (ctx: RegisterCliContext) => void) => - fn({ - program, - config: {}, - workspaceDir: undefined, - logger: noopLogger, - }), - registerService: () => {}, - resolvePath: (p: string) => p, - }); -} - -describe("voice-call plugin", () => { - beforeEach(() => { - runtimeStub = { - config: { toNumber: "+15550001234" }, - manager: { - initiateCall: vi.fn(async () => ({ callId: "call-1", success: true })), - continueCall: vi.fn(async () => ({ - success: true, - transcript: "hello", - })), - speak: vi.fn(async () => ({ success: true })), - endCall: vi.fn(async () => ({ success: true })), - getCall: vi.fn((id: string) => (id === "call-1" ? { callId: "call-1" } : undefined)), - getCallByProviderCallId: vi.fn(() => undefined), - }, - stop: vi.fn(async () => {}), - }; - }); - - afterEach(() => vi.restoreAllMocks()); - - it("registers gateway methods", () => { - const { methods } = setup({ provider: "mock" }); - expect(methods.has("voicecall.initiate")).toBe(true); - expect(methods.has("voicecall.continue")).toBe(true); - expect(methods.has("voicecall.speak")).toBe(true); - expect(methods.has("voicecall.end")).toBe(true); - expect(methods.has("voicecall.status")).toBe(true); - expect(methods.has("voicecall.start")).toBe(true); - }); - - it("initiates a call via voicecall.initiate", async () => { - const { methods } = setup({ provider: "mock" }); - const handler = methods.get("voicecall.initiate") as - | ((ctx: { - params: Record; - respond: ReturnType; - }) => Promise) - | undefined; - const respond = vi.fn(); - await handler?.({ params: { message: "Hi" }, respond }); - expect(runtimeStub.manager.initiateCall).toHaveBeenCalled(); - const [ok, payload] = respond.mock.calls[0]; - expect(ok).toBe(true); - expect(payload.callId).toBe("call-1"); - }); - - it("returns call status", async () => { - const { methods } = setup({ provider: "mock" }); - const handler = methods.get("voicecall.status") as - | ((ctx: { - params: Record; - respond: ReturnType; - }) => Promise) - | undefined; - const respond = vi.fn(); - await handler?.({ params: { callId: "call-1" }, respond }); - const [ok, payload] = respond.mock.calls[0]; - expect(ok).toBe(true); - expect(payload.found).toBe(true); - }); - - it("tool get_status returns json payload", async () => { - const { tools } = setup({ provider: "mock" }); - const tool = tools[0] as { - execute: (id: string, params: unknown) => Promise; - }; - const result = (await tool.execute("id", { - action: "get_status", - callId: "call-1", - })) as { details: { found?: boolean } }; - expect(result.details.found).toBe(true); - }); - - it("legacy tool status without sid returns error payload", async () => { - const { tools } = setup({ provider: "mock" }); - const tool = tools[0] as { - execute: (id: string, params: unknown) => Promise; - }; - const result = (await tool.execute("id", { mode: "status" })) as { - details: { error?: unknown }; - }; - expect(String(result.details.error)).toContain("sid required"); - }); - - it("CLI latency summarizes turn metrics from JSONL", async () => { - const program = new Command(); - const tmpFile = path.join(os.tmpdir(), `voicecall-latency-${Date.now()}.jsonl`); - fs.writeFileSync( - tmpFile, - [ - JSON.stringify({ metadata: { lastTurnLatencyMs: 100, lastTurnListenWaitMs: 70 } }), - JSON.stringify({ metadata: { lastTurnLatencyMs: 200, lastTurnListenWaitMs: 110 } }), - ].join("\n") + "\n", - "utf8", - ); - - const stdout = captureStdout(); - - try { - await registerVoiceCallCli(program); - - await program.parseAsync(["voicecall", "latency", "--file", tmpFile, "--last", "10"], { - from: "user", - }); - - const printed = stdout.output(); - expect(printed).toContain('"recordsScanned": 2'); - expect(printed).toContain('"p50Ms": 100'); - expect(printed).toContain('"p95Ms": 200'); - } finally { - stdout.restore(); - fs.unlinkSync(tmpFile); - } - }); - - it("CLI start prints JSON", async () => { - const program = new Command(); - const stdout = captureStdout(); - await registerVoiceCallCli(program); - - try { - await program.parseAsync(["voicecall", "start", "--to", "+1", "--message", "Hello"], { - from: "user", - }); - expect(stdout.output()).toContain('"callId": "call-1"'); - } finally { - stdout.restore(); - } - }); -});