Files
openclaw/extensions/slack/src/message-action-dispatch.test.ts
Peter Steinberger f81f9d8570 chore: enforce max-lines suppression ratchet (#107315)
* ci: enforce max-lines suppression ratchet

* chore: prune stale max-lines suppression

* fix: close max-lines ratchet enforcement gaps

* fix: harden max-lines ratchet checks

* fix(ci): satisfy max-lines ratchet checks

* style: format max-lines declarations

* fix(ci): match oxlint suppression grammar

* test: isolate max-lines git fixtures

* chore: prune resolved max-lines debt

* test: skip newline path fixture on Windows

* fix: harden max-lines suppression ratchet

* chore: refresh max-lines baseline

* fix: close max-lines ratchet bypasses

* fix: derive ratchet base from PR merge tree

* fix: support older Git in staged ratchet

* fix: align max-lines declarations and baseline

* chore: refresh max-lines baseline for current main

* fix: exclude generated wizard locales from max-lines

* chore: prune resolved max-lines debt
2026-07-14 09:27:02 -07:00

1210 lines
35 KiB
TypeScript

// Slack tests cover message action dispatch plugin behavior.
import { describe, expect, it, vi } from "vitest";
import { handleSlackMessageAction } from "./message-action-dispatch.js";
import { extractSlackToolSend } from "./message-actions.js";
import { renderSlackMessagePresentationFallbackText } from "./presentation-fallback.js";
function createInvokeSpy() {
return vi.fn(async (action: Record<string, unknown>, _cfg?: unknown, _toolContext?: unknown) => ({
ok: true,
content: action,
}));
}
function slackConfig() {
return { channels: { slack: { botToken: "tok" } } };
}
function firstInvokeCall(invoke: ReturnType<typeof createInvokeSpy>) {
const [call] = invoke.mock.calls;
if (!call) {
throw new Error("expected first Slack action invoke");
}
return call;
}
function expectForwardedCfg(invoke: ReturnType<typeof createInvokeSpy>, cfg: unknown) {
expect(firstInvokeCall(invoke)[1]).toBe(cfg);
}
function expectNoForwardedToolContext(invoke: ReturnType<typeof createInvokeSpy>) {
expect(firstInvokeCall(invoke)[2]).toBeUndefined();
}
function firstAction(invoke: ReturnType<typeof createInvokeSpy>) {
const action = firstInvokeCall(invoke)[0];
if (!action || typeof action !== "object") {
throw new Error("expected first invoke action");
}
return action;
}
function preparedMessages(invoke: ReturnType<typeof createInvokeSpy>) {
const context = firstInvokeCall(invoke)[2] as
| { preparedMessages?: Array<Record<string, unknown>> }
| undefined;
const messages = context?.preparedMessages;
if (!messages?.length) {
throw new Error("expected prepared Slack messages");
}
return messages;
}
function blockAt(action: Record<string, unknown>, index: number) {
const blocks = action.blocks as Array<Record<string, unknown>> | undefined;
const block = blocks?.[index];
if (!block) {
throw new Error(`expected Slack block ${index}`);
}
return block;
}
function elementAt(block: Record<string, unknown>, index: number) {
const elements = block.elements as Array<Record<string, unknown>> | undefined;
const element = elements?.[index];
if (!element) {
throw new Error(`expected Slack block element ${index}`);
}
return element;
}
function largeTablePresentation() {
return {
blocks: [
{
type: "table",
caption: "Large pipeline",
headers: ["Account"],
rows: Array.from({ length: 100 }, (_entry, index) => [
index === 0 ? "<@U123>" : `account-${String(index)} ${"x".repeat(110)}`,
]),
},
],
};
}
describe("handleSlackMessageAction", () => {
it("defaults reactions to the current inbound Slack message", async () => {
const invoke = createInvokeSpy();
const toolContext = { currentMessageId: "171234.567" };
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "react",
cfg: {},
params: {
channelId: "C1",
emoji: "✅",
},
toolContext,
} as never,
invoke: invoke as never,
});
expect(firstAction(invoke)).toMatchObject({
action: "react",
channelId: "C1",
emoji: "✅",
messageId: "171234.567",
});
expect(firstInvokeCall(invoke)[2]).toBe(toolContext);
});
it("merges presentation and interactive blocks when sending", async () => {
const invoke = createInvokeSpy();
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "send",
cfg: {},
params: {
to: "channel:C1",
message: "Deploy?",
presentation: {
blocks: [{ type: "text", text: "Deploy summary" }],
},
interactive: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Approve", value: "approve" }],
},
],
},
},
} as never,
invoke: invoke as never,
});
const action = firstAction(invoke);
expect(action).not.toHaveProperty("blocks");
const message = preparedMessages(invoke)[0]!;
expect(blockAt(message, 0).type).toBe("section");
expect(blockAt(message, 1).type).toBe("section");
const actionsBlock = blockAt(message, 2);
expect(actionsBlock.type).toBe("actions");
expect(elementAt(actionsBlock, 0).value).toBe("approve");
});
it("sends an exact mirrored portable control row once", async () => {
const invoke = createInvokeSpy();
const buttons = [{ label: "Approve", action: { type: "callback" as const, value: "approve" } }];
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "send",
cfg: {},
params: {
to: "channel:C1",
message: "Deploy?",
presentation: { blocks: [{ type: "buttons", buttons }] },
interactive: { blocks: [{ type: "buttons", buttons }] },
},
} as never,
invoke: invoke as never,
});
const action = firstAction(invoke);
expect(action).not.toHaveProperty("blocks");
const message = preparedMessages(invoke)[0]!;
const blocks = message.blocks as Array<Record<string, unknown>> | undefined;
const actions = blocks?.filter((block) => block.type === "actions") ?? [];
expect(actions).toHaveLength(1);
expect(elementAt(actions[0]!, 0).value).toBe("approve");
});
it("sends native charts with a complete accessible text representation", async () => {
const invoke = createInvokeSpy();
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "send",
cfg: {},
params: {
to: "channel:C1",
message: "Revenue summary",
presentation: {
blocks: [
{
type: "chart",
chartType: "pie",
title: "Revenue mix",
segments: [
{ label: "Product", value: 60 },
{ label: "Services", value: 40 },
],
},
],
},
},
} as never,
invoke: invoke as never,
});
const action = firstAction(invoke);
expect(action.content).toBe("Revenue summary");
expect(action).not.toHaveProperty("blocks");
expect(action).not.toHaveProperty("nativeDataFallbackBaseText");
const message = preparedMessages(invoke)[0]!;
expect(message.text).toBe(
"Revenue summary\n\nRevenue mix (pie chart)\n- Product: 60\n- Services: 40",
);
expect(blockAt(message, 1)).toEqual({
type: "data_visualization",
title: "Revenue mix",
chart: {
type: "pie",
segments: [
{ label: "Product", value: 60 },
{ label: "Services", value: 40 },
],
},
});
});
it("sends native tables with a complete accessible text representation", async () => {
const invoke = createInvokeSpy();
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "send",
cfg: {},
params: {
to: "channel:C1",
message: "Pipeline summary",
presentation: {
blocks: [
{
type: "table",
caption: "Pipeline",
headers: ["Account", "ARR"],
rows: [
["Acme", 125000],
["Globex", 82000],
],
},
],
},
},
} as never,
invoke: invoke as never,
});
const action = firstAction(invoke);
expect(action.content).toBe("Pipeline summary");
const message = preparedMessages(invoke)[0]!;
expect(message.text).toBe(
"Pipeline summary\n\nPipeline (table)\nAccount\tARR\nAcme\t125000\nGlobex\t82000",
);
});
it("edits native tables with a complete accessible text representation", async () => {
const invoke = createInvokeSpy();
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "edit",
cfg: {},
params: {
channelId: "C1",
messageId: "171234.567",
message: "Updated pipeline",
presentation: {
blocks: [
{
type: "table",
caption: "Pipeline",
headers: ["Account", "Stage"],
rows: [["Acme", "Won"]],
},
],
},
},
} as never,
invoke: invoke as never,
});
expect(firstAction(invoke)).toMatchObject({
action: "editMessage",
channelId: "C1",
messageId: "171234.567",
content: "Updated pipeline\n\nPipeline (table)\n- Account: Acme; Stage: Won",
});
});
it("routes non-native tables through Slack-safe text with native controls", async () => {
const invoke = createInvokeSpy();
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "send",
cfg: {},
params: {
to: "channel:C1",
message: "Summary",
presentation: {
blocks: [
{
type: "chart",
chartType: "pie",
title: "Revenue mix",
segments: [{ label: "Product", value: 60 }],
},
...largeTablePresentation().blocks,
{
type: "buttons",
buttons: [{ label: "Stage", value: "stage" }],
},
{
type: "select",
placeholder: "Lane",
options: [{ label: "Production", value: "production" }],
},
],
},
interactive: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Refresh", value: "refresh" }],
},
{
type: "select",
placeholder: "Window",
options: [{ label: "Recent", value: "recent" }],
},
],
},
},
} as never,
invoke: invoke as never,
});
const action = firstAction(invoke);
expect(action).not.toHaveProperty("separateTextAndBlocks");
expect(action).not.toHaveProperty("blocks");
const messages = preparedMessages(invoke);
expect(messages).toHaveLength(3);
expect(messages[0]?.text).toBe("Summary\n\nRevenue mix (pie chart)\n- Product: 60");
expect(blockAt(messages[0]!, 1).type).toBe("data_visualization");
expect(messages[1]?.text).toContain("- Account: <@U123>");
expect(messages[1]?.text).toContain("- Account: account-99");
expect(messages[1]?.textIsSlackPlainText).toBe(true);
const controls = messages[2]!;
const presentationButtons = blockAt(controls, 0);
expect(presentationButtons.block_id).toBe("openclaw_reply_buttons_1");
expect(elementAt(presentationButtons, 0)).toMatchObject({
action_id: "openclaw:reply_button:1:1",
value: "stage",
});
const presentationSelect = blockAt(controls, 1);
expect(presentationSelect.block_id).toBe("openclaw_reply_select_1");
expect(elementAt(presentationSelect, 0).action_id).toBe("openclaw:reply_select:1");
const legacyButtons = blockAt(controls, 2);
expect(legacyButtons.block_id).toBe("openclaw_reply_buttons_2");
expect(elementAt(legacyButtons, 0)).toMatchObject({
action_id: "openclaw:reply_button:2:1",
value: "refresh",
});
const legacySelect = blockAt(controls, 3);
expect(legacySelect.block_id).toBe("openclaw_reply_select_2");
expect(elementAt(legacySelect, 0).action_id).toBe("openclaw:reply_select:2");
expect(String(messages[1]?.text).length).toBeGreaterThan(8000);
});
it("keeps an oversized portable control label complete in literal fallback", async () => {
const invoke = createInvokeSpy();
const label = `Deploy ${"x".repeat(80)}`;
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "send",
cfg: {},
params: {
to: "channel:C1",
presentation: {
blocks: [{ type: "buttons", buttons: [{ label, value: "deploy" }] }],
},
},
} as never,
invoke: invoke as never,
});
const action = firstAction(invoke);
expect(action).not.toHaveProperty("blocks");
expect(action).not.toHaveProperty("preparedMessages");
expect(preparedMessages(invoke)).toEqual([{ text: `- ${label}`, textIsSlackPlainText: true }]);
});
it("uses text-only edits for non-native tables that fit one message", async () => {
const invoke = createInvokeSpy();
const headers = Array.from({ length: 21 }, (_entry, index) => `Column ${String(index)}`);
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "edit",
cfg: {},
params: {
channelId: "C1",
messageId: "171234.567",
presentation: {
blocks: [
{
type: "table",
caption: "Wide pipeline",
headers,
rows: [headers.map((_header, index) => `Value ${String(index)}`)],
},
],
},
},
} as never,
invoke: invoke as never,
});
const action = firstAction(invoke);
expect(action.blocks).toBeUndefined();
expect(action.content).toContain("Column 20: Value 20");
});
it("rejects non-native table edits whose complete fallback cannot fit", async () => {
const invoke = createInvokeSpy();
const headers = Array.from({ length: 21 }, (_entry, index) => `Column ${String(index)}`);
const presentation = {
blocks: [
{
type: "table" as const,
caption: "Wide pipeline",
headers,
rows: Array.from({ length: 10 }, (_row, rowIndex) =>
headers.map(
(_header, columnIndex) => `Value ${String(rowIndex)}-${String(columnIndex)}`,
),
),
},
],
};
const fallback = renderSlackMessagePresentationFallbackText({ presentation });
expect(fallback.length).toBeGreaterThan(4000);
expect(fallback.length).toBeLessThan(8000);
await expect(
handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "edit",
cfg: {},
params: {
channelId: "C1",
messageId: "171234.567",
presentation,
},
} as never,
invoke: invoke as never,
}),
).rejects.toThrow("Slack presentation fallback exceeds the 4000-character edit limit");
expect(invoke).not.toHaveBeenCalled();
});
it("rejects non-native chart edits whose complete fallback cannot fit", async () => {
const invoke = createInvokeSpy();
const categories = Array.from(
{ length: 21 },
(_entry, index) => `Category ${String(index)} ${"x".repeat(200)}`,
);
const presentation = {
blocks: [
{
type: "chart" as const,
chartType: "line" as const,
title: "Long trend",
categories,
series: [{ name: "Series", values: categories.map((_category, index) => index) }],
},
],
};
expect(renderSlackMessagePresentationFallbackText({ presentation }).length).toBeGreaterThan(
4000,
);
await expect(
handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "edit",
cfg: {},
params: { channelId: "C1", messageId: "171234.567", presentation },
} as never,
invoke: invoke as never,
}),
).rejects.toThrow("Slack presentation fallback exceeds the 4000-character edit limit");
expect(invoke).not.toHaveBeenCalled();
});
it("keeps an unrenderable control label complete in text-only edits", async () => {
const invoke = createInvokeSpy();
const label = `Deploy ${"x".repeat(80)}`;
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "edit",
cfg: {},
params: {
channelId: "C1",
messageId: "171234.567",
presentation: {
blocks: [{ type: "buttons", buttons: [{ label, value: "deploy" }] }],
},
},
} as never,
invoke: invoke as never,
});
expect(firstAction(invoke)).toMatchObject({ content: `- ${label}`, blocks: undefined });
});
it("uses complete text-only fallback when an edit exceeds fifty blocks", async () => {
const invoke = createInvokeSpy();
const presentation = {
blocks: Array.from({ length: 51 }, (_entry, index) => ({
type: "text" as const,
text: `Detail ${String(index)}`,
})),
};
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "edit",
cfg: {},
params: { channelId: "C1", messageId: "171234.567", presentation },
} as never,
invoke: invoke as never,
});
const action = firstAction(invoke);
expect(action.blocks).toBeUndefined();
expect(action.content).toContain("Detail 0");
expect(action.content).toContain("Detail 50");
});
it("keeps generated Slack control ids unique when presentation and interactive controls are merged", async () => {
const invoke = createInvokeSpy();
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "send",
cfg: {},
params: {
to: "channel:C1",
message: "Deploy?",
presentation: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Stage", value: "stage" }],
},
],
},
interactive: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Approve", value: "approve" }],
},
],
},
},
} as never,
invoke: invoke as never,
});
const action = firstAction(invoke);
expect(action).not.toHaveProperty("blocks");
const message = preparedMessages(invoke)[0]!;
const firstButtons = blockAt(message, 1);
expect(firstButtons.block_id).toBe("openclaw_reply_buttons_1");
expect(elementAt(firstButtons, 0).action_id).toBe("openclaw:reply_button:1:1");
const secondButtons = blockAt(message, 2);
expect(secondButtons.block_id).toBe("openclaw_reply_buttons_2");
expect(elementAt(secondButtons, 0).action_id).toBe("openclaw:reply_button:2:1");
});
it("passes media and rendered interactive blocks through for split Slack delivery", async () => {
const invoke = createInvokeSpy();
const cfg = slackConfig();
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "send",
cfg,
params: {
to: "channel:C1",
message: "Approval required",
media: "https://example.com/report.md",
interactive: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Approve", value: "approve" }],
},
],
},
},
} as never,
invoke: invoke as never,
});
expect(invoke).toHaveBeenCalledOnce();
const action = firstAction(invoke);
expect(action.action).toBe("sendMessage");
expect(action.to).toBe("channel:C1");
expect(action.content).toBe("Approval required");
expect(action.mediaUrl).toBe("https://example.com/report.md");
expect(action).not.toHaveProperty("blocks");
const message = preparedMessages(invoke)[0]!;
expect(blockAt(message, 0).type).toBe("section");
const actionsBlock = blockAt(message, 1);
expect(actionsBlock.type).toBe("actions");
expect(elementAt(actionsBlock, 0).value).toBe("approve");
expectForwardedCfg(invoke, cfg);
});
it("passes replyBroadcast through for Slack thread sends", async () => {
const invoke = createInvokeSpy();
const cfg = slackConfig();
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "send",
cfg,
params: {
to: "channel:C1",
message: "Visible from the channel",
threadId: "111.222",
replyBroadcast: true,
},
} as never,
invoke: invoke as never,
});
const action = firstAction(invoke);
expect(action.action).toBe("sendMessage");
expect(action.to).toBe("channel:C1");
expect(action.content).toBe("Visible from the channel");
expect(action.threadTs).toBe("111.222");
expect(action.replyBroadcast).toBe(true);
expectForwardedCfg(invoke, cfg);
expectNoForwardedToolContext(invoke);
});
it("passes topLevel through so same-channel Slack sends can suppress thread inheritance", async () => {
const invoke = createInvokeSpy();
const cfg = slackConfig();
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "send",
cfg,
params: {
to: "channel:C1",
message: "Visible in the parent channel",
topLevel: true,
},
} as never,
invoke: invoke as never,
});
const action = firstAction(invoke);
expect(action.action).toBe("sendMessage");
expect(action.to).toBe("channel:C1");
expect(action.content).toBe("Visible in the parent channel");
expect(action.threadTs).toBeUndefined();
expect(action.topLevel).toBe(true);
expectForwardedCfg(invoke, cfg);
expectNoForwardedToolContext(invoke);
});
it("treats threadId null as a Slack top-level send request", async () => {
const invoke = createInvokeSpy();
const cfg = slackConfig();
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "send",
cfg,
params: {
to: "channel:C1",
message: "Visible in the parent channel",
threadId: null,
},
} as never,
invoke: invoke as never,
});
const action = firstAction(invoke);
expect(action.action).toBe("sendMessage");
expect(action.threadTs).toBeUndefined();
expect(action.topLevel).toBe(true);
expectForwardedCfg(invoke, cfg);
expectNoForwardedToolContext(invoke);
});
it("maps upload-file to the internal uploadFile action", async () => {
const invoke = createInvokeSpy();
const cfg = slackConfig();
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "upload-file",
cfg,
params: {
to: "user:U1",
filePath: "/tmp/report.png",
initialComment: "fresh build",
filename: "build.png",
title: "Build Screenshot",
threadId: "111.222",
},
} as never,
invoke: invoke as never,
});
const action = firstAction(invoke);
expect(action.action).toBe("uploadFile");
expect(action.to).toBe("user:U1");
expect(action.filePath).toBe("/tmp/report.png");
expect(action.initialComment).toBe("fresh build");
expect(action.filename).toBe("build.png");
expect(action.title).toBe("Build Screenshot");
expect(action.threadTs).toBe("111.222");
expectForwardedCfg(invoke, cfg);
expectNoForwardedToolContext(invoke);
});
it("rejects replyBroadcast for upload-file", async () => {
await expect(
handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "upload-file",
cfg: {},
params: {
to: "channel:C1",
filePath: "/tmp/report.png",
threadId: "111.222",
replyBroadcast: true,
},
} as never,
invoke: createInvokeSpy() as never,
}),
).rejects.toThrow(/replyBroadcast is only supported for text or block thread replies/i);
});
it("maps upload-file aliases to upload params", async () => {
const invoke = createInvokeSpy();
const cfg = slackConfig();
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "upload-file",
cfg,
params: {
channelId: "C1",
media: "/tmp/chart.png",
message: "chart attached",
replyTo: "333.444",
},
} as never,
invoke: invoke as never,
});
const action = firstAction(invoke);
expect(action.action).toBe("uploadFile");
expect(action.to).toBe("C1");
expect(action.filePath).toBe("/tmp/chart.png");
expect(action.initialComment).toBe("chart attached");
expect(action.threadTs).toBe("333.444");
expectForwardedCfg(invoke, cfg);
expectNoForwardedToolContext(invoke);
});
it("maps upload-file path alias to filePath", async () => {
const invoke = createInvokeSpy();
const cfg = slackConfig();
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "upload-file",
cfg,
params: {
to: "channel:C1",
path: "/tmp/report.txt",
initialComment: "path alias",
},
} as never,
invoke: invoke as never,
});
const action = firstAction(invoke);
expect(action.action).toBe("uploadFile");
expect(action.to).toBe("channel:C1");
expect(action.filePath).toBe("/tmp/report.txt");
expect(action.initialComment).toBe("path alias");
expectForwardedCfg(invoke, cfg);
expectNoForwardedToolContext(invoke);
});
it("forwards messageId for read actions", async () => {
const invoke = createInvokeSpy();
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "read",
cfg: {},
params: {
channelId: "C1",
messageId: "1712345678.654321",
},
} as never,
invoke: invoke as never,
});
const action = firstAction(invoke);
expect(action.action).toBe("readMessages");
expect(action.channelId).toBe("C1");
expect(action.messageId).toBe("1712345678.654321");
expect(firstInvokeCall(invoke)[1]).toEqual({});
});
it("rejects fractional read limits before invoking Slack actions", async () => {
const invoke = createInvokeSpy();
await expect(
handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "read",
cfg: {},
params: {
channelId: "C1",
limit: 2.5,
},
} as never,
invoke: invoke as never,
}),
).rejects.toThrow("limit must be a positive integer.");
expect(invoke).not.toHaveBeenCalled();
});
it("requires filePath, path, or media for upload-file", async () => {
await expect(
handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "upload-file",
cfg: {},
params: {
to: "channel:C1",
},
} as never,
invoke: createInvokeSpy() as never,
}),
).rejects.toThrow(/upload-file requires filePath, path, or media/i);
});
it("maps download-file to the internal downloadFile action", async () => {
const invoke = createInvokeSpy();
const cfg = slackConfig();
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "download-file",
cfg,
params: {
channelId: "C1",
fileId: "F123",
threadId: "111.222",
},
} as never,
invoke: invoke as never,
});
const action = firstAction(invoke);
expect(action.action).toBe("downloadFile");
expect(action.fileId).toBe("F123");
expect(action.channelId).toBe("C1");
expect(action.threadId).toBe("111.222");
expectForwardedCfg(invoke, cfg);
});
it("forwards tool context for current-channel download-file actions", async () => {
const invoke = createInvokeSpy();
const cfg = slackConfig();
const toolContext = { currentChannelId: "C1" };
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "download-file",
cfg,
toolContext,
params: {
fileId: "F123",
},
} as never,
invoke: invoke as never,
});
const action = firstAction(invoke);
expect(action.action).toBe("downloadFile");
expect(action.fileId).toBe("F123");
expect(action.channelId).toBeUndefined();
expectForwardedCfg(invoke, cfg);
expect(firstInvokeCall(invoke)[2]).toBe(toolContext);
});
it("maps download-file target aliases to scope fields", async () => {
const invoke = createInvokeSpy();
const cfg = slackConfig();
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "download-file",
cfg,
params: {
to: "channel:C2",
fileId: "F999",
replyTo: "333.444",
},
} as never,
invoke: invoke as never,
});
const action = firstAction(invoke);
expect(action.action).toBe("downloadFile");
expect(action.fileId).toBe("F999");
expect(action.channelId).toBe("channel:C2");
expect(action.threadId).toBe("333.444");
expectForwardedCfg(invoke, cfg);
});
it("explains that download-file requires fileId, not messageId", async () => {
await expect(
handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "download-file",
cfg: {},
params: {
channelId: "C1",
messageId: "1777423717.666499",
},
} as never,
invoke: createInvokeSpy() as never,
}),
).rejects.toThrow(/Did you mean to pass fileId/i);
});
it("explains that download-file requires fileId for message_id aliases", async () => {
await expect(
handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "download-file",
cfg: {},
params: {
channelId: "C1",
message_id: "1777423717.666499",
},
} as never,
invoke: createInvokeSpy() as never,
}),
).rejects.toThrow(/Did you mean to pass fileId/i);
});
it("keeps the generic fileId requirement when no message id was supplied", async () => {
await expect(
handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "download-file",
cfg: {},
params: {
channelId: "C1",
},
} as never,
invoke: createInvokeSpy() as never,
}),
).rejects.toThrow(/fileId/i);
});
it("defaults member-info userId to the inbound sender when omitted", async () => {
const invoke = createInvokeSpy();
const toolContext = { currentChannelProvider: " Slack " };
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "member-info",
cfg: {},
params: {},
accountId: "OPS",
requesterAccountId: "ops",
requesterSenderId: "U123",
toolContext,
} as never,
invoke: invoke as never,
});
expect(invoke).toHaveBeenCalledWith(
expect.objectContaining({ action: "memberInfo", userId: "U123" }),
expect.any(Object),
toolContext,
);
});
it("defaults member-info userId through the configured default Slack account", async () => {
const invoke = createInvokeSpy();
const toolContext = { currentChannelProvider: "slack" };
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "member-info",
cfg: { channels: { slack: { defaultAccount: "ops", accounts: { ops: {} } } } },
params: {},
requesterAccountId: "OPS",
requesterSenderId: "U123",
toolContext,
} as never,
invoke: invoke as never,
});
expect(invoke).toHaveBeenCalledWith(
expect.objectContaining({ action: "memberInfo", userId: "U123" }),
expect.any(Object),
toolContext,
);
});
it.each([
["has no inbound sender", { toolContext: { currentChannelProvider: "slack" } }],
["has no source provider", { requesterSenderId: "U123" }],
[
"has no source account",
{
accountId: "default",
requesterSenderId: "U123",
toolContext: { currentChannelProvider: "slack" },
},
],
[
"targets another Slack account",
{
accountId: "other",
requesterAccountId: "default",
requesterSenderId: "U123",
toolContext: { currentChannelProvider: "slack" },
},
],
[
"comes from another provider",
{ requesterSenderId: "U123", toolContext: { currentChannelProvider: "telegram" } },
],
])("rejects member-info without userId when the request %s", async (_label, context) => {
await expect(
handleSlackMessageAction({
providerId: "slack",
ctx: { action: "member-info", cfg: {}, params: {}, ...context } as never,
invoke: createInvokeSpy() as never,
}),
).rejects.toThrow(/member-info requires a userId/i);
});
it("prefers an explicit member-info userId over the inbound sender", async () => {
const invoke = createInvokeSpy();
const toolContext = { currentChannelProvider: "telegram" };
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "member-info",
cfg: {},
params: { userId: "U999" },
accountId: "other",
requesterAccountId: "default",
requesterSenderId: "U123",
toolContext,
} as never,
invoke: invoke as never,
});
expect(invoke).toHaveBeenCalledWith(
expect.objectContaining({ action: "memberInfo", userId: "U999" }),
expect.any(Object),
toolContext,
);
});
});
describe("extractSlackToolSend", () => {
it("maps native thread and top-level fields into send telemetry", () => {
expect(
extractSlackToolSend({
action: "sendMessage",
to: "channel:C1",
threadTs: "171.222",
}),
).toMatchObject({
to: "channel:C1",
threadId: "171.222",
});
expect(
extractSlackToolSend({
action: "sendMessage",
to: "channel:C1",
}),
).toMatchObject({
to: "channel:C1",
threadImplicit: true,
});
expect(
extractSlackToolSend({
action: "uploadFile",
to: "channel:C1",
}),
).toMatchObject({
to: "channel:C1",
threadImplicit: true,
});
expect(
extractSlackToolSend({
action: "sendMessage",
to: "channel:C1",
threadTs: null,
}),
).toMatchObject({
to: "channel:C1",
threadSuppressed: true,
});
});
it("maps generic send and upload thread precedence into telemetry", () => {
expect(
extractSlackToolSend({
action: "send",
to: "channel:C1",
threadId: "111.000",
replyTo: "999.000",
}),
).toMatchObject({
to: "channel:C1",
threadId: "999.000",
});
expect(
extractSlackToolSend({
action: "upload-file",
to: "channel:C1",
threadId: "111.000",
replyTo: "999.000",
}),
).toMatchObject({
to: "channel:C1",
threadId: "111.000",
});
expect(
extractSlackToolSend({
action: "upload-file",
to: "channel:C1",
replyTo: "999.000",
}),
).toMatchObject({
to: "channel:C1",
threadId: "999.000",
});
});
});
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */