import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { handleSlackAction, slackActionRuntime } from "./action-runtime.js"; import { parseSlackBlocksInput } from "./blocks-input.js"; const originalSlackActionRuntime = { ...slackActionRuntime }; const deleteSlackMessage = vi.fn(async (..._args: unknown[]) => ({})); const downloadSlackFile = vi.fn(async (..._args: unknown[]) => null); const editSlackMessage = vi.fn(async (..._args: unknown[]) => ({})); const getSlackMemberInfo = vi.fn(async (..._args: unknown[]) => ({})); const listSlackEmojis = vi.fn(async (..._args: unknown[]) => ({})); const listSlackPins = vi.fn(async (..._args: unknown[]) => ({})); const listSlackReactions = vi.fn(async (..._args: unknown[]) => ({})); const pinSlackMessage = vi.fn(async (..._args: unknown[]) => ({})); const reactSlackMessage = vi.fn(async (..._args: unknown[]) => ({})); const readSlackMessages = vi.fn(async (..._args: unknown[]) => ({})); const removeOwnSlackReactions = vi.fn(async (..._args: unknown[]) => ["thumbsup"]); const removeSlackReaction = vi.fn(async (..._args: unknown[]) => ({})); const recordSlackThreadParticipation = vi.fn(); const sendSlackMessage = vi.fn(async (..._args: unknown[]) => ({ channelId: "C123" })); const unpinSlackMessage = vi.fn(async (..._args: unknown[]) => ({})); describe("handleSlackAction", () => { function slackConfig(overrides?: Record): OpenClawConfig { return { channels: { slack: { botToken: "tok", ...overrides, }, }, } as OpenClawConfig; } function createReplyToFirstContext(hasRepliedRef: { value: boolean }) { return { currentChannelId: "C123", currentThreadTs: "1111111111.111111", replyToMode: "first" as const, hasRepliedRef, }; } function createReplyToFirstScenario() { const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; sendSlackMessage.mockClear(); const hasRepliedRef = { value: false }; const context = createReplyToFirstContext(hasRepliedRef); return { cfg, context, hasRepliedRef }; } function expectLastSlackSend(content: string, threadTs?: string) { expect(sendSlackMessage).toHaveBeenLastCalledWith("channel:C123", content, { mediaUrl: undefined, threadTs, blocks: undefined, }); } async function sendSecondMessageAndExpectNoThread(params: { cfg: OpenClawConfig; context: ReturnType; }) { await handleSlackAction( { action: "sendMessage", to: "channel:C123", content: "Second" }, params.cfg, params.context, ); expectLastSlackSend("Second"); } async function resolveReadToken(cfg: OpenClawConfig): Promise { readSlackMessages.mockClear(); readSlackMessages.mockResolvedValueOnce({ messages: [], hasMore: false }); await handleSlackAction({ action: "readMessages", channelId: "C1" }, cfg); const opts = readSlackMessages.mock.calls[0]?.[1] as { token?: string } | undefined; return opts?.token; } async function resolveSendToken(cfg: OpenClawConfig): Promise { sendSlackMessage.mockClear(); await handleSlackAction({ action: "sendMessage", to: "channel:C1", content: "Hello" }, cfg); const opts = sendSlackMessage.mock.calls[0]?.[2] as { token?: string } | undefined; return opts?.token; } beforeEach(() => { vi.clearAllMocks(); Object.assign(slackActionRuntime, originalSlackActionRuntime, { deleteSlackMessage, downloadSlackFile, editSlackMessage, getSlackMemberInfo, listSlackEmojis, listSlackPins, listSlackReactions, parseSlackBlocksInput, pinSlackMessage, reactSlackMessage, readSlackMessages, recordSlackThreadParticipation, removeOwnSlackReactions, removeSlackReaction, sendSlackMessage, unpinSlackMessage, }); }); it.each([ { name: "raw channel id", channelId: "C1" }, { name: "channel: prefixed id", channelId: "channel:C1" }, ])("adds reactions for $name", async ({ channelId }) => { await handleSlackAction( { action: "react", channelId, messageId: "123.456", emoji: "✅", }, slackConfig(), ); expect(reactSlackMessage).toHaveBeenCalledWith("C1", "123.456", "✅"); }); it("removes reactions on empty emoji", async () => { await handleSlackAction( { action: "react", channelId: "C1", messageId: "123.456", emoji: "", }, slackConfig(), ); expect(removeOwnSlackReactions).toHaveBeenCalledWith("C1", "123.456"); }); it("removes reactions when remove flag set", async () => { await handleSlackAction( { action: "react", channelId: "C1", messageId: "123.456", emoji: "✅", remove: true, }, slackConfig(), ); expect(removeSlackReaction).toHaveBeenCalledWith("C1", "123.456", "✅"); }); it("rejects removes without emoji", async () => { await expect( handleSlackAction( { action: "react", channelId: "C1", messageId: "123.456", emoji: "", remove: true, }, slackConfig(), ), ).rejects.toThrow(/Emoji is required/); }); it("respects reaction gating", async () => { await expect( handleSlackAction( { action: "react", channelId: "C1", messageId: "123.456", emoji: "✅", }, slackConfig({ actions: { reactions: false } }), ), ).rejects.toThrow(/Slack reactions are disabled/); }); it("passes threadTs to sendSlackMessage for thread replies", async () => { await handleSlackAction( { action: "sendMessage", to: "channel:C123", content: "Hello thread", threadTs: "1234567890.123456", }, slackConfig(), ); expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "Hello thread", { mediaUrl: undefined, threadTs: "1234567890.123456", blocks: undefined, }); }); it("returns a friendly error when downloadFile cannot fetch the attachment", async () => { downloadSlackFile.mockResolvedValueOnce(null); const result = await handleSlackAction( { action: "downloadFile", fileId: "F123", }, slackConfig(), ); expect(downloadSlackFile).toHaveBeenCalledWith( "F123", expect.objectContaining({ maxBytes: 20 * 1024 * 1024 }), ); expect(result).toEqual( expect.objectContaining({ details: expect.objectContaining({ ok: false }), }), ); }); it("passes download scope (channel/thread) to downloadSlackFile", async () => { downloadSlackFile.mockResolvedValueOnce(null); const result = await handleSlackAction( { action: "downloadFile", fileId: "F123", to: "channel:C1", replyTo: "123.456", }, slackConfig(), ); expect(downloadSlackFile).toHaveBeenCalledWith( "F123", expect.objectContaining({ channelId: "C1", threadId: "123.456", }), ); expect(result).toEqual( expect.objectContaining({ details: expect.objectContaining({ ok: false }), }), ); }); it.each([ { name: "JSON blocks", blocks: JSON.stringify([ { type: "section", text: { type: "mrkdwn", text: "*Deploy* status" } }, ]), expectedBlocks: [{ type: "section", text: { type: "mrkdwn", text: "*Deploy* status" } }], }, { name: "array blocks", blocks: [{ type: "divider" }], expectedBlocks: [{ type: "divider" }], }, ])("accepts $name and allows empty content", async ({ blocks, expectedBlocks }) => { await handleSlackAction( { action: "sendMessage", to: "channel:C123", content: "", blocks, }, slackConfig(), ); expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "", { mediaUrl: undefined, threadTs: undefined, blocks: expectedBlocks, }); }); it.each([ { name: "invalid blocks JSON", blocks: "{not json", expectedError: /blocks must be valid JSON/i, }, { name: "empty blocks arrays", blocks: "[]", expectedError: /at least one block/i }, ])("rejects $name", async ({ blocks, expectedError }) => { await expect( handleSlackAction( { action: "sendMessage", to: "channel:C123", content: "", blocks, }, slackConfig(), ), ).rejects.toThrow(expectedError); }); it("requires at least one of content, blocks, or mediaUrl", async () => { await expect( handleSlackAction( { action: "sendMessage", to: "channel:C123", content: "", }, slackConfig(), ), ).rejects.toThrow(/requires content, blocks, or mediaUrl/i); }); it("rejects blocks combined with mediaUrl", async () => { await expect( handleSlackAction( { action: "sendMessage", to: "channel:C123", content: "hello", mediaUrl: "https://example.com/file.png", blocks: JSON.stringify([{ type: "divider" }]), }, slackConfig(), ), ).rejects.toThrow(/does not support blocks with mediaUrl/i); }); it.each([ { name: "JSON blocks", blocks: JSON.stringify([{ type: "divider" }]), expectedBlocks: [{ type: "divider" }], }, { name: "array blocks", blocks: [{ type: "section", text: { type: "mrkdwn", text: "updated" } }], expectedBlocks: [{ type: "section", text: { type: "mrkdwn", text: "updated" } }], }, ])("passes $name to editSlackMessage", async ({ blocks, expectedBlocks }) => { await handleSlackAction( { action: "editMessage", channelId: "C123", messageId: "123.456", content: "", blocks, }, slackConfig(), ); expect(editSlackMessage).toHaveBeenCalledWith("C123", "123.456", "", { blocks: expectedBlocks, }); }); it("requires content or blocks for editMessage", async () => { await expect( handleSlackAction( { action: "editMessage", channelId: "C123", messageId: "123.456", content: "", }, slackConfig(), ), ).rejects.toThrow(/requires content or blocks/i); }); it("auto-injects threadTs from context when replyToMode=all", async () => { await handleSlackAction( { action: "sendMessage", to: "channel:C123", content: "Threaded reply", }, slackConfig(), { currentChannelId: "C123", currentThreadTs: "1111111111.111111", replyToMode: "all", }, ); expectLastSlackSend("Threaded reply", "1111111111.111111"); }); it("replyToMode=first threads first message then stops", async () => { const { cfg, context } = createReplyToFirstScenario(); await handleSlackAction( { action: "sendMessage", to: "channel:C123", content: "First" }, cfg, context, ); expectLastSlackSend("First", "1111111111.111111"); await sendSecondMessageAndExpectNoThread({ cfg, context }); }); it("replyToMode=first marks hasRepliedRef even when threadTs is explicit", async () => { const { cfg, context, hasRepliedRef } = createReplyToFirstScenario(); await handleSlackAction( { action: "sendMessage", to: "channel:C123", content: "Explicit", threadTs: "9999999999.999999", }, cfg, context, ); expectLastSlackSend("Explicit", "9999999999.999999"); expect(hasRepliedRef.value).toBe(true); await sendSecondMessageAndExpectNoThread({ cfg, context }); }); it("replyToMode=first without hasRepliedRef does not thread", async () => { await handleSlackAction( { action: "sendMessage", to: "channel:C123", content: "No ref" }, slackConfig(), { currentChannelId: "C123", currentThreadTs: "1111111111.111111", replyToMode: "first", }, ); expectLastSlackSend("No ref"); }); it("does not auto-inject threadTs when replyToMode=off", async () => { await handleSlackAction( { action: "sendMessage", to: "channel:C123", content: "No thread" }, slackConfig(), { currentChannelId: "C123", currentThreadTs: "1111111111.111111", replyToMode: "off", }, ); expectLastSlackSend("No thread"); }); it("does not auto-inject threadTs when sending to different channel", async () => { await handleSlackAction( { action: "sendMessage", to: "channel:C999", content: "Other channel" }, slackConfig(), { currentChannelId: "C123", currentThreadTs: "1111111111.111111", replyToMode: "all", }, ); expect(sendSlackMessage).toHaveBeenCalledWith("channel:C999", "Other channel", { mediaUrl: undefined, threadTs: undefined, blocks: undefined, }); }); it("explicit threadTs overrides context threadTs", async () => { await handleSlackAction( { action: "sendMessage", to: "channel:C123", content: "Explicit wins", threadTs: "9999999999.999999", }, slackConfig(), { currentChannelId: "C123", currentThreadTs: "1111111111.111111", replyToMode: "all", }, ); expectLastSlackSend("Explicit wins", "9999999999.999999"); }); it("handles channel target without prefix when replyToMode=all", async () => { await handleSlackAction( { action: "sendMessage", to: "C123", content: "Bare target" }, slackConfig(), { currentChannelId: "C123", currentThreadTs: "1111111111.111111", replyToMode: "all", }, ); expect(sendSlackMessage).toHaveBeenCalledWith("C123", "Bare target", { mediaUrl: undefined, threadTs: "1111111111.111111", blocks: undefined, }); }); it("adds normalized timestamps to readMessages payloads", async () => { readSlackMessages.mockResolvedValueOnce({ messages: [{ ts: "1712345678.123456", text: "hi" }], hasMore: false, }); const result = await handleSlackAction( { action: "readMessages", channelId: "C1" }, slackConfig(), ); expect(result).toMatchObject({ details: { ok: true, hasMore: false, messages: [ expect.objectContaining({ ts: "1712345678.123456", timestampMs: 1712345678123, }), ], }, }); }); it("passes threadId through to readSlackMessages", async () => { readSlackMessages.mockResolvedValueOnce({ messages: [], hasMore: false }); await handleSlackAction( { action: "readMessages", channelId: "C1", threadId: "1712345678.123456" }, slackConfig(), ); expect(readSlackMessages).toHaveBeenCalledWith("C1", { threadId: "1712345678.123456", limit: undefined, before: undefined, after: undefined, }); }); it("adds normalized timestamps to pin payloads", async () => { listSlackPins.mockResolvedValueOnce([{ message: { ts: "1712345678.123456", text: "pin" } }]); const result = await handleSlackAction({ action: "listPins", channelId: "C1" }, slackConfig()); expect(result).toMatchObject({ details: { ok: true, pins: [ { message: expect.objectContaining({ ts: "1712345678.123456", timestampMs: 1712345678123, }), }, ], }, }); }); it("uses user token for reads when available", async () => { const token = await resolveReadToken( slackConfig({ accounts: { default: { botToken: "xoxb-bot", userToken: "xoxp-user", }, }, }), ); expect(token).toBe("xoxp-user"); }); it("falls back to bot token for reads when user token missing", async () => { const token = await resolveReadToken( slackConfig({ accounts: { default: { botToken: "xoxb-bot", }, }, }), ); expect(token).toBeUndefined(); }); it("uses bot token for writes when userTokenReadOnly is true", async () => { const token = await resolveSendToken( slackConfig({ accounts: { default: { botToken: "xoxb-bot", userToken: "xoxp-user", userTokenReadOnly: true, }, }, }), ); expect(token).toBeUndefined(); }); it("allows user token writes when bot token is missing", async () => { const token = await resolveSendToken({ channels: { slack: { accounts: { default: { userToken: "xoxp-user", userTokenReadOnly: false, }, }, }, }, } as OpenClawConfig); expect(token).toBe("xoxp-user"); }); it("returns all emojis when no limit is provided", async () => { listSlackEmojis.mockResolvedValueOnce({ ok: true, emoji: { party: "https://example.com/party.png", wave: "https://example.com/wave.png" }, }); const result = await handleSlackAction({ action: "emojiList" }, slackConfig()); expect(result).toMatchObject({ details: { ok: true, emojis: { emoji: { party: "https://example.com/party.png", wave: "https://example.com/wave.png" }, }, }, }); }); it("applies limit to emoji-list results", async () => { listSlackEmojis.mockResolvedValueOnce({ ok: true, emoji: { wave: "https://example.com/wave.png", party: "https://example.com/party.png", tada: "https://example.com/tada.png", }, }); const result = await handleSlackAction({ action: "emojiList", limit: 2 }, slackConfig()); expect(result).toMatchObject({ details: { ok: true, emojis: { emoji: { party: "https://example.com/party.png", tada: "https://example.com/tada.png", }, }, }, }); }); });