2026-02-21 23:41:40 +00:00
|
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
2026-03-18 02:07:26 +00:00
|
|
|
import type { OpenClawConfig } from "../../../src/config/config.js";
|
|
|
|
|
import { handleSlackAction, slackActionRuntime } from "./action-runtime.js";
|
|
|
|
|
import { parseSlackBlocksInput } from "./blocks-input.js";
|
2026-01-07 04:10:13 +01:00
|
|
|
|
2026-03-18 02:07:26 +00:00
|
|
|
const originalSlackActionRuntime = { ...slackActionRuntime };
|
2026-02-17 10:51:25 +09:00
|
|
|
const deleteSlackMessage = vi.fn(async (..._args: unknown[]) => ({}));
|
2026-03-01 18:45:05 +01:00
|
|
|
const downloadSlackFile = vi.fn(async (..._args: unknown[]) => null);
|
2026-02-17 10:51:25 +09:00
|
|
|
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[]) => ({}));
|
2026-03-18 02:07:26 +00:00
|
|
|
const recordSlackThreadParticipation = vi.fn();
|
|
|
|
|
const sendSlackMessage = vi.fn(async (..._args: unknown[]) => ({ channelId: "C123" }));
|
2026-02-17 10:51:25 +09:00
|
|
|
const unpinSlackMessage = vi.fn(async (..._args: unknown[]) => ({}));
|
2026-01-07 04:10:13 +01:00
|
|
|
|
|
|
|
|
describe("handleSlackAction", () => {
|
2026-02-21 23:41:40 +00:00
|
|
|
function slackConfig(overrides?: Record<string, unknown>): OpenClawConfig {
|
|
|
|
|
return {
|
|
|
|
|
channels: {
|
|
|
|
|
slack: {
|
|
|
|
|
botToken: "tok",
|
|
|
|
|
...overrides,
|
|
|
|
|
},
|
2026-01-07 04:10:13 +01:00
|
|
|
},
|
2026-02-21 23:41:40 +00:00
|
|
|
} as OpenClawConfig;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:11:17 +00:00
|
|
|
function createReplyToFirstContext(hasRepliedRef: { value: boolean }) {
|
|
|
|
|
return {
|
|
|
|
|
currentChannelId: "C123",
|
|
|
|
|
currentThreadTs: "1111111111.111111",
|
|
|
|
|
replyToMode: "first" as const,
|
|
|
|
|
hasRepliedRef,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 20:01:43 +00:00
|
|
|
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<typeof createReplyToFirstContext>;
|
|
|
|
|
}) {
|
|
|
|
|
await handleSlackAction(
|
|
|
|
|
{ action: "sendMessage", to: "channel:C123", content: "Second" },
|
|
|
|
|
params.cfg,
|
|
|
|
|
params.context,
|
|
|
|
|
);
|
|
|
|
|
expectLastSlackSend("Second");
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:11:17 +00:00
|
|
|
async function resolveReadToken(cfg: OpenClawConfig): Promise<string | undefined> {
|
|
|
|
|
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<string | undefined> {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 23:41:40 +00:00
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.clearAllMocks();
|
2026-03-18 02:07:26 +00:00
|
|
|
Object.assign(slackActionRuntime, originalSlackActionRuntime, {
|
|
|
|
|
deleteSlackMessage,
|
|
|
|
|
downloadSlackFile,
|
|
|
|
|
editSlackMessage,
|
|
|
|
|
getSlackMemberInfo,
|
|
|
|
|
listSlackEmojis,
|
|
|
|
|
listSlackPins,
|
|
|
|
|
listSlackReactions,
|
|
|
|
|
parseSlackBlocksInput,
|
|
|
|
|
pinSlackMessage,
|
|
|
|
|
reactSlackMessage,
|
|
|
|
|
readSlackMessages,
|
|
|
|
|
recordSlackThreadParticipation,
|
|
|
|
|
removeOwnSlackReactions,
|
|
|
|
|
removeSlackReaction,
|
|
|
|
|
sendSlackMessage,
|
|
|
|
|
unpinSlackMessage,
|
|
|
|
|
});
|
2026-01-07 04:10:13 +01:00
|
|
|
});
|
|
|
|
|
|
2026-02-21 23:41:40 +00:00
|
|
|
it.each([
|
|
|
|
|
{ name: "raw channel id", channelId: "C1" },
|
|
|
|
|
{ name: "channel: prefixed id", channelId: "channel:C1" },
|
|
|
|
|
])("adds reactions for $name", async ({ channelId }) => {
|
2026-01-18 00:15:02 +00:00
|
|
|
await handleSlackAction(
|
|
|
|
|
{
|
|
|
|
|
action: "react",
|
2026-02-21 23:41:40 +00:00
|
|
|
channelId,
|
2026-01-18 00:15:02 +00:00
|
|
|
messageId: "123.456",
|
|
|
|
|
emoji: "✅",
|
|
|
|
|
},
|
2026-02-21 23:41:40 +00:00
|
|
|
slackConfig(),
|
2026-01-18 00:15:02 +00:00
|
|
|
);
|
|
|
|
|
expect(reactSlackMessage).toHaveBeenCalledWith("C1", "123.456", "✅");
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-07 04:10:13 +01:00
|
|
|
it("removes reactions on empty emoji", async () => {
|
|
|
|
|
await handleSlackAction(
|
|
|
|
|
{
|
|
|
|
|
action: "react",
|
|
|
|
|
channelId: "C1",
|
|
|
|
|
messageId: "123.456",
|
|
|
|
|
emoji: "",
|
|
|
|
|
},
|
2026-02-21 23:41:40 +00:00
|
|
|
slackConfig(),
|
2026-01-07 04:10:13 +01:00
|
|
|
);
|
|
|
|
|
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,
|
|
|
|
|
},
|
2026-02-21 23:41:40 +00:00
|
|
|
slackConfig(),
|
2026-01-07 04:10:13 +01:00
|
|
|
);
|
|
|
|
|
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,
|
|
|
|
|
},
|
2026-02-21 23:41:40 +00:00
|
|
|
slackConfig(),
|
2026-01-07 04:10:13 +01:00
|
|
|
),
|
|
|
|
|
).rejects.toThrow(/Emoji is required/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("respects reaction gating", async () => {
|
|
|
|
|
await expect(
|
|
|
|
|
handleSlackAction(
|
|
|
|
|
{
|
|
|
|
|
action: "react",
|
|
|
|
|
channelId: "C1",
|
|
|
|
|
messageId: "123.456",
|
|
|
|
|
emoji: "✅",
|
|
|
|
|
},
|
2026-02-21 23:41:40 +00:00
|
|
|
slackConfig({ actions: { reactions: false } }),
|
2026-01-07 04:10:13 +01:00
|
|
|
),
|
|
|
|
|
).rejects.toThrow(/Slack reactions are disabled/);
|
|
|
|
|
});
|
2026-01-07 23:30:30 -08:00
|
|
|
|
|
|
|
|
it("passes threadTs to sendSlackMessage for thread replies", async () => {
|
|
|
|
|
await handleSlackAction(
|
|
|
|
|
{
|
|
|
|
|
action: "sendMessage",
|
|
|
|
|
to: "channel:C123",
|
|
|
|
|
content: "Hello thread",
|
|
|
|
|
threadTs: "1234567890.123456",
|
|
|
|
|
},
|
2026-02-21 23:41:40 +00:00
|
|
|
slackConfig(),
|
2026-01-07 23:30:30 -08:00
|
|
|
);
|
2026-01-14 14:31:43 +00:00
|
|
|
expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "Hello thread", {
|
|
|
|
|
mediaUrl: undefined,
|
|
|
|
|
threadTs: "1234567890.123456",
|
2026-02-16 12:04:18 -05:00
|
|
|
blocks: undefined,
|
2026-01-14 14:31:43 +00:00
|
|
|
});
|
2026-01-08 16:04:52 -08:00
|
|
|
});
|
|
|
|
|
|
2026-03-01 18:45:05 +01:00
|
|
|
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 }),
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-02 02:23:12 +00:00
|
|
|
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 }),
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-21 23:41:40 +00:00
|
|
|
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 }) => {
|
2026-02-16 12:04:18 -05:00
|
|
|
await handleSlackAction(
|
|
|
|
|
{
|
|
|
|
|
action: "sendMessage",
|
|
|
|
|
to: "channel:C123",
|
2026-03-18 02:07:26 +00:00
|
|
|
content: "",
|
2026-02-21 23:41:40 +00:00
|
|
|
blocks,
|
2026-02-16 12:04:18 -05:00
|
|
|
},
|
2026-02-21 23:41:40 +00:00
|
|
|
slackConfig(),
|
2026-02-16 12:04:18 -05:00
|
|
|
);
|
|
|
|
|
expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "", {
|
|
|
|
|
mediaUrl: undefined,
|
|
|
|
|
threadTs: undefined,
|
2026-02-21 23:41:40 +00:00
|
|
|
blocks: expectedBlocks,
|
2026-02-16 12:04:18 -05:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-21 23:41:40 +00:00
|
|
|
it.each([
|
|
|
|
|
{
|
|
|
|
|
name: "invalid blocks JSON",
|
2026-03-18 02:07:26 +00:00
|
|
|
blocks: "{not json",
|
2026-02-21 23:41:40 +00:00
|
|
|
expectedError: /blocks must be valid JSON/i,
|
|
|
|
|
},
|
|
|
|
|
{ name: "empty blocks arrays", blocks: "[]", expectedError: /at least one block/i },
|
|
|
|
|
])("rejects $name", async ({ blocks, expectedError }) => {
|
2026-02-16 12:30:33 -05:00
|
|
|
await expect(
|
|
|
|
|
handleSlackAction(
|
|
|
|
|
{
|
|
|
|
|
action: "sendMessage",
|
|
|
|
|
to: "channel:C123",
|
2026-03-18 02:07:26 +00:00
|
|
|
content: "",
|
2026-02-21 23:41:40 +00:00
|
|
|
blocks,
|
2026-02-16 12:30:33 -05:00
|
|
|
},
|
2026-02-21 23:41:40 +00:00
|
|
|
slackConfig(),
|
2026-02-16 12:30:33 -05:00
|
|
|
),
|
2026-02-21 23:41:40 +00:00
|
|
|
).rejects.toThrow(expectedError);
|
2026-02-16 12:30:33 -05:00
|
|
|
});
|
|
|
|
|
|
2026-02-16 12:04:18 -05:00
|
|
|
it("requires at least one of content, blocks, or mediaUrl", async () => {
|
|
|
|
|
await expect(
|
|
|
|
|
handleSlackAction(
|
|
|
|
|
{
|
|
|
|
|
action: "sendMessage",
|
|
|
|
|
to: "channel:C123",
|
|
|
|
|
content: "",
|
|
|
|
|
},
|
2026-02-21 23:41:40 +00:00
|
|
|
slackConfig(),
|
2026-02-16 12:04:18 -05:00
|
|
|
),
|
|
|
|
|
).rejects.toThrow(/requires content, blocks, or mediaUrl/i);
|
|
|
|
|
});
|
2026-02-16 12:08:58 -05:00
|
|
|
|
2026-02-16 12:33:43 -05:00
|
|
|
it("rejects blocks combined with mediaUrl", async () => {
|
|
|
|
|
await expect(
|
|
|
|
|
handleSlackAction(
|
|
|
|
|
{
|
|
|
|
|
action: "sendMessage",
|
|
|
|
|
to: "channel:C123",
|
2026-03-18 02:07:26 +00:00
|
|
|
content: "hello",
|
|
|
|
|
mediaUrl: "https://example.com/file.png",
|
|
|
|
|
blocks: JSON.stringify([{ type: "divider" }]),
|
2026-02-16 12:33:43 -05:00
|
|
|
},
|
2026-02-21 23:41:40 +00:00
|
|
|
slackConfig(),
|
2026-02-16 12:33:43 -05:00
|
|
|
),
|
|
|
|
|
).rejects.toThrow(/does not support blocks with mediaUrl/i);
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-21 23:41:40 +00:00
|
|
|
it.each([
|
|
|
|
|
{
|
|
|
|
|
name: "JSON blocks",
|
2026-03-18 02:07:26 +00:00
|
|
|
blocks: JSON.stringify([{ type: "divider" }]),
|
|
|
|
|
expectedBlocks: [{ type: "divider" }],
|
2026-02-21 23:41:40 +00:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "array blocks",
|
2026-03-18 02:07:26 +00:00
|
|
|
blocks: [{ type: "section", text: { type: "mrkdwn", text: "updated" } }],
|
|
|
|
|
expectedBlocks: [{ type: "section", text: { type: "mrkdwn", text: "updated" } }],
|
2026-02-21 23:41:40 +00:00
|
|
|
},
|
|
|
|
|
])("passes $name to editSlackMessage", async ({ blocks, expectedBlocks }) => {
|
2026-02-16 12:08:58 -05:00
|
|
|
await handleSlackAction(
|
|
|
|
|
{
|
|
|
|
|
action: "editMessage",
|
|
|
|
|
channelId: "C123",
|
|
|
|
|
messageId: "123.456",
|
2026-03-18 02:07:26 +00:00
|
|
|
content: "",
|
2026-02-21 23:41:40 +00:00
|
|
|
blocks,
|
2026-02-16 12:08:58 -05:00
|
|
|
},
|
2026-02-21 23:41:40 +00:00
|
|
|
slackConfig(),
|
2026-02-16 12:08:58 -05:00
|
|
|
);
|
|
|
|
|
expect(editSlackMessage).toHaveBeenCalledWith("C123", "123.456", "", {
|
2026-02-21 23:41:40 +00:00
|
|
|
blocks: expectedBlocks,
|
2026-02-16 12:08:58 -05:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("requires content or blocks for editMessage", async () => {
|
|
|
|
|
await expect(
|
|
|
|
|
handleSlackAction(
|
|
|
|
|
{
|
|
|
|
|
action: "editMessage",
|
|
|
|
|
channelId: "C123",
|
|
|
|
|
messageId: "123.456",
|
|
|
|
|
content: "",
|
|
|
|
|
},
|
2026-02-21 23:41:40 +00:00
|
|
|
slackConfig(),
|
2026-02-16 12:08:58 -05:00
|
|
|
),
|
|
|
|
|
).rejects.toThrow(/requires content or blocks/i);
|
|
|
|
|
});
|
2026-02-16 12:04:18 -05:00
|
|
|
|
2026-01-08 16:04:52 -08:00
|
|
|
it("auto-injects threadTs from context when replyToMode=all", async () => {
|
|
|
|
|
await handleSlackAction(
|
|
|
|
|
{
|
|
|
|
|
action: "sendMessage",
|
|
|
|
|
to: "channel:C123",
|
2026-03-18 02:07:26 +00:00
|
|
|
content: "Threaded reply",
|
2026-01-08 16:04:52 -08:00
|
|
|
},
|
2026-03-18 02:07:26 +00:00
|
|
|
slackConfig(),
|
2026-01-08 16:04:52 -08:00
|
|
|
{
|
|
|
|
|
currentChannelId: "C123",
|
|
|
|
|
currentThreadTs: "1111111111.111111",
|
|
|
|
|
replyToMode: "all",
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-03-18 02:07:26 +00:00
|
|
|
expectLastSlackSend("Threaded reply", "1111111111.111111");
|
2026-01-08 16:04:52 -08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("replyToMode=first threads first message then stops", async () => {
|
2026-03-18 02:07:26 +00:00
|
|
|
const { cfg, context } = createReplyToFirstScenario();
|
2026-01-08 16:04:52 -08:00
|
|
|
|
|
|
|
|
await handleSlackAction(
|
|
|
|
|
{ action: "sendMessage", to: "channel:C123", content: "First" },
|
|
|
|
|
cfg,
|
|
|
|
|
context,
|
|
|
|
|
);
|
|
|
|
|
|
2026-03-18 02:07:26 +00:00
|
|
|
expectLastSlackSend("First", "1111111111.111111");
|
2026-02-22 20:01:43 +00:00
|
|
|
await sendSecondMessageAndExpectNoThread({ cfg, context });
|
2026-01-08 16:04:52 -08:00
|
|
|
});
|
|
|
|
|
|
2026-01-09 22:09:02 +01:00
|
|
|
it("replyToMode=first marks hasRepliedRef even when threadTs is explicit", async () => {
|
2026-02-22 20:01:43 +00:00
|
|
|
const { cfg, context, hasRepliedRef } = createReplyToFirstScenario();
|
2026-01-09 22:09:02 +01:00
|
|
|
|
|
|
|
|
await handleSlackAction(
|
|
|
|
|
{
|
|
|
|
|
action: "sendMessage",
|
|
|
|
|
to: "channel:C123",
|
|
|
|
|
content: "Explicit",
|
2026-03-18 02:07:26 +00:00
|
|
|
threadTs: "9999999999.999999",
|
2026-01-09 22:09:02 +01:00
|
|
|
},
|
|
|
|
|
cfg,
|
|
|
|
|
context,
|
|
|
|
|
);
|
|
|
|
|
|
2026-03-18 02:07:26 +00:00
|
|
|
expectLastSlackSend("Explicit", "9999999999.999999");
|
|
|
|
|
expect(hasRepliedRef.value).toBe(true);
|
2026-02-22 20:01:43 +00:00
|
|
|
await sendSecondMessageAndExpectNoThread({ cfg, context });
|
2026-01-09 22:09:02 +01:00
|
|
|
});
|
|
|
|
|
|
2026-01-08 16:04:52 -08:00
|
|
|
it("replyToMode=first without hasRepliedRef does not thread", async () => {
|
2026-03-18 02:07:26 +00:00
|
|
|
await handleSlackAction(
|
|
|
|
|
{ action: "sendMessage", to: "channel:C123", content: "No ref" },
|
|
|
|
|
slackConfig(),
|
|
|
|
|
{
|
|
|
|
|
currentChannelId: "C123",
|
|
|
|
|
currentThreadTs: "1111111111.111111",
|
|
|
|
|
replyToMode: "first",
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
expectLastSlackSend("No ref");
|
2026-01-08 16:04:52 -08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not auto-inject threadTs when replyToMode=off", async () => {
|
|
|
|
|
await handleSlackAction(
|
2026-03-18 02:07:26 +00:00
|
|
|
{ action: "sendMessage", to: "channel:C123", content: "No thread" },
|
|
|
|
|
slackConfig(),
|
2026-01-08 16:04:52 -08:00
|
|
|
{
|
|
|
|
|
currentChannelId: "C123",
|
|
|
|
|
currentThreadTs: "1111111111.111111",
|
|
|
|
|
replyToMode: "off",
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-03-18 02:07:26 +00:00
|
|
|
expectLastSlackSend("No thread");
|
2026-01-08 16:04:52 -08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not auto-inject threadTs when sending to different channel", async () => {
|
|
|
|
|
await handleSlackAction(
|
2026-03-18 02:07:26 +00:00
|
|
|
{ action: "sendMessage", to: "channel:C999", content: "Other channel" },
|
|
|
|
|
slackConfig(),
|
2026-01-08 16:04:52 -08:00
|
|
|
{
|
|
|
|
|
currentChannelId: "C123",
|
|
|
|
|
currentThreadTs: "1111111111.111111",
|
|
|
|
|
replyToMode: "all",
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-03-18 02:07:26 +00:00
|
|
|
expect(sendSlackMessage).toHaveBeenCalledWith("channel:C999", "Other channel", {
|
2026-01-14 14:31:43 +00:00
|
|
|
mediaUrl: undefined,
|
|
|
|
|
threadTs: undefined,
|
2026-02-16 12:04:18 -05:00
|
|
|
blocks: undefined,
|
2026-01-14 14:31:43 +00:00
|
|
|
});
|
2026-01-08 16:04:52 -08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("explicit threadTs overrides context threadTs", async () => {
|
|
|
|
|
await handleSlackAction(
|
|
|
|
|
{
|
|
|
|
|
action: "sendMessage",
|
|
|
|
|
to: "channel:C123",
|
2026-03-18 02:07:26 +00:00
|
|
|
content: "Explicit wins",
|
|
|
|
|
threadTs: "9999999999.999999",
|
2026-01-08 16:04:52 -08:00
|
|
|
},
|
2026-03-18 02:07:26 +00:00
|
|
|
slackConfig(),
|
2026-01-08 16:04:52 -08:00
|
|
|
{
|
|
|
|
|
currentChannelId: "C123",
|
|
|
|
|
currentThreadTs: "1111111111.111111",
|
|
|
|
|
replyToMode: "all",
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-03-18 02:07:26 +00:00
|
|
|
expectLastSlackSend("Explicit wins", "9999999999.999999");
|
2026-01-08 16:04:52 -08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles channel target without prefix when replyToMode=all", async () => {
|
|
|
|
|
await handleSlackAction(
|
2026-03-18 02:07:26 +00:00
|
|
|
{ action: "sendMessage", to: "C123", content: "Bare target" },
|
|
|
|
|
slackConfig(),
|
2026-01-08 16:04:52 -08:00
|
|
|
{
|
|
|
|
|
currentChannelId: "C123",
|
|
|
|
|
currentThreadTs: "1111111111.111111",
|
|
|
|
|
replyToMode: "all",
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-03-18 02:07:26 +00:00
|
|
|
expect(sendSlackMessage).toHaveBeenCalledWith("C123", "Bare target", {
|
2026-01-07 23:30:30 -08:00
|
|
|
mediaUrl: undefined,
|
2026-01-08 16:04:52 -08:00
|
|
|
threadTs: "1111111111.111111",
|
2026-02-16 12:04:18 -05:00
|
|
|
blocks: undefined,
|
2026-01-07 23:30:30 -08:00
|
|
|
});
|
|
|
|
|
});
|
2026-01-15 22:26:31 +00:00
|
|
|
|
|
|
|
|
it("adds normalized timestamps to readMessages payloads", async () => {
|
|
|
|
|
readSlackMessages.mockResolvedValueOnce({
|
2026-03-18 02:07:26 +00:00
|
|
|
messages: [{ ts: "1712345678.123456", text: "hi" }],
|
2026-01-15 22:26:31 +00:00
|
|
|
hasMore: false,
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-18 02:07:26 +00:00
|
|
|
const result = await handleSlackAction(
|
|
|
|
|
{ action: "readMessages", channelId: "C1" },
|
|
|
|
|
slackConfig(),
|
|
|
|
|
);
|
2026-01-15 22:26:31 +00:00
|
|
|
|
2026-03-18 02:07:26 +00:00
|
|
|
expect(result).toMatchObject({
|
|
|
|
|
details: {
|
|
|
|
|
ok: true,
|
|
|
|
|
hasMore: false,
|
|
|
|
|
messages: [
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
ts: "1712345678.123456",
|
|
|
|
|
timestampMs: 1712345678123,
|
|
|
|
|
}),
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-01-15 22:26:31 +00:00
|
|
|
});
|
|
|
|
|
|
2026-01-23 01:17:45 -03:00
|
|
|
it("passes threadId through to readSlackMessages", async () => {
|
|
|
|
|
readSlackMessages.mockResolvedValueOnce({ messages: [], hasMore: false });
|
|
|
|
|
|
|
|
|
|
await handleSlackAction(
|
2026-03-18 02:07:26 +00:00
|
|
|
{ action: "readMessages", channelId: "C1", threadId: "1712345678.123456" },
|
|
|
|
|
slackConfig(),
|
2026-01-23 01:17:45 -03:00
|
|
|
);
|
|
|
|
|
|
2026-03-18 02:07:26 +00:00
|
|
|
expect(readSlackMessages).toHaveBeenCalledWith("C1", {
|
|
|
|
|
threadId: "1712345678.123456",
|
|
|
|
|
limit: undefined,
|
|
|
|
|
before: undefined,
|
|
|
|
|
after: undefined,
|
|
|
|
|
});
|
2026-01-23 01:17:45 -03:00
|
|
|
});
|
|
|
|
|
|
2026-01-15 22:26:31 +00:00
|
|
|
it("adds normalized timestamps to pin payloads", async () => {
|
2026-03-18 02:07:26 +00:00
|
|
|
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,
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
],
|
2026-01-15 22:26:31 +00:00
|
|
|
},
|
2026-03-18 02:07:26 +00:00
|
|
|
});
|
2026-01-15 22:26:31 +00:00
|
|
|
});
|
feat(slack): add userToken for read-only access to DMs and private channels (#981)
- Add userToken and userTokenReadOnly (default: true) config fields
- Implement token routing: reads prefer user token, writes use bot token
- Add tests for token routing logic
- Update documentation with required OAuth scopes
User tokens enable reading DMs and private channels without requiring
bot membership. The userTokenReadOnly flag (true by default) ensures
the user token can only be used for reads, preventing accidental
sends as the user.
Required user token scopes:
- channels:history, channels:read
- groups:history, groups:read
- im:history, im:read
- mpim:history, mpim:read
- users:read, reactions:read, pins:read, emoji:read, search:read
2026-01-15 16:11:33 -08:00
|
|
|
|
|
|
|
|
it("uses user token for reads when available", async () => {
|
2026-03-18 02:07:26 +00:00
|
|
|
const token = await resolveReadToken(
|
|
|
|
|
slackConfig({
|
|
|
|
|
accounts: {
|
|
|
|
|
default: {
|
|
|
|
|
botToken: "xoxb-bot",
|
|
|
|
|
userToken: "xoxp-user",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
expect(token).toBe("xoxp-user");
|
feat(slack): add userToken for read-only access to DMs and private channels (#981)
- Add userToken and userTokenReadOnly (default: true) config fields
- Implement token routing: reads prefer user token, writes use bot token
- Add tests for token routing logic
- Update documentation with required OAuth scopes
User tokens enable reading DMs and private channels without requiring
bot membership. The userTokenReadOnly flag (true by default) ensures
the user token can only be used for reads, preventing accidental
sends as the user.
Required user token scopes:
- channels:history, channels:read
- groups:history, groups:read
- im:history, im:read
- mpim:history, mpim:read
- users:read, reactions:read, pins:read, emoji:read, search:read
2026-01-15 16:11:33 -08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("falls back to bot token for reads when user token missing", async () => {
|
2026-03-18 02:07:26 +00:00
|
|
|
const token = await resolveReadToken(
|
|
|
|
|
slackConfig({
|
|
|
|
|
accounts: {
|
|
|
|
|
default: {
|
|
|
|
|
botToken: "xoxb-bot",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
expect(token).toBeUndefined();
|
feat(slack): add userToken for read-only access to DMs and private channels (#981)
- Add userToken and userTokenReadOnly (default: true) config fields
- Implement token routing: reads prefer user token, writes use bot token
- Add tests for token routing logic
- Update documentation with required OAuth scopes
User tokens enable reading DMs and private channels without requiring
bot membership. The userTokenReadOnly flag (true by default) ensures
the user token can only be used for reads, preventing accidental
sends as the user.
Required user token scopes:
- channels:history, channels:read
- groups:history, groups:read
- im:history, im:read
- mpim:history, mpim:read
- users:read, reactions:read, pins:read, emoji:read, search:read
2026-01-15 16:11:33 -08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("uses bot token for writes when userTokenReadOnly is true", async () => {
|
2026-03-18 02:07:26 +00:00
|
|
|
const token = await resolveSendToken(
|
|
|
|
|
slackConfig({
|
|
|
|
|
accounts: {
|
|
|
|
|
default: {
|
|
|
|
|
botToken: "xoxb-bot",
|
|
|
|
|
userToken: "xoxp-user",
|
|
|
|
|
userTokenReadOnly: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
expect(token).toBeUndefined();
|
feat(slack): add userToken for read-only access to DMs and private channels (#981)
- Add userToken and userTokenReadOnly (default: true) config fields
- Implement token routing: reads prefer user token, writes use bot token
- Add tests for token routing logic
- Update documentation with required OAuth scopes
User tokens enable reading DMs and private channels without requiring
bot membership. The userTokenReadOnly flag (true by default) ensures
the user token can only be used for reads, preventing accidental
sends as the user.
Required user token scopes:
- channels:history, channels:read
- groups:history, groups:read
- im:history, im:read
- mpim:history, mpim:read
- users:read, reactions:read, pins:read, emoji:read, search:read
2026-01-15 16:11:33 -08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("allows user token writes when bot token is missing", async () => {
|
2026-03-18 02:07:26 +00:00
|
|
|
const token = await resolveSendToken({
|
feat(slack): add userToken for read-only access to DMs and private channels (#981)
- Add userToken and userTokenReadOnly (default: true) config fields
- Implement token routing: reads prefer user token, writes use bot token
- Add tests for token routing logic
- Update documentation with required OAuth scopes
User tokens enable reading DMs and private channels without requiring
bot membership. The userTokenReadOnly flag (true by default) ensures
the user token can only be used for reads, preventing accidental
sends as the user.
Required user token scopes:
- channels:history, channels:read
- groups:history, groups:read
- im:history, im:read
- mpim:history, mpim:read
- users:read, reactions:read, pins:read, emoji:read, search:read
2026-01-15 16:11:33 -08:00
|
|
|
channels: {
|
2026-03-18 02:07:26 +00:00
|
|
|
slack: {
|
|
|
|
|
accounts: {
|
|
|
|
|
default: {
|
|
|
|
|
userToken: "xoxp-user",
|
|
|
|
|
userTokenReadOnly: false,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
feat(slack): add userToken for read-only access to DMs and private channels (#981)
- Add userToken and userTokenReadOnly (default: true) config fields
- Implement token routing: reads prefer user token, writes use bot token
- Add tests for token routing logic
- Update documentation with required OAuth scopes
User tokens enable reading DMs and private channels without requiring
bot membership. The userTokenReadOnly flag (true by default) ensures
the user token can only be used for reads, preventing accidental
sends as the user.
Required user token scopes:
- channels:history, channels:read
- groups:history, groups:read
- im:history, im:read
- mpim:history, mpim:read
- users:read, reactions:read, pins:read, emoji:read, search:read
2026-01-15 16:11:33 -08:00
|
|
|
},
|
2026-03-18 02:07:26 +00:00
|
|
|
} as OpenClawConfig);
|
|
|
|
|
expect(token).toBe("xoxp-user");
|
feat(slack): add userToken for read-only access to DMs and private channels (#981)
- Add userToken and userTokenReadOnly (default: true) config fields
- Implement token routing: reads prefer user token, writes use bot token
- Add tests for token routing logic
- Update documentation with required OAuth scopes
User tokens enable reading DMs and private channels without requiring
bot membership. The userTokenReadOnly flag (true by default) ensures
the user token can only be used for reads, preventing accidental
sends as the user.
Required user token scopes:
- channels:history, channels:read
- groups:history, groups:read
- im:history, im:read
- mpim:history, mpim:read
- users:read, reactions:read, pins:read, emoji:read, search:read
2026-01-15 16:11:33 -08:00
|
|
|
});
|
2026-02-13 14:20:41 -03:00
|
|
|
|
|
|
|
|
it("returns all emojis when no limit is provided", async () => {
|
2026-03-18 02:07:26 +00:00
|
|
|
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" },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-02-13 14:20:41 -03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("applies limit to emoji-list results", async () => {
|
2026-03-18 02:07:26 +00:00
|
|
|
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",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-02-13 14:20:41 -03:00
|
|
|
});
|
2026-01-07 04:10:13 +01:00
|
|
|
});
|