refactor(channels): dedupe transport and gateway test scaffolds

This commit is contained in:
Peter Steinberger
2026-02-16 14:52:15 +00:00
parent f717a13039
commit 93ca0ed54f
95 changed files with 4068 additions and 5221 deletions

View File

@@ -24,6 +24,50 @@ beforeEach(() => {
});
describe("monitorSlackProvider tool results", () => {
function setDirectMessageReplyMode(replyToMode: "off" | "all" | "first") {
slackTestState.config = {
messages: {
responsePrefix: "PFX",
ackReaction: "👀",
ackReactionScope: "group-mentions",
},
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
replyToMode,
},
},
};
}
async function runDirectMessageEvent(ts: string, extraEvent: Record<string, unknown> = {}) {
await runSlackMessageOnce(monitorSlackProvider, {
event: {
type: "message",
user: "U1",
text: "hello",
ts,
channel: "C1",
channel_type: "im",
...extraEvent,
},
});
}
async function runChannelThreadReplyEvent() {
await runSlackMessageOnce(monitorSlackProvider, {
event: {
type: "message",
user: "U1",
text: "thread reply",
ts: "123.456",
thread_ts: "111.222",
channel: "C1",
channel_type: "channel",
},
});
}
it("skips tool summaries with responsePrefix", async () => {
replyMock.mockResolvedValue({ text: "final reply" });
@@ -274,7 +318,7 @@ describe("monitorSlackProvider tool results", () => {
});
});
it("accepts channel messages when mentionPatterns match", async () => {
async function expectMentionPatternMessageAccepted(text: string): Promise<void> {
slackTestState.config = {
messages: {
responsePrefix: "PFX",
@@ -293,7 +337,7 @@ describe("monitorSlackProvider tool results", () => {
event: {
type: "message",
user: "U1",
text: "openclaw: hello",
text,
ts: "123",
channel: "C1",
channel_type: "channel",
@@ -302,36 +346,14 @@ describe("monitorSlackProvider tool results", () => {
expect(replyMock).toHaveBeenCalledTimes(1);
expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true);
}
it("accepts channel messages when mentionPatterns match", async () => {
await expectMentionPatternMessageAccepted("openclaw: hello");
});
it("accepts channel messages when mentionPatterns match even if another user is mentioned", async () => {
slackTestState.config = {
messages: {
responsePrefix: "PFX",
groupChat: { mentionPatterns: ["\\bopenclaw\\b"] },
},
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
channels: { C1: { allow: true, requireMention: true } },
},
},
};
replyMock.mockResolvedValue({ text: "hi" });
await runSlackMessageOnce(monitorSlackProvider, {
event: {
type: "message",
user: "U1",
text: "openclaw: hello <@U2>",
ts: "123",
channel: "C1",
channel_type: "channel",
},
});
expect(replyMock).toHaveBeenCalledTimes(1);
expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true);
await expectMentionPatternMessageAccepted("openclaw: hello <@U2>");
});
it("treats replies to bot threads as implicit mentions", async () => {
@@ -419,25 +441,16 @@ describe("monitorSlackProvider tool results", () => {
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
groupPolicy: "open",
replyToMode: "off",
channels: { C1: { allow: true, requireMention: false } },
},
},
};
await runSlackMessageOnce(monitorSlackProvider, {
event: {
type: "message",
user: "U1",
text: "hello",
ts: "123",
thread_ts: "456",
channel: "C1",
channel_type: "im",
},
});
await runChannelThreadReplyEvent();
expect(sendMock).toHaveBeenCalledTimes(1);
expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: "456" });
expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: "111.222" });
});
it("forces thread replies when replyToId is set", async () => {
@@ -571,30 +584,8 @@ describe("monitorSlackProvider tool results", () => {
it("threads top-level replies when replyToMode is all", async () => {
replyMock.mockResolvedValue({ text: "thread reply" });
slackTestState.config = {
messages: {
responsePrefix: "PFX",
ackReaction: "👀",
ackReactionScope: "group-mentions",
},
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
replyToMode: "all",
},
},
};
await runSlackMessageOnce(monitorSlackProvider, {
event: {
type: "message",
user: "U1",
text: "hello",
ts: "123",
channel: "C1",
channel_type: "im",
},
});
setDirectMessageReplyMode("all");
await runDirectMessageEvent("123");
expect(sendMock).toHaveBeenCalledTimes(1);
expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: "123" });
@@ -685,17 +676,7 @@ describe("monitorSlackProvider tool results", () => {
},
};
await runSlackMessageOnce(monitorSlackProvider, {
event: {
type: "message",
user: "U1",
text: "thread reply",
ts: "123.456",
thread_ts: "111.222",
channel: "C1",
channel_type: "channel",
},
});
await runChannelThreadReplyEvent();
expect(replyMock).toHaveBeenCalledTimes(1);
const ctx = replyMock.mock.calls[0]?.[0] as {
@@ -736,17 +717,7 @@ describe("monitorSlackProvider tool results", () => {
});
}
await runSlackMessageOnce(monitorSlackProvider, {
event: {
type: "message",
user: "U1",
text: "thread reply",
ts: "123.456",
thread_ts: "111.222",
channel: "C1",
channel_type: "channel",
},
});
await runChannelThreadReplyEvent();
expect(replyMock).toHaveBeenCalledTimes(1);
const ctx = replyMock.mock.calls[0]?.[0] as {
@@ -759,30 +730,8 @@ describe("monitorSlackProvider tool results", () => {
it("keeps replies in channel root when message is not threaded (replyToMode off)", async () => {
replyMock.mockResolvedValue({ text: "root reply" });
slackTestState.config = {
messages: {
responsePrefix: "PFX",
ackReaction: "👀",
ackReactionScope: "group-mentions",
},
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
replyToMode: "off",
},
},
};
await runSlackMessageOnce(monitorSlackProvider, {
event: {
type: "message",
user: "U1",
text: "hello",
ts: "789",
channel: "C1",
channel_type: "im",
},
});
setDirectMessageReplyMode("off");
await runDirectMessageEvent("789");
expect(sendMock).toHaveBeenCalledTimes(1);
expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: undefined });
@@ -790,30 +739,8 @@ describe("monitorSlackProvider tool results", () => {
it("threads first reply when replyToMode is first and message is not threaded", async () => {
replyMock.mockResolvedValue({ text: "first reply" });
slackTestState.config = {
messages: {
responsePrefix: "PFX",
ackReaction: "👀",
ackReactionScope: "group-mentions",
},
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
replyToMode: "first",
},
},
};
await runSlackMessageOnce(monitorSlackProvider, {
event: {
type: "message",
user: "U1",
text: "hello",
ts: "789",
channel: "C1",
channel_type: "im",
},
});
setDirectMessageReplyMode("first");
await runDirectMessageEvent("789");
expect(sendMock).toHaveBeenCalledTimes(1);
// First reply starts a thread under the incoming message

View File

@@ -38,14 +38,18 @@ describe("slack prepareSlackMessage inbound contract", () => {
}
});
function createDefaultSlackCtx() {
const slackCtx = createSlackMonitorContext({
cfg: {
channels: { slack: { enabled: true } },
} as OpenClawConfig,
function createInboundSlackCtx(params: {
cfg: OpenClawConfig;
appClient?: App["client"];
defaultRequireMention?: boolean;
replyToMode?: "off" | "all";
channelsConfig?: Record<string, { systemPrompt: string }>;
}) {
return createSlackMonitorContext({
cfg: params.cfg,
accountId: "default",
botToken: "token",
app: { client: {} } as App,
app: { client: params.appClient ?? {} } as App,
runtime: {} as RuntimeEnv,
botUserId: "B1",
teamId: "T1",
@@ -58,12 +62,13 @@ describe("slack prepareSlackMessage inbound contract", () => {
allowFrom: [],
groupDmEnabled: true,
groupDmChannels: [],
defaultRequireMention: true,
defaultRequireMention: params.defaultRequireMention ?? true,
channelsConfig: params.channelsConfig,
groupPolicy: "open",
useAccessGroups: false,
reactionMode: "off",
reactionAllowlist: [],
replyToMode: "off",
replyToMode: params.replyToMode ?? "off",
threadHistoryScope: "thread",
threadInheritParent: false,
slashCommand: {
@@ -77,6 +82,14 @@ describe("slack prepareSlackMessage inbound contract", () => {
mediaMaxBytes: 1024,
removeAckAfterReply: false,
});
}
function createDefaultSlackCtx() {
const slackCtx = createInboundSlackCtx({
cfg: {
channels: { slack: { enabled: true } },
} as OpenClawConfig,
});
// oxlint-disable-next-line typescript/no-explicit-any
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
return slackCtx;
@@ -100,41 +113,11 @@ describe("slack prepareSlackMessage inbound contract", () => {
}
function createThreadSlackCtx(params: { cfg: OpenClawConfig; replies: unknown }) {
return createSlackMonitorContext({
return createInboundSlackCtx({
cfg: params.cfg,
accountId: "default",
botToken: "token",
app: { client: { conversations: { replies: params.replies } } } as App,
runtime: {} as RuntimeEnv,
botUserId: "B1",
teamId: "T1",
apiAppId: "A1",
historyLimit: 0,
sessionScope: "per-sender",
mainKey: "main",
dmEnabled: true,
dmPolicy: "open",
allowFrom: [],
groupDmEnabled: true,
groupDmChannels: [],
appClient: { conversations: { replies: params.replies } } as App["client"],
defaultRequireMention: false,
groupPolicy: "open",
useAccessGroups: false,
reactionMode: "off",
reactionAllowlist: [],
replyToMode: "all",
threadHistoryScope: "thread",
threadInheritParent: false,
slashCommand: {
enabled: false,
name: "openclaw",
sessionPrefix: "slack:slash",
ephemeral: true,
},
textLimit: 4000,
ackReactionScope: "group-mentions",
mediaMaxBytes: 1024,
removeAckAfterReply: false,
});
}
@@ -168,7 +151,7 @@ describe("slack prepareSlackMessage inbound contract", () => {
});
it("keeps channel metadata out of GroupSystemPrompt", async () => {
const slackCtx = createSlackMonitorContext({
const slackCtx = createInboundSlackCtx({
cfg: {
channels: {
slack: {
@@ -176,42 +159,10 @@ describe("slack prepareSlackMessage inbound contract", () => {
},
},
} as OpenClawConfig,
accountId: "default",
botToken: "token",
app: { client: {} } as App,
runtime: {} as RuntimeEnv,
botUserId: "B1",
teamId: "T1",
apiAppId: "A1",
historyLimit: 0,
sessionScope: "per-sender",
mainKey: "main",
dmEnabled: true,
dmPolicy: "open",
allowFrom: [],
groupDmEnabled: true,
groupDmChannels: [],
defaultRequireMention: false,
channelsConfig: {
C123: { systemPrompt: "Config prompt" },
},
groupPolicy: "open",
useAccessGroups: false,
reactionMode: "off",
reactionAllowlist: [],
replyToMode: "off",
threadHistoryScope: "thread",
threadInheritParent: false,
slashCommand: {
enabled: false,
name: "openclaw",
sessionPrefix: "slack:slash",
ephemeral: true,
},
textLimit: 4000,
ackReactionScope: "group-mentions",
mediaMaxBytes: 1024,
removeAckAfterReply: false,
});
// oxlint-disable-next-line typescript/no-explicit-any
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
@@ -256,43 +207,11 @@ describe("slack prepareSlackMessage inbound contract", () => {
});
it("sets MessageThreadId for top-level messages when replyToMode=all", async () => {
const slackCtx = createSlackMonitorContext({
const slackCtx = createInboundSlackCtx({
cfg: {
channels: { slack: { enabled: true, replyToMode: "all" } },
} as OpenClawConfig,
accountId: "default",
botToken: "token",
app: { client: {} } as App,
runtime: {} as RuntimeEnv,
botUserId: "B1",
teamId: "T1",
apiAppId: "A1",
historyLimit: 0,
sessionScope: "per-sender",
mainKey: "main",
dmEnabled: true,
dmPolicy: "open",
allowFrom: [],
groupDmEnabled: true,
groupDmChannels: [],
defaultRequireMention: true,
groupPolicy: "open",
useAccessGroups: false,
reactionMode: "off",
reactionAllowlist: [],
replyToMode: "all",
threadHistoryScope: "thread",
threadInheritParent: false,
slashCommand: {
enabled: false,
name: "openclaw",
sessionPrefix: "slack:slash",
ephemeral: true,
},
textLimit: 4000,
ackReactionScope: "group-mentions",
mediaMaxBytes: 1024,
removeAckAfterReply: false,
});
// oxlint-disable-next-line typescript/no-explicit-any
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
@@ -487,11 +406,16 @@ describe("slack prepareSlackMessage inbound contract", () => {
});
describe("prepareSlackMessage sender prefix", () => {
it("prefixes channel bodies with sender label", async () => {
const ctx = {
function createSenderPrefixCtx(params: {
channels: Record<string, unknown>;
allowFrom?: string[];
useAccessGroups?: boolean;
slashCommand: Record<string, unknown>;
}): SlackMonitorContext {
return {
cfg: {
agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } },
channels: { slack: {} },
channels: { slack: params.channels },
},
accountId: "default",
botToken: "xoxb",
@@ -512,18 +436,18 @@ describe("prepareSlackMessage sender prefix", () => {
mainKey: "agent:main:main",
dmEnabled: true,
dmPolicy: "open",
allowFrom: [],
allowFrom: params.allowFrom ?? [],
groupDmEnabled: false,
groupDmChannels: [],
defaultRequireMention: true,
groupPolicy: "open",
useAccessGroups: false,
useAccessGroups: params.useAccessGroups ?? false,
reactionMode: "off",
reactionAllowlist: [],
replyToMode: "off",
threadHistoryScope: "channel",
threadInheritParent: false,
slashCommand: { command: "/openclaw", enabled: true },
slashCommand: params.slashCommand,
textLimit: 2000,
ackReactionScope: "off",
mediaMaxBytes: 1000,
@@ -533,13 +457,17 @@ describe("prepareSlackMessage sender prefix", () => {
shouldDropMismatchedSlackEvent: () => false,
resolveSlackSystemEventSessionKey: () => "agent:main:slack:channel:c1",
isChannelAllowed: () => true,
resolveChannelName: async () => ({
name: "general",
type: "channel",
}),
resolveChannelName: async () => ({ name: "general", type: "channel" }),
resolveUserName: async () => ({ name: "Alice" }),
setSlackThreadStatus: async () => undefined,
} satisfies SlackMonitorContext;
} as unknown as SlackMonitorContext;
}
it("prefixes channel bodies with sender label", async () => {
const ctx = createSenderPrefixCtx({
channels: {},
slashCommand: { command: "/openclaw", enabled: true },
});
const result = await prepareSlackMessage({
ctx,
@@ -562,60 +490,17 @@ describe("prepareSlackMessage sender prefix", () => {
});
it("detects /new as control command when prefixed with Slack mention", async () => {
const ctx = {
cfg: {
agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } },
channels: { slack: { dm: { enabled: true, policy: "open", allowFrom: ["*"] } } },
},
accountId: "default",
botToken: "xoxb",
app: { client: {} },
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
},
botUserId: "BOT",
teamId: "T1",
apiAppId: "A1",
historyLimit: 0,
channelHistories: new Map(),
sessionScope: "per-sender",
mainKey: "agent:main:main",
dmEnabled: true,
dmPolicy: "open",
const ctx = createSenderPrefixCtx({
channels: { dm: { enabled: true, policy: "open", allowFrom: ["*"] } },
allowFrom: ["U1"],
groupDmEnabled: false,
groupDmChannels: [],
defaultRequireMention: true,
groupPolicy: "open",
useAccessGroups: true,
reactionMode: "off",
reactionAllowlist: [],
replyToMode: "off",
threadHistoryScope: "channel",
threadInheritParent: false,
slashCommand: {
enabled: false,
name: "openclaw",
sessionPrefix: "slack:slash",
ephemeral: true,
},
textLimit: 2000,
ackReactionScope: "off",
mediaMaxBytes: 1000,
removeAckAfterReply: false,
logger: { info: vi.fn(), warn: vi.fn() },
markMessageSeen: () => false,
shouldDropMismatchedSlackEvent: () => false,
resolveSlackSystemEventSessionKey: () => "agent:main:slack:channel:c1",
isChannelAllowed: () => true,
resolveChannelName: async () => ({ name: "general", type: "channel" }),
resolveUserName: async () => ({ name: "Alice" }),
setSlackThreadStatus: async () => undefined,
} satisfies SlackMonitorContext;
});
const result = await prepareSlackMessage({
ctx,

View File

@@ -129,12 +129,19 @@ function createArgMenusHarness() {
describe("Slack native command argument menus", () => {
let harness: ReturnType<typeof createArgMenusHarness>;
let usageHandler: (args: unknown) => Promise<void>;
let argMenuHandler: (args: unknown) => Promise<void>;
beforeAll(async () => {
harness = createArgMenusHarness();
await registerCommands(harness.ctx, harness.account);
const usage = harness.commands.get("/usage");
if (!usage) {
throw new Error("Missing /usage handler");
}
usageHandler = usage;
const argMenu = harness.actions.get("openclaw_cmdarg");
if (!argMenu) {
throw new Error("Missing arg-menu action handler");
@@ -146,6 +153,29 @@ describe("Slack native command argument menus", () => {
harness.postEphemeral.mockClear();
});
it("shows a button menu when required args are omitted", async () => {
const respond = vi.fn().mockResolvedValue(undefined);
const ack = vi.fn().mockResolvedValue(undefined);
await usageHandler({
command: {
user_id: "U1",
user_name: "Ada",
channel_id: "C1",
channel_name: "directmessage",
text: "",
trigger_id: "t1",
},
ack,
respond,
});
expect(respond).toHaveBeenCalledTimes(1);
const payload = respond.mock.calls[0]?.[0] as { blocks?: Array<{ type: string }> };
expect(payload.blocks?.[0]?.type).toBe("section");
expect(payload.blocks?.[1]?.type).toBe("actions");
});
it("dispatches the command when a menu button is clicked", async () => {
const respond = vi.fn().mockResolvedValue(undefined);
await argMenuHandler({
@@ -187,4 +217,303 @@ describe("Slack native command argument menus", () => {
response_type: "ephemeral",
});
});
it("falls back to postEphemeral with token when respond is unavailable", async () => {
await argMenuHandler({
ack: vi.fn().mockResolvedValue(undefined),
action: { value: "garbage" },
body: { user: { id: "U1" }, channel: { id: "C1" } },
});
expect(harness.postEphemeral).toHaveBeenCalledWith(
expect.objectContaining({
token: "bot-token",
channel: "C1",
user: "U1",
}),
);
});
it("treats malformed percent-encoding as an invalid button (no throw)", async () => {
await argMenuHandler({
ack: vi.fn().mockResolvedValue(undefined),
action: { value: "cmdarg|%E0%A4%A|mode|on|U1" },
body: { user: { id: "U1" }, channel: { id: "C1" } },
});
expect(harness.postEphemeral).toHaveBeenCalledWith(
expect.objectContaining({
token: "bot-token",
channel: "C1",
user: "U1",
text: "Sorry, that button is no longer valid.",
}),
);
});
});
function createPolicyHarness(overrides?: {
groupPolicy?: "open" | "allowlist";
channelsConfig?: Record<string, { allow?: boolean; requireMention?: boolean }>;
channelId?: string;
channelName?: string;
allowFrom?: string[];
useAccessGroups?: boolean;
resolveChannelName?: () => Promise<{ name?: string; type?: string }>;
}) {
const commands = new Map<unknown, (args: unknown) => Promise<void>>();
const postEphemeral = vi.fn().mockResolvedValue({ ok: true });
const app = {
client: { chat: { postEphemeral } },
command: (name: unknown, handler: (args: unknown) => Promise<void>) => {
commands.set(name, handler);
},
};
const channelId = overrides?.channelId ?? "C_UNLISTED";
const channelName = overrides?.channelName ?? "unlisted";
const ctx = {
cfg: { commands: { native: false } },
runtime: {},
botToken: "bot-token",
botUserId: "bot",
teamId: "T1",
allowFrom: overrides?.allowFrom ?? ["*"],
dmEnabled: true,
dmPolicy: "open",
groupDmEnabled: false,
groupDmChannels: [],
defaultRequireMention: true,
groupPolicy: overrides?.groupPolicy ?? "open",
useAccessGroups: overrides?.useAccessGroups ?? true,
channelsConfig: overrides?.channelsConfig,
slashCommand: {
enabled: true,
name: "openclaw",
ephemeral: true,
sessionPrefix: "slack:slash",
},
textLimit: 4000,
app,
isChannelAllowed: () => true,
resolveChannelName:
overrides?.resolveChannelName ?? (async () => ({ name: channelName, type: "channel" })),
resolveUserName: async () => ({ name: "Ada" }),
} as unknown;
const account = { accountId: "acct", config: { commands: { native: false } } } as unknown;
return { commands, ctx, account, postEphemeral, channelId, channelName };
}
async function runSlashHandler(params: {
commands: Map<unknown, (args: unknown) => Promise<void>>;
command: Partial<{
user_id: string;
user_name: string;
channel_id: string;
channel_name: string;
text: string;
trigger_id: string;
}> &
Pick<{ channel_id: string; channel_name: string }, "channel_id" | "channel_name">;
}): Promise<{ respond: ReturnType<typeof vi.fn>; ack: ReturnType<typeof vi.fn> }> {
const handler = [...params.commands.values()][0];
if (!handler) {
throw new Error("Missing slash handler");
}
const respond = vi.fn().mockResolvedValue(undefined);
const ack = vi.fn().mockResolvedValue(undefined);
await handler({
command: {
user_id: "U1",
user_name: "Ada",
text: "hello",
trigger_id: "t1",
...params.command,
},
ack,
respond,
});
return { respond, ack };
}
function expectChannelBlockedResponse(respond: ReturnType<typeof vi.fn>) {
expect(dispatchMock).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith({
text: "This channel is not allowed.",
response_type: "ephemeral",
});
}
function expectUnauthorizedResponse(respond: ReturnType<typeof vi.fn>) {
expect(dispatchMock).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith({
text: "You are not authorized to use this command.",
response_type: "ephemeral",
});
}
describe("slack slash commands channel policy", () => {
it("allows unlisted channels when groupPolicy is open", async () => {
const { commands, ctx, account, channelId, channelName } = createPolicyHarness({
groupPolicy: "open",
channelsConfig: { C_LISTED: { requireMention: true } },
channelId: "C_UNLISTED",
channelName: "unlisted",
});
await registerCommands(ctx, account);
const { respond } = await runSlashHandler({
commands,
command: {
channel_id: channelId,
channel_name: channelName,
},
});
expect(dispatchMock).toHaveBeenCalledTimes(1);
expect(respond).not.toHaveBeenCalledWith(
expect.objectContaining({ text: "This channel is not allowed." }),
);
});
it("blocks explicitly denied channels when groupPolicy is open", async () => {
const { commands, ctx, account, channelId, channelName } = createPolicyHarness({
groupPolicy: "open",
channelsConfig: { C_DENIED: { allow: false } },
channelId: "C_DENIED",
channelName: "denied",
});
await registerCommands(ctx, account);
const { respond } = await runSlashHandler({
commands,
command: {
channel_id: channelId,
channel_name: channelName,
},
});
expectChannelBlockedResponse(respond);
});
it("blocks unlisted channels when groupPolicy is allowlist", async () => {
const { commands, ctx, account, channelId, channelName } = createPolicyHarness({
groupPolicy: "allowlist",
channelsConfig: { C_LISTED: { requireMention: true } },
channelId: "C_UNLISTED",
channelName: "unlisted",
});
await registerCommands(ctx, account);
const { respond } = await runSlashHandler({
commands,
command: {
channel_id: channelId,
channel_name: channelName,
},
});
expectChannelBlockedResponse(respond);
});
});
describe("slack slash commands access groups", () => {
it("fails closed when channel type lookup returns empty for channels", async () => {
const { commands, ctx, account, channelId, channelName } = createPolicyHarness({
allowFrom: [],
channelId: "C_UNKNOWN",
channelName: "unknown",
resolveChannelName: async () => ({}),
});
await registerCommands(ctx, account);
const { respond } = await runSlashHandler({
commands,
command: {
channel_id: channelId,
channel_name: channelName,
},
});
expectUnauthorizedResponse(respond);
});
it("still treats D-prefixed channel ids as DMs when lookup fails", async () => {
const { commands, ctx, account } = createPolicyHarness({
allowFrom: [],
channelId: "D123",
channelName: "notdirectmessage",
resolveChannelName: async () => ({}),
});
await registerCommands(ctx, account);
const { respond } = await runSlashHandler({
commands,
command: {
channel_id: "D123",
channel_name: "notdirectmessage",
},
});
expect(dispatchMock).toHaveBeenCalledTimes(1);
expect(respond).not.toHaveBeenCalledWith(
expect.objectContaining({ text: "You are not authorized to use this command." }),
);
const dispatchArg = dispatchMock.mock.calls[0]?.[0] as {
ctx?: { CommandAuthorized?: boolean };
};
expect(dispatchArg?.ctx?.CommandAuthorized).toBe(false);
});
it("computes CommandAuthorized for DM slash commands when dmPolicy is open", async () => {
const { commands, ctx, account } = createPolicyHarness({
allowFrom: ["U_OWNER"],
channelId: "D999",
channelName: "directmessage",
resolveChannelName: async () => ({ name: "directmessage", type: "im" }),
});
await registerCommands(ctx, account);
await runSlashHandler({
commands,
command: {
user_id: "U_ATTACKER",
user_name: "Mallory",
channel_id: "D999",
channel_name: "directmessage",
},
});
expect(dispatchMock).toHaveBeenCalledTimes(1);
const dispatchArg = dispatchMock.mock.calls[0]?.[0] as {
ctx?: { CommandAuthorized?: boolean };
};
expect(dispatchArg?.ctx?.CommandAuthorized).toBe(false);
});
it("enforces access-group gating when lookup fails for private channels", async () => {
const { commands, ctx, account, channelId, channelName } = createPolicyHarness({
allowFrom: [],
channelId: "G123",
channelName: "private",
resolveChannelName: async () => ({}),
});
await registerCommands(ctx, account);
const { respond } = await runSlashHandler({
commands,
command: {
channel_id: channelId,
channel_name: channelName,
},
});
expectUnauthorizedResponse(respond);
});
});

View File

@@ -1,6 +1,7 @@
import type { WebClient as SlackWebClient } from "@slack/web-api";
import type { SlackMessageEvent } from "../types.js";
import { logVerbose, shouldLogVerbose } from "../../globals.js";
import { pruneMapToMaxSize } from "../../infra/map-size.js";
type ThreadTsCacheEntry = {
threadTs: string | null;
@@ -68,17 +69,7 @@ export function createSlackThreadTsResolver(params: {
const setCached = (key: string, threadTs: string | null, now: number) => {
cache.delete(key);
cache.set(key, { threadTs, updatedAt: now });
if (maxSize <= 0) {
cache.clear();
return;
}
while (cache.size > maxSize) {
const oldestKey = cache.keys().next().value;
if (!oldestKey) {
break;
}
cache.delete(oldestKey);
}
pruneMapToMaxSize(cache, maxSize);
};
return {