test(actions): table-drive slack and telegram action cases

This commit is contained in:
Peter Steinberger
2026-02-21 23:41:40 +00:00
parent 7707e3406c
commit 2595690a4d
2 changed files with 173 additions and 231 deletions

View File

@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/config.js";
import { handleSlackAction } from "./slack-actions.js"; import { handleSlackAction } from "./slack-actions.js";
@@ -17,52 +17,59 @@ const sendSlackMessage = vi.fn(async (..._args: unknown[]) => ({}));
const unpinSlackMessage = vi.fn(async (..._args: unknown[]) => ({})); const unpinSlackMessage = vi.fn(async (..._args: unknown[]) => ({}));
vi.mock("../../slack/actions.js", () => ({ vi.mock("../../slack/actions.js", () => ({
deleteSlackMessage, deleteSlackMessage: (...args: Parameters<typeof deleteSlackMessage>) =>
editSlackMessage, deleteSlackMessage(...args),
getSlackMemberInfo, editSlackMessage: (...args: Parameters<typeof editSlackMessage>) => editSlackMessage(...args),
listSlackEmojis, getSlackMemberInfo: (...args: Parameters<typeof getSlackMemberInfo>) =>
listSlackPins, getSlackMemberInfo(...args),
listSlackReactions, listSlackEmojis: (...args: Parameters<typeof listSlackEmojis>) => listSlackEmojis(...args),
pinSlackMessage, listSlackPins: (...args: Parameters<typeof listSlackPins>) => listSlackPins(...args),
reactSlackMessage, listSlackReactions: (...args: Parameters<typeof listSlackReactions>) =>
readSlackMessages, listSlackReactions(...args),
removeOwnSlackReactions, pinSlackMessage: (...args: Parameters<typeof pinSlackMessage>) => pinSlackMessage(...args),
removeSlackReaction, reactSlackMessage: (...args: Parameters<typeof reactSlackMessage>) => reactSlackMessage(...args),
sendSlackMessage, readSlackMessages: (...args: Parameters<typeof readSlackMessages>) => readSlackMessages(...args),
unpinSlackMessage, removeOwnSlackReactions: (...args: Parameters<typeof removeOwnSlackReactions>) =>
removeOwnSlackReactions(...args),
removeSlackReaction: (...args: Parameters<typeof removeSlackReaction>) =>
removeSlackReaction(...args),
sendSlackMessage: (...args: Parameters<typeof sendSlackMessage>) => sendSlackMessage(...args),
unpinSlackMessage: (...args: Parameters<typeof unpinSlackMessage>) => unpinSlackMessage(...args),
})); }));
describe("handleSlackAction", () => { describe("handleSlackAction", () => {
it("adds reactions", async () => { function slackConfig(overrides?: Record<string, unknown>): OpenClawConfig {
const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; return {
await handleSlackAction( channels: {
{ slack: {
action: "react", botToken: "tok",
channelId: "C1", ...overrides,
messageId: "123.456", },
emoji: "✅",
}, },
cfg, } as OpenClawConfig;
); }
expect(reactSlackMessage).toHaveBeenCalledWith("C1", "123.456", "✅");
beforeEach(() => {
vi.clearAllMocks();
}); });
it("strips channel: prefix for channelId params", async () => { it.each([
const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; { name: "raw channel id", channelId: "C1" },
{ name: "channel: prefixed id", channelId: "channel:C1" },
])("adds reactions for $name", async ({ channelId }) => {
await handleSlackAction( await handleSlackAction(
{ {
action: "react", action: "react",
channelId: "channel:C1", channelId,
messageId: "123.456", messageId: "123.456",
emoji: "✅", emoji: "✅",
}, },
cfg, slackConfig(),
); );
expect(reactSlackMessage).toHaveBeenCalledWith("C1", "123.456", "✅"); expect(reactSlackMessage).toHaveBeenCalledWith("C1", "123.456", "✅");
}); });
it("removes reactions on empty emoji", async () => { it("removes reactions on empty emoji", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig;
await handleSlackAction( await handleSlackAction(
{ {
action: "react", action: "react",
@@ -70,13 +77,12 @@ describe("handleSlackAction", () => {
messageId: "123.456", messageId: "123.456",
emoji: "", emoji: "",
}, },
cfg, slackConfig(),
); );
expect(removeOwnSlackReactions).toHaveBeenCalledWith("C1", "123.456"); expect(removeOwnSlackReactions).toHaveBeenCalledWith("C1", "123.456");
}); });
it("removes reactions when remove flag set", async () => { it("removes reactions when remove flag set", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig;
await handleSlackAction( await handleSlackAction(
{ {
action: "react", action: "react",
@@ -85,13 +91,12 @@ describe("handleSlackAction", () => {
emoji: "✅", emoji: "✅",
remove: true, remove: true,
}, },
cfg, slackConfig(),
); );
expect(removeSlackReaction).toHaveBeenCalledWith("C1", "123.456", "✅"); expect(removeSlackReaction).toHaveBeenCalledWith("C1", "123.456", "✅");
}); });
it("rejects removes without emoji", async () => { it("rejects removes without emoji", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig;
await expect( await expect(
handleSlackAction( handleSlackAction(
{ {
@@ -101,15 +106,12 @@ describe("handleSlackAction", () => {
emoji: "", emoji: "",
remove: true, remove: true,
}, },
cfg, slackConfig(),
), ),
).rejects.toThrow(/Emoji is required/); ).rejects.toThrow(/Emoji is required/);
}); });
it("respects reaction gating", async () => { it("respects reaction gating", async () => {
const cfg = {
channels: { slack: { botToken: "tok", actions: { reactions: false } } },
} as OpenClawConfig;
await expect( await expect(
handleSlackAction( handleSlackAction(
{ {
@@ -118,13 +120,12 @@ describe("handleSlackAction", () => {
messageId: "123.456", messageId: "123.456",
emoji: "✅", emoji: "✅",
}, },
cfg, slackConfig({ actions: { reactions: false } }),
), ),
).rejects.toThrow(/Slack reactions are disabled/); ).rejects.toThrow(/Slack reactions are disabled/);
}); });
it("passes threadTs to sendSlackMessage for thread replies", async () => { it("passes threadTs to sendSlackMessage for thread replies", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig;
await handleSlackAction( await handleSlackAction(
{ {
action: "sendMessage", action: "sendMessage",
@@ -132,7 +133,7 @@ describe("handleSlackAction", () => {
content: "Hello thread", content: "Hello thread",
threadTs: "1234567890.123456", threadTs: "1234567890.123456",
}, },
cfg, slackConfig(),
); );
expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "Hello thread", { expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "Hello thread", {
mediaUrl: undefined, mediaUrl: undefined,
@@ -141,74 +142,56 @@ describe("handleSlackAction", () => {
}); });
}); });
it("accepts blocks JSON and allows empty content", async () => { it.each([
const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; {
sendSlackMessage.mockClear(); name: "JSON blocks",
await handleSlackAction( blocks: JSON.stringify([
{ { type: "section", text: { type: "mrkdwn", text: "*Deploy* status" } },
action: "sendMessage", ]),
to: "channel:C123", expectedBlocks: [{ type: "section", text: { type: "mrkdwn", text: "*Deploy* status" } }],
blocks: JSON.stringify([ },
{ type: "section", text: { type: "mrkdwn", text: "*Deploy* status" } }, {
]), name: "array blocks",
},
cfg,
);
expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "", {
mediaUrl: undefined,
threadTs: undefined,
blocks: [{ type: "section", text: { type: "mrkdwn", text: "*Deploy* status" } }],
});
});
it("accepts blocks arrays directly", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig;
sendSlackMessage.mockClear();
await handleSlackAction(
{
action: "sendMessage",
to: "channel:C123",
blocks: [{ type: "divider" }],
},
cfg,
);
expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "", {
mediaUrl: undefined,
threadTs: undefined,
blocks: [{ type: "divider" }], blocks: [{ type: "divider" }],
expectedBlocks: [{ type: "divider" }],
},
])("accepts $name and allows empty content", async ({ blocks, expectedBlocks }) => {
await handleSlackAction(
{
action: "sendMessage",
to: "channel:C123",
blocks,
},
slackConfig(),
);
expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "", {
mediaUrl: undefined,
threadTs: undefined,
blocks: expectedBlocks,
}); });
}); });
it("rejects invalid blocks JSON", async () => { it.each([
const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; {
name: "invalid blocks JSON",
blocks: "{bad-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( await expect(
handleSlackAction( handleSlackAction(
{ {
action: "sendMessage", action: "sendMessage",
to: "channel:C123", to: "channel:C123",
blocks: "{bad-json", blocks,
}, },
cfg, slackConfig(),
), ),
).rejects.toThrow(/blocks must be valid JSON/i); ).rejects.toThrow(expectedError);
});
it("rejects empty blocks arrays", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig;
await expect(
handleSlackAction(
{
action: "sendMessage",
to: "channel:C123",
blocks: "[]",
},
cfg,
),
).rejects.toThrow(/at least one block/i);
}); });
it("requires at least one of content, blocks, or mediaUrl", async () => { it("requires at least one of content, blocks, or mediaUrl", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig;
await expect( await expect(
handleSlackAction( handleSlackAction(
{ {
@@ -216,13 +199,12 @@ describe("handleSlackAction", () => {
to: "channel:C123", to: "channel:C123",
content: "", content: "",
}, },
cfg, slackConfig(),
), ),
).rejects.toThrow(/requires content, blocks, or mediaUrl/i); ).rejects.toThrow(/requires content, blocks, or mediaUrl/i);
}); });
it("rejects blocks combined with mediaUrl", async () => { it("rejects blocks combined with mediaUrl", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig;
await expect( await expect(
handleSlackAction( handleSlackAction(
{ {
@@ -231,47 +213,38 @@ describe("handleSlackAction", () => {
blocks: [{ type: "divider" }], blocks: [{ type: "divider" }],
mediaUrl: "https://example.com/image.png", mediaUrl: "https://example.com/image.png",
}, },
cfg, slackConfig(),
), ),
).rejects.toThrow(/does not support blocks with mediaUrl/i); ).rejects.toThrow(/does not support blocks with mediaUrl/i);
}); });
it("passes blocks JSON to editSlackMessage with empty content", async () => { it.each([
const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; {
editSlackMessage.mockClear(); name: "JSON blocks",
await handleSlackAction( blocks: JSON.stringify([{ type: "section", text: { type: "mrkdwn", text: "Updated" } }]),
{ expectedBlocks: [{ type: "section", text: { type: "mrkdwn", text: "Updated" } }],
action: "editMessage", },
channelId: "C123", {
messageId: "123.456", name: "array blocks",
blocks: JSON.stringify([{ type: "section", text: { type: "mrkdwn", text: "Updated" } }]),
},
cfg,
);
expect(editSlackMessage).toHaveBeenCalledWith("C123", "123.456", "", {
blocks: [{ type: "section", text: { type: "mrkdwn", text: "Updated" } }],
});
});
it("passes blocks arrays to editSlackMessage", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig;
editSlackMessage.mockClear();
await handleSlackAction(
{
action: "editMessage",
channelId: "C123",
messageId: "123.456",
blocks: [{ type: "divider" }],
},
cfg,
);
expect(editSlackMessage).toHaveBeenCalledWith("C123", "123.456", "", {
blocks: [{ type: "divider" }], blocks: [{ type: "divider" }],
expectedBlocks: [{ type: "divider" }],
},
])("passes $name to editSlackMessage", async ({ blocks, expectedBlocks }) => {
await handleSlackAction(
{
action: "editMessage",
channelId: "C123",
messageId: "123.456",
blocks,
},
slackConfig(),
);
expect(editSlackMessage).toHaveBeenCalledWith("C123", "123.456", "", {
blocks: expectedBlocks,
}); });
}); });
it("requires content or blocks for editMessage", async () => { it("requires content or blocks for editMessage", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig;
await expect( await expect(
handleSlackAction( handleSlackAction(
{ {
@@ -280,7 +253,7 @@ describe("handleSlackAction", () => {
messageId: "123.456", messageId: "123.456",
content: "", content: "",
}, },
cfg, slackConfig(),
), ),
).rejects.toThrow(/requires content or blocks/i); ).rejects.toThrow(/requires content or blocks/i);
}); });

View File

@@ -40,6 +40,17 @@ describe("handleTelegramAction", () => {
} as OpenClawConfig; } as OpenClawConfig;
} }
function telegramConfig(overrides?: Record<string, unknown>): OpenClawConfig {
return {
channels: {
telegram: {
botToken: "tok",
...overrides,
},
},
} as OpenClawConfig;
}
async function expectReactionAdded(reactionLevel: "minimal" | "extensive") { async function expectReactionAdded(reactionLevel: "minimal" | "extensive") {
await handleTelegramAction(defaultReactionAction, reactionConfig(reactionLevel)); await handleTelegramAction(defaultReactionAction, reactionConfig(reactionLevel));
expect(reactMessageTelegram).toHaveBeenCalledWith( expect(reactMessageTelegram).toHaveBeenCalledWith(
@@ -166,8 +177,16 @@ describe("handleTelegramAction", () => {
); );
}); });
it("blocks reactions when reactionLevel is off", async () => { it.each([
const cfg = reactionConfig("off"); {
level: "off" as const,
expectedMessage: /Telegram agent reactions disabled.*reactionLevel="off"/,
},
{
level: "ack" as const,
expectedMessage: /Telegram agent reactions disabled.*reactionLevel="ack"/,
},
])("blocks reactions when reactionLevel is $level", async ({ level, expectedMessage }) => {
await expect( await expect(
handleTelegramAction( handleTelegramAction(
{ {
@@ -176,24 +195,9 @@ describe("handleTelegramAction", () => {
messageId: "456", messageId: "456",
emoji: "✅", emoji: "✅",
}, },
cfg, reactionConfig(level),
), ),
).rejects.toThrow(/Telegram agent reactions disabled.*reactionLevel="off"/); ).rejects.toThrow(expectedMessage);
});
it("blocks reactions when reactionLevel is ack", async () => {
const cfg = reactionConfig("ack");
await expect(
handleTelegramAction(
{
action: "react",
chatId: "123",
messageId: "456",
emoji: "✅",
},
cfg,
),
).rejects.toThrow(/Telegram agent reactions disabled.*reactionLevel="ack"/);
}); });
it("also respects legacy actions.reactions gating", async () => { it("also respects legacy actions.reactions gating", async () => {
@@ -220,16 +224,13 @@ describe("handleTelegramAction", () => {
}); });
it("sends a text message", async () => { it("sends a text message", async () => {
const cfg = {
channels: { telegram: { botToken: "tok" } },
} as OpenClawConfig;
const result = await handleTelegramAction( const result = await handleTelegramAction(
{ {
action: "sendMessage", action: "sendMessage",
to: "@testchannel", to: "@testchannel",
content: "Hello, Telegram!", content: "Hello, Telegram!",
}, },
cfg, telegramConfig(),
); );
expect(sendMessageTelegram).toHaveBeenCalledWith( expect(sendMessageTelegram).toHaveBeenCalledWith(
"@testchannel", "@testchannel",
@@ -242,87 +243,66 @@ describe("handleTelegramAction", () => {
}); });
}); });
it("sends a message with media", async () => { it.each([
const cfg = { {
channels: { telegram: { botToken: "tok" } }, name: "media",
} as OpenClawConfig; params: {
await handleTelegramAction(
{
action: "sendMessage", action: "sendMessage",
to: "123456", to: "123456",
content: "Check this image!", content: "Check this image!",
mediaUrl: "https://example.com/image.jpg", mediaUrl: "https://example.com/image.jpg",
}, },
cfg, expectedTo: "123456",
); expectedContent: "Check this image!",
expect(sendMessageTelegram).toHaveBeenCalledWith( expectedOptions: { mediaUrl: "https://example.com/image.jpg" },
"123456", },
"Check this image!", {
expect.objectContaining({ name: "quoteText",
token: "tok", params: {
mediaUrl: "https://example.com/image.jpg",
}),
);
});
it("passes quoteText when provided", async () => {
const cfg = {
channels: { telegram: { botToken: "tok" } },
} as OpenClawConfig;
await handleTelegramAction(
{
action: "sendMessage", action: "sendMessage",
to: "123456", to: "123456",
content: "Replying now", content: "Replying now",
replyToMessageId: 144, replyToMessageId: 144,
quoteText: "The text you want to quote", quoteText: "The text you want to quote",
}, },
cfg, expectedTo: "123456",
); expectedContent: "Replying now",
expect(sendMessageTelegram).toHaveBeenCalledWith( expectedOptions: {
"123456",
"Replying now",
expect.objectContaining({
token: "tok",
replyToMessageId: 144, replyToMessageId: 144,
quoteText: "The text you want to quote", quoteText: "The text you want to quote",
}), },
); },
}); {
name: "media-only",
it("allows media-only messages without content", async () => { params: {
const cfg = {
channels: { telegram: { botToken: "tok" } },
} as OpenClawConfig;
await handleTelegramAction(
{
action: "sendMessage", action: "sendMessage",
to: "123456", to: "123456",
mediaUrl: "https://example.com/note.ogg", mediaUrl: "https://example.com/note.ogg",
}, },
cfg, expectedTo: "123456",
); expectedContent: "",
expectedOptions: { mediaUrl: "https://example.com/note.ogg" },
},
] as const)("maps sendMessage params for $name", async (testCase) => {
await handleTelegramAction(testCase.params, telegramConfig());
expect(sendMessageTelegram).toHaveBeenCalledWith( expect(sendMessageTelegram).toHaveBeenCalledWith(
"123456", testCase.expectedTo,
"", testCase.expectedContent,
expect.objectContaining({ expect.objectContaining({
token: "tok", token: "tok",
mediaUrl: "https://example.com/note.ogg", ...testCase.expectedOptions,
}), }),
); );
}); });
it("requires content when no mediaUrl is provided", async () => { it("requires content when no mediaUrl is provided", async () => {
const cfg = {
channels: { telegram: { botToken: "tok" } },
} as OpenClawConfig;
await expect( await expect(
handleTelegramAction( handleTelegramAction(
{ {
action: "sendMessage", action: "sendMessage",
to: "123456", to: "123456",
}, },
cfg, telegramConfig(),
), ),
).rejects.toThrow(/content required/i); ).rejects.toThrow(/content required/i);
}); });
@@ -413,42 +393,31 @@ describe("handleTelegramAction", () => {
expect(sendMessageTelegram).toHaveBeenCalled(); expect(sendMessageTelegram).toHaveBeenCalled();
}); });
it("blocks inline buttons when scope is off", async () => { it.each([
const cfg = { {
channels: { name: "scope is off",
telegram: { botToken: "tok", capabilities: { inlineButtons: "off" } }, to: "@testchannel",
}, inlineButtons: "off" as const,
} as OpenClawConfig; expectedMessage: /inline buttons are disabled/i,
},
{
name: "scope is dm and target is group",
to: "-100123456",
inlineButtons: "dm" as const,
expectedMessage: /inline buttons are limited to DMs/i,
},
])("blocks inline buttons when $name", async ({ to, inlineButtons, expectedMessage }) => {
await expect( await expect(
handleTelegramAction( handleTelegramAction(
{ {
action: "sendMessage", action: "sendMessage",
to: "@testchannel", to,
content: "Choose", content: "Choose",
buttons: [[{ text: "Ok", callback_data: "cmd:ok" }]], buttons: [[{ text: "Ok", callback_data: "cmd:ok" }]],
}, },
cfg, telegramConfig({ capabilities: { inlineButtons } }),
), ),
).rejects.toThrow(/inline buttons are disabled/i); ).rejects.toThrow(expectedMessage);
});
it("blocks inline buttons in groups when scope is dm", async () => {
const cfg = {
channels: {
telegram: { botToken: "tok", capabilities: { inlineButtons: "dm" } },
},
} as OpenClawConfig;
await expect(
handleTelegramAction(
{
action: "sendMessage",
to: "-100123456",
content: "Choose",
buttons: [[{ text: "Ok", callback_data: "cmd:ok" }]],
},
cfg,
),
).rejects.toThrow(/inline buttons are limited to DMs/i);
}); });
it("allows inline buttons in DMs with tg: prefixed targets", async () => { it("allows inline buttons in DMs with tg: prefixed targets", async () => {