import { describe, expect, it, vi } from "vitest"; import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; import { slackOutbound } from "./outbound-adapter.js"; import type { OpenClawConfig } from "./runtime-api.js"; const handleSlackActionMock = vi.fn(); vi.mock("./runtime.js", () => ({ getSlackRuntime: () => ({ channel: { slack: { handleSlackAction: handleSlackActionMock, }, }, }), })); import { slackPlugin } from "./channel.js"; async function getSlackConfiguredState(cfg: OpenClawConfig) { const account = slackPlugin.config.resolveAccount(cfg, "default"); return { configured: slackPlugin.config.isConfigured?.(account, cfg), snapshot: await slackPlugin.status?.buildAccountSnapshot?.({ account, cfg, runtime: undefined, }), }; } function requireSlackHandleAction() { const handleAction = slackPlugin.actions?.handleAction; if (!handleAction) { throw new Error("slack actions.handleAction unavailable"); } return handleAction; } function requireSlackSendText() { const sendText = slackPlugin.outbound?.sendText; if (!sendText) { throw new Error("slack outbound.sendText unavailable"); } return sendText; } function requireSlackSendMedia() { const sendMedia = slackPlugin.outbound?.sendMedia; if (!sendMedia) { throw new Error("slack outbound.sendMedia unavailable"); } return sendMedia; } function requireSlackSendPayload() { const sendPayload = slackOutbound.sendPayload; if (!sendPayload) { throw new Error("slack outbound.sendPayload unavailable"); } return sendPayload; } function requireSlackListPeers() { const listPeers = slackPlugin.directory?.listPeers; if (!listPeers) { throw new Error("slack directory.listPeers unavailable"); } return listPeers; } describe("slackPlugin actions", () => { it("prefers session lookup for announce target routing", () => { expect(slackPlugin.meta.preferSessionLookupForAnnounceTarget).toBe(true); }); it("owns unified message tool discovery", () => { const discovery = slackPlugin.actions?.describeMessageTool({ cfg: { channels: { slack: { botToken: "xoxb-test", appToken: "xapp-test", capabilities: { interactiveReplies: true }, }, }, }, }); expect(discovery?.actions).toContain("send"); expect(discovery?.capabilities).toEqual(expect.arrayContaining(["blocks", "interactive"])); expect(discovery?.schema).toMatchObject({ properties: { blocks: expect.any(Object), }, }); }); it("forwards read threadId to Slack action handler", async () => { handleSlackActionMock.mockResolvedValueOnce({ messages: [], hasMore: false }); const handleAction = requireSlackHandleAction(); await handleAction({ action: "read", channel: "slack", accountId: "default", cfg: {}, params: { channelId: "C123", threadId: "1712345678.123456", }, }); expect(handleSlackActionMock).toHaveBeenCalledWith( expect.objectContaining({ action: "readMessages", channelId: "C123", threadId: "1712345678.123456", }), {}, undefined, ); }); }); describe("slackPlugin security", () => { it("normalizes dm allowlist entries with trimmed prefixes", () => { const resolveDmPolicy = slackPlugin.security?.resolveDmPolicy; if (!resolveDmPolicy) { throw new Error("resolveDmPolicy unavailable"); } const result = resolveDmPolicy({ cfg: { channels: { slack: { dm: { policy: "allowlist", allowFrom: [" slack:U123 "] }, }, }, } as OpenClawConfig, account: slackPlugin.config.resolveAccount( { channels: { slack: { botToken: "xoxb-test", appToken: "xapp-test", dm: { policy: "allowlist", allowFrom: [" slack:U123 "] }, }, }, } as OpenClawConfig, "default", ), }); if (!result) { throw new Error("slack resolveDmPolicy returned null"); } expect(result.policy).toBe("allowlist"); expect(result.allowFrom).toEqual([" slack:U123 "]); expect(result.normalizeEntry?.(" slack:U123 ")).toBe("U123"); expect(result.normalizeEntry?.(" user:U999 ")).toBe("U999"); }); }); describe("slackPlugin outbound", () => { const cfg = { channels: { slack: { botToken: "xoxb-test", appToken: "xapp-test", }, }, }; it("uses threadId as threadTs fallback for sendText", async () => { const sendSlack = vi.fn().mockResolvedValue({ messageId: "m-text" }); const sendText = requireSlackSendText(); const result = await sendText({ cfg, to: "C123", text: "hello", accountId: "default", threadId: "1712345678.123456", deps: { sendSlack }, }); expect(sendSlack).toHaveBeenCalledWith( "C123", "hello", expect.objectContaining({ threadTs: "1712345678.123456", }), ); expect(result).toEqual({ channel: "slack", messageId: "m-text" }); }); it("prefers replyToId over threadId for sendMedia", async () => { const sendSlack = vi.fn().mockResolvedValue({ messageId: "m-media" }); const sendMedia = requireSlackSendMedia(); const result = await sendMedia({ cfg, to: "C999", text: "caption", mediaUrl: "https://example.com/image.png", accountId: "default", replyToId: "1712000000.000001", threadId: "1712345678.123456", deps: { sendSlack }, }); expect(sendSlack).toHaveBeenCalledWith( "C999", "caption", expect.objectContaining({ mediaUrl: "https://example.com/image.png", threadTs: "1712000000.000001", }), ); expect(result).toEqual({ channel: "slack", messageId: "m-media" }); }); it("forwards mediaLocalRoots for sendMedia", async () => { const sendSlack = vi.fn().mockResolvedValue({ messageId: "m-media-local" }); const sendMedia = requireSlackSendMedia(); const mediaLocalRoots = ["/tmp/workspace"]; const result = await sendMedia({ cfg, to: "C999", text: "caption", mediaUrl: "/tmp/workspace/image.png", mediaLocalRoots, accountId: "default", deps: { sendSlack }, }); expect(sendSlack).toHaveBeenCalledWith( "C999", "caption", expect.objectContaining({ mediaUrl: "/tmp/workspace/image.png", mediaLocalRoots, }), ); expect(result).toEqual({ channel: "slack", messageId: "m-media-local" }); }); it("sends block payload media first, then the final block message", async () => { const sendSlack = vi .fn() .mockResolvedValueOnce({ messageId: "m-media-1" }) .mockResolvedValueOnce({ messageId: "m-media-2" }) .mockResolvedValueOnce({ messageId: "m-final" }); const sendPayload = requireSlackSendPayload(); const result = await sendPayload({ cfg, to: "C999", text: "", payload: { text: "hello", mediaUrls: ["https://example.com/1.png", "https://example.com/2.png"], channelData: { slack: { blocks: [ { type: "section", text: { type: "plain_text", text: "Block body", }, }, ], }, }, }, accountId: "default", deps: { sendSlack }, mediaLocalRoots: ["/tmp/media"], }); expect(sendSlack).toHaveBeenCalledTimes(3); expect(sendSlack).toHaveBeenNthCalledWith( 1, "C999", "", expect.objectContaining({ mediaUrl: "https://example.com/1.png", mediaLocalRoots: ["/tmp/media"], }), ); expect(sendSlack).toHaveBeenNthCalledWith( 2, "C999", "", expect.objectContaining({ mediaUrl: "https://example.com/2.png", mediaLocalRoots: ["/tmp/media"], }), ); expect(sendSlack).toHaveBeenNthCalledWith( 3, "C999", "hello", expect.objectContaining({ blocks: [ { type: "section", text: { type: "plain_text", text: "Block body", }, }, ], }), ); expect(result).toEqual({ channel: "slack", messageId: "m-final" }); }); }); describe("slackPlugin directory", () => { it("lists configured peers without throwing a ReferenceError", async () => { const listPeers = requireSlackListPeers(); await expect( listPeers({ cfg: { channels: { slack: { dms: { U123: {}, }, }, }, }, runtime: createRuntimeEnv(), }), ).resolves.toEqual([{ id: "user:u123", kind: "user" }]); }); }); describe("slackPlugin agentPrompt", () => { it("tells agents interactive replies are disabled by default", () => { const hints = slackPlugin.agentPrompt?.messageToolHints?.({ cfg: { channels: { slack: { botToken: "xoxb-test", appToken: "xapp-test", }, }, }, }); expect(hints).toEqual([ "- Slack interactive replies are disabled. If needed, ask to set `channels.slack.capabilities.interactiveReplies=true` (or the same under `channels.slack.accounts..capabilities`).", ]); }); it("shows Slack interactive reply directives when enabled", () => { const hints = slackPlugin.agentPrompt?.messageToolHints?.({ cfg: { channels: { slack: { botToken: "xoxb-test", appToken: "xapp-test", capabilities: { interactiveReplies: true }, }, }, }, }); expect(hints).toContain( "- Slack interactive replies: use `[[slack_buttons: Label:value, Other:other]]` to add action buttons that route clicks back as Slack interaction system events.", ); expect(hints).toContain( "- Slack selects: use `[[slack_select: Placeholder | Label:value, Other:other]]` to add a static select menu that routes the chosen value back as a Slack interaction system event.", ); }); }); describe("slackPlugin outbound new targets", () => { const cfg = { channels: { slack: { botToken: "xoxb-test", appToken: "xapp-test", }, }, }; it("sends to a new user target via DM without erroring", async () => { const sendSlack = vi.fn().mockResolvedValue({ messageId: "m-new-user", channelId: "D999" }); const sendText = requireSlackSendText(); const result = await sendText({ cfg, to: "user:U99NEW", text: "hello new user", accountId: "default", deps: { sendSlack }, }); expect(sendSlack).toHaveBeenCalledWith( "user:U99NEW", "hello new user", expect.objectContaining({ cfg }), ); expect(result).toEqual({ channel: "slack", messageId: "m-new-user", channelId: "D999" }); }); it("sends to a new channel target without erroring", async () => { const sendSlack = vi.fn().mockResolvedValue({ messageId: "m-new-chan", channelId: "C555" }); const sendText = requireSlackSendText(); const result = await sendText({ cfg, to: "channel:C555NEW", text: "hello channel", accountId: "default", deps: { sendSlack }, }); expect(sendSlack).toHaveBeenCalledWith( "channel:C555NEW", "hello channel", expect.objectContaining({ cfg }), ); expect(result).toEqual({ channel: "slack", messageId: "m-new-chan", channelId: "C555" }); }); it("sends media to a new user target without erroring", async () => { const sendSlack = vi.fn().mockResolvedValue({ messageId: "m-new-media", channelId: "D888" }); const sendMedia = requireSlackSendMedia(); const result = await sendMedia({ cfg, to: "user:U88NEW", text: "here is a file", mediaUrl: "https://example.com/file.png", accountId: "default", deps: { sendSlack }, }); expect(sendSlack).toHaveBeenCalledWith( "user:U88NEW", "here is a file", expect.objectContaining({ cfg, mediaUrl: "https://example.com/file.png", }), ); expect(result).toEqual({ channel: "slack", messageId: "m-new-media", channelId: "D888" }); }); }); describe("slackPlugin config", () => { it("treats HTTP mode accounts with bot token + signing secret as configured", async () => { const cfg: OpenClawConfig = { channels: { slack: { mode: "http", botToken: "xoxb-http", signingSecret: "secret-http", // pragma: allowlist secret }, }, }; const { configured, snapshot } = await getSlackConfiguredState(cfg); expect(configured).toBe(true); expect(snapshot?.configured).toBe(true); }); it("keeps socket mode requiring app token", async () => { const cfg: OpenClawConfig = { channels: { slack: { mode: "socket", botToken: "xoxb-socket", }, }, }; const { configured, snapshot } = await getSlackConfiguredState(cfg); expect(configured).toBe(false); expect(snapshot?.configured).toBe(false); }); it("does not mark partial configured-unavailable token status as configured", async () => { const snapshot = await slackPlugin.status?.buildAccountSnapshot?.({ account: { accountId: "default", name: "Default", enabled: true, configured: false, botTokenStatus: "configured_unavailable", appTokenStatus: "missing", botTokenSource: "config", appTokenSource: "none", config: {}, } as never, cfg: {} as OpenClawConfig, runtime: undefined, }); expect(snapshot?.configured).toBe(false); expect(snapshot?.botTokenStatus).toBe("configured_unavailable"); expect(snapshot?.appTokenStatus).toBe("missing"); }); it("keeps HTTP mode signing-secret unavailable accounts configured in snapshots", async () => { const snapshot = await slackPlugin.status?.buildAccountSnapshot?.({ account: { accountId: "default", name: "Default", enabled: true, configured: true, mode: "http", botTokenStatus: "available", signingSecretStatus: "configured_unavailable", // pragma: allowlist secret botTokenSource: "config", signingSecretSource: "config", // pragma: allowlist secret config: { mode: "http", botToken: "xoxb-http", signingSecret: { source: "env", provider: "default", id: "SLACK_SIGNING_SECRET" }, }, } as never, cfg: {} as OpenClawConfig, runtime: undefined, }); expect(snapshot?.configured).toBe(true); expect(snapshot?.botTokenStatus).toBe("available"); expect(snapshot?.signingSecretStatus).toBe("configured_unavailable"); }); });