import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { jsonResult } from "../../agents/tools/common.js"; import type { ChannelPlugin } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; import { runMessageAction } from "./message-action-runner.js"; function createAlwaysConfiguredPluginConfig(account: Record = { enabled: true }) { return { listAccountIds: () => ["default"], resolveAccount: () => account, isConfigured: () => true, }; } describe("runMessageAction plugin dispatch", () => { describe("media caption behavior", () => { afterEach(() => { setActivePluginRegistry(createTestRegistry([])); }); it("promotes caption to message for media sends when message is empty", async () => { const sendMedia = vi.fn().mockResolvedValue({ channel: "testchat", messageId: "m1", chatId: "c1", }); setActivePluginRegistry( createTestRegistry([ { pluginId: "testchat", source: "test", plugin: createOutboundTestPlugin({ id: "testchat", outbound: { deliveryMode: "direct", sendText: vi.fn().mockResolvedValue({ channel: "testchat", messageId: "t1", chatId: "c1", }), sendMedia, }, }), }, ]), ); const cfg = { channels: { testchat: { enabled: true, }, }, } as OpenClawConfig; const result = await runMessageAction({ cfg, action: "send", params: { channel: "testchat", target: "channel:abc", media: "https://example.com/cat.png", caption: "caption-only text", }, dryRun: false, }); expect(result.kind).toBe("send"); expect(sendMedia).toHaveBeenCalledWith( expect.objectContaining({ text: "caption-only text", mediaUrl: "https://example.com/cat.png", }), ); }); }); describe("card-only send behavior", () => { const handleAction = vi.fn(async ({ params }: { params: Record }) => jsonResult({ ok: true, card: params.card ?? null, message: params.message ?? null, }), ); const cardPlugin: ChannelPlugin = { id: "cardchat", meta: { id: "cardchat", label: "Card Chat", selectionLabel: "Card Chat", docsPath: "/channels/cardchat", blurb: "Card-only send test plugin.", }, capabilities: { chatTypes: ["direct"] }, config: createAlwaysConfiguredPluginConfig(), actions: { listActions: () => ["send"], supportsAction: ({ action }) => action === "send", handleAction, }, }; beforeEach(() => { setActivePluginRegistry( createTestRegistry([ { pluginId: "cardchat", source: "test", plugin: cardPlugin, }, ]), ); handleAction.mockClear(); }); afterEach(() => { setActivePluginRegistry(createTestRegistry([])); vi.clearAllMocks(); }); it("allows card-only sends without text or media", async () => { const cfg = { channels: { cardchat: { enabled: true, }, }, } as OpenClawConfig; const card = { type: "AdaptiveCard", version: "1.4", body: [{ type: "TextBlock", text: "Card-only payload" }], }; const result = await runMessageAction({ cfg, action: "send", params: { channel: "cardchat", target: "channel:test-card", card, }, dryRun: false, }); expect(result.kind).toBe("send"); expect(result.handledBy).toBe("plugin"); expect(handleAction).toHaveBeenCalled(); expect(result.payload).toMatchObject({ ok: true, card, }); }); }); describe("telegram plugin poll forwarding", () => { const handleAction = vi.fn(async ({ params }: { params: Record }) => jsonResult({ ok: true, forwarded: { to: params.to ?? null, pollQuestion: params.pollQuestion ?? null, pollOption: params.pollOption ?? null, pollDurationSeconds: params.pollDurationSeconds ?? null, pollPublic: params.pollPublic ?? null, threadId: params.threadId ?? null, }, }), ); const telegramPollPlugin: ChannelPlugin = { id: "telegram", meta: { id: "telegram", label: "Telegram", selectionLabel: "Telegram", docsPath: "/channels/telegram", blurb: "Telegram poll forwarding test plugin.", }, capabilities: { chatTypes: ["direct"] }, config: createAlwaysConfiguredPluginConfig(), messaging: { targetResolver: { looksLikeId: () => true, }, }, actions: { listActions: () => ["poll"], supportsAction: ({ action }) => action === "poll", handleAction, }, }; beforeEach(() => { setActivePluginRegistry( createTestRegistry([ { pluginId: "telegram", source: "test", plugin: telegramPollPlugin, }, ]), ); handleAction.mockClear(); }); afterEach(() => { setActivePluginRegistry(createTestRegistry([])); vi.clearAllMocks(); }); it("forwards telegram poll params through plugin dispatch", async () => { const result = await runMessageAction({ cfg: { channels: { telegram: { botToken: "tok", }, }, } as OpenClawConfig, action: "poll", params: { channel: "telegram", target: "telegram:123", pollQuestion: "Lunch?", pollOption: ["Pizza", "Sushi"], pollDurationSeconds: 120, pollPublic: true, threadId: "42", }, dryRun: false, }); expect(result.kind).toBe("poll"); expect(result.handledBy).toBe("plugin"); expect(handleAction).toHaveBeenCalledWith( expect.objectContaining({ action: "poll", channel: "telegram", params: expect.objectContaining({ to: "telegram:123", pollQuestion: "Lunch?", pollOption: ["Pizza", "Sushi"], pollDurationSeconds: 120, pollPublic: true, threadId: "42", }), }), ); expect(result.payload).toMatchObject({ ok: true, forwarded: { to: "telegram:123", pollQuestion: "Lunch?", pollOption: ["Pizza", "Sushi"], pollDurationSeconds: 120, pollPublic: true, threadId: "42", }, }); }); }); describe("components parsing", () => { const handleAction = vi.fn(async ({ params }: { params: Record }) => jsonResult({ ok: true, components: params.components ?? null, }), ); const componentsPlugin: ChannelPlugin = { id: "discord", meta: { id: "discord", label: "Discord", selectionLabel: "Discord", docsPath: "/channels/discord", blurb: "Discord components send test plugin.", }, capabilities: { chatTypes: ["direct"] }, config: createAlwaysConfiguredPluginConfig({}), actions: { listActions: () => ["send"], supportsAction: ({ action }) => action === "send", handleAction, }, }; beforeEach(() => { setActivePluginRegistry( createTestRegistry([ { pluginId: "discord", source: "test", plugin: componentsPlugin, }, ]), ); handleAction.mockClear(); }); afterEach(() => { setActivePluginRegistry(createTestRegistry([])); vi.clearAllMocks(); }); it("parses components JSON strings before plugin dispatch", async () => { const components = { text: "hello", buttons: [{ label: "A", customId: "a" }], }; const result = await runMessageAction({ cfg: {} as OpenClawConfig, action: "send", params: { channel: "discord", target: "channel:123", message: "hi", components: JSON.stringify(components), }, dryRun: false, }); expect(result.kind).toBe("send"); expect(handleAction).toHaveBeenCalled(); expect(result.payload).toMatchObject({ ok: true, components }); }); it("throws on invalid components JSON strings", async () => { await expect( runMessageAction({ cfg: {} as OpenClawConfig, action: "send", params: { channel: "discord", target: "channel:123", message: "hi", components: "{not-json}", }, dryRun: false, }), ).rejects.toThrow(/--components must be valid JSON/); expect(handleAction).not.toHaveBeenCalled(); }); }); describe("accountId defaults", () => { const handleAction = vi.fn(async () => jsonResult({ ok: true })); const accountPlugin: ChannelPlugin = { id: "discord", meta: { id: "discord", label: "Discord", selectionLabel: "Discord", docsPath: "/channels/discord", blurb: "Discord test plugin.", }, capabilities: { chatTypes: ["direct"] }, config: { listAccountIds: () => ["default"], resolveAccount: () => ({}), }, actions: { listActions: () => ["send"], handleAction, }, }; beforeEach(() => { setActivePluginRegistry( createTestRegistry([ { pluginId: "discord", source: "test", plugin: accountPlugin, }, ]), ); handleAction.mockClear(); }); afterEach(() => { setActivePluginRegistry(createTestRegistry([])); vi.clearAllMocks(); }); it.each([ { name: "uses defaultAccountId override", args: { cfg: {} as OpenClawConfig, defaultAccountId: "ops", }, expectedAccountId: "ops", }, { name: "falls back to agent binding account", args: { cfg: { bindings: [ { agentId: "agent-b", match: { channel: "discord", accountId: "account-b" } }, ], } as OpenClawConfig, agentId: "agent-b", }, expectedAccountId: "account-b", }, ])("$name", async ({ args, expectedAccountId }) => { await runMessageAction({ ...args, action: "send", params: { channel: "discord", target: "channel:123", message: "hi", }, }); expect(handleAction).toHaveBeenCalled(); const ctx = (handleAction.mock.calls as unknown as Array<[unknown]>)[0]?.[0] as | { accountId?: string | null; params: Record; } | undefined; if (!ctx) { throw new Error("expected action context"); } expect(ctx.accountId).toBe(expectedAccountId); expect(ctx.params.accountId).toBe(expectedAccountId); }); }); });