diff --git a/src/slack/monitor/channel-config.test.ts b/src/slack/monitor/channel-config.test.ts deleted file mode 100644 index 9303605a99d..00000000000 --- a/src/slack/monitor/channel-config.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveSlackChannelConfig } from "./channel-config.js"; - -describe("resolveSlackChannelConfig", () => { - it("uses defaultRequireMention when channels config is empty", () => { - const res = resolveSlackChannelConfig({ - channelId: "C1", - channels: {}, - defaultRequireMention: false, - }); - expect(res).toEqual({ allowed: true, requireMention: false }); - }); - - it("defaults defaultRequireMention to true when not provided", () => { - const res = resolveSlackChannelConfig({ - channelId: "C1", - channels: {}, - }); - expect(res).toEqual({ allowed: true, requireMention: true }); - }); - - it("prefers explicit channel/fallback requireMention over defaultRequireMention", () => { - const res = resolveSlackChannelConfig({ - channelId: "C1", - channels: { "*": { requireMention: true } }, - defaultRequireMention: false, - }); - expect(res).toMatchObject({ requireMention: true }); - }); - - it("uses wildcard entries when no direct channel config exists", () => { - const res = resolveSlackChannelConfig({ - channelId: "C1", - channels: { "*": { allow: true, requireMention: false } }, - defaultRequireMention: true, - }); - expect(res).toMatchObject({ - allowed: true, - requireMention: false, - matchKey: "*", - matchSource: "wildcard", - }); - }); - - it("uses direct match metadata when channel config exists", () => { - const res = resolveSlackChannelConfig({ - channelId: "C1", - channels: { C1: { allow: true, requireMention: false } }, - defaultRequireMention: true, - }); - expect(res).toMatchObject({ - matchKey: "C1", - matchSource: "direct", - }); - }); -}); diff --git a/src/slack/monitor/context.test.ts b/src/slack/monitor/context.test.ts deleted file mode 100644 index 0afde23461c..00000000000 --- a/src/slack/monitor/context.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import type { App } from "@slack/bolt"; -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import type { RuntimeEnv } from "../../runtime.js"; -import { createSlackMonitorContext, normalizeSlackChannelType } from "./context.js"; - -const baseParams = () => ({ - cfg: {} as OpenClawConfig, - accountId: "default", - botToken: "token", - app: { client: {} } as App, - runtime: {} as RuntimeEnv, - botUserId: "B1", - teamId: "T1", - apiAppId: "A1", - historyLimit: 0, - sessionScope: "per-sender" as const, - mainKey: "main", - dmEnabled: true, - dmPolicy: "open" as const, - allowFrom: [], - groupDmEnabled: true, - groupDmChannels: [], - defaultRequireMention: true, - groupPolicy: "open" as const, - useAccessGroups: false, - reactionMode: "off" as const, - reactionAllowlist: [], - replyToMode: "off" as const, - slashCommand: { - enabled: false, - name: "openclaw", - sessionPrefix: "slack:slash", - ephemeral: true, - }, - textLimit: 4000, - ackReactionScope: "group-mentions", - mediaMaxBytes: 1, - removeAckAfterReply: false, -}); - -describe("normalizeSlackChannelType", () => { - it("infers channel types from ids when missing", () => { - expect(normalizeSlackChannelType(undefined, "C123")).toBe("channel"); - expect(normalizeSlackChannelType(undefined, "D123")).toBe("im"); - expect(normalizeSlackChannelType(undefined, "G123")).toBe("group"); - }); - - it("prefers explicit channel_type values", () => { - expect(normalizeSlackChannelType("mpim", "C123")).toBe("mpim"); - }); -}); - -describe("resolveSlackSystemEventSessionKey", () => { - it("defaults missing channel_type to channel sessions", () => { - const ctx = createSlackMonitorContext(baseParams()); - expect(ctx.resolveSlackSystemEventSessionKey({ channelId: "C123" })).toBe( - "agent:main:slack:channel:c123", - ); - }); -}); - -describe("isChannelAllowed with groupPolicy and channelsConfig", () => { - it("allows unlisted channels when groupPolicy is open even with channelsConfig entries", () => { - // Bug fix: when groupPolicy="open" and channels has some entries, - // unlisted channels should still be allowed (not blocked) - const ctx = createSlackMonitorContext({ - ...baseParams(), - groupPolicy: "open", - channelsConfig: { - C_LISTED: { requireMention: true }, - }, - }); - // Listed channel should be allowed - expect(ctx.isChannelAllowed({ channelId: "C_LISTED", channelType: "channel" })).toBe(true); - // Unlisted channel should ALSO be allowed when policy is "open" - expect(ctx.isChannelAllowed({ channelId: "C_UNLISTED", channelType: "channel" })).toBe(true); - }); - - it("blocks unlisted channels when groupPolicy is allowlist", () => { - const ctx = createSlackMonitorContext({ - ...baseParams(), - groupPolicy: "allowlist", - channelsConfig: { - C_LISTED: { requireMention: true }, - }, - }); - // Listed channel should be allowed - expect(ctx.isChannelAllowed({ channelId: "C_LISTED", channelType: "channel" })).toBe(true); - // Unlisted channel should be blocked when policy is "allowlist" - expect(ctx.isChannelAllowed({ channelId: "C_UNLISTED", channelType: "channel" })).toBe(false); - }); - - it("blocks explicitly denied channels even when groupPolicy is open", () => { - const ctx = createSlackMonitorContext({ - ...baseParams(), - groupPolicy: "open", - channelsConfig: { - C_ALLOWED: { allow: true }, - C_DENIED: { allow: false }, - }, - }); - // Explicitly allowed channel - expect(ctx.isChannelAllowed({ channelId: "C_ALLOWED", channelType: "channel" })).toBe(true); - // Explicitly denied channel should be blocked even with open policy - expect(ctx.isChannelAllowed({ channelId: "C_DENIED", channelType: "channel" })).toBe(false); - // Unlisted channel should be allowed with open policy - expect(ctx.isChannelAllowed({ channelId: "C_UNLISTED", channelType: "channel" })).toBe(true); - }); - - it("allows all channels when groupPolicy is open and channelsConfig is empty", () => { - const ctx = createSlackMonitorContext({ - ...baseParams(), - groupPolicy: "open", - channelsConfig: undefined, - }); - expect(ctx.isChannelAllowed({ channelId: "C_ANY", channelType: "channel" })).toBe(true); - }); -}); diff --git a/src/slack/monitor/media.thread-starter-cache.test.ts b/src/slack/monitor/media.thread-starter-cache.test.ts deleted file mode 100644 index 45278acfe62..00000000000 --- a/src/slack/monitor/media.thread-starter-cache.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { resetSlackThreadStarterCacheForTest, resolveSlackThreadStarter } from "./media.js"; - -describe("resolveSlackThreadStarter cache", () => { - afterEach(() => { - resetSlackThreadStarterCacheForTest(); - vi.useRealTimers(); - }); - - it("returns cached thread starter without refetching within ttl", async () => { - const replies = vi.fn(async () => ({ - messages: [{ text: "root message", user: "U1", ts: "1000.1" }], - })); - const client = { - conversations: { replies }, - } as unknown as Parameters[0]["client"]; - - const first = await resolveSlackThreadStarter({ - channelId: "C1", - threadTs: "1000.1", - client, - }); - const second = await resolveSlackThreadStarter({ - channelId: "C1", - threadTs: "1000.1", - client, - }); - - expect(first).toEqual(second); - expect(replies).toHaveBeenCalledTimes(1); - }); - - it("expires stale cache entries and refetches after ttl", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); - - const replies = vi.fn(async () => ({ - messages: [{ text: "root message", user: "U1", ts: "1000.1" }], - })); - const client = { - conversations: { replies }, - } as unknown as Parameters[0]["client"]; - - await resolveSlackThreadStarter({ - channelId: "C1", - threadTs: "1000.1", - client, - }); - - vi.setSystemTime(new Date("2026-01-01T07:00:00.000Z")); - await resolveSlackThreadStarter({ - channelId: "C1", - threadTs: "1000.1", - client, - }); - - expect(replies).toHaveBeenCalledTimes(2); - }); - - it("evicts oldest entries once cache exceeds bounded size", async () => { - const replies = vi.fn(async () => ({ - messages: [{ text: "root message", user: "U1", ts: "1000.1" }], - })); - const client = { - conversations: { replies }, - } as unknown as Parameters[0]["client"]; - - // Cache cap is 2000; add enough distinct keys to force eviction of earliest keys. - for (let i = 0; i <= 2000; i += 1) { - await resolveSlackThreadStarter({ - channelId: "C1", - threadTs: `1000.${i}`, - client, - }); - } - const callsAfterFill = replies.mock.calls.length; - - // Oldest key should be evicted and require fetch again. - await resolveSlackThreadStarter({ - channelId: "C1", - threadTs: "1000.0", - client, - }); - - expect(replies.mock.calls.length).toBe(callsAfterFill + 1); - }); -}); diff --git a/src/slack/monitor/message-handler/prepare.sender-prefix.test.ts b/src/slack/monitor/message-handler/prepare.sender-prefix.test.ts deleted file mode 100644 index 30cfdc1ef9d..00000000000 --- a/src/slack/monitor/message-handler/prepare.sender-prefix.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { SlackMonitorContext } from "../context.js"; -import { prepareSlackMessage } from "./prepare.js"; - -describe("prepareSlackMessage sender prefix", () => { - it("prefixes channel bodies with sender label", async () => { - const ctx = { - cfg: { - agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } }, - channels: { slack: {} }, - }, - 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", - allowFrom: [], - groupDmEnabled: false, - groupDmChannels: [], - defaultRequireMention: true, - groupPolicy: "open", - useAccessGroups: false, - reactionMode: "off", - reactionAllowlist: [], - replyToMode: "off", - threadHistoryScope: "channel", - threadInheritParent: false, - slashCommand: { command: "/openclaw", enabled: 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, - account: { accountId: "default", config: {} } as never, - message: { - type: "message", - channel: "C1", - channel_type: "channel", - text: "<@BOT> hello", - user: "U1", - ts: "1700000000.0001", - event_ts: "1700000000.0001", - } as never, - opts: { source: "message", wasMentioned: true }, - }); - - expect(result).not.toBeNull(); - const body = result?.ctxPayload.Body ?? ""; - expect(body).toContain("Alice (U1): <@BOT> hello"); - }); - - 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", - 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, - account: { accountId: "default", config: {} } as never, - message: { - type: "message", - channel: "C1", - channel_type: "channel", - text: "<@BOT> /new", - user: "U1", - ts: "1700000000.0002", - event_ts: "1700000000.0002", - } as never, - opts: { source: "message", wasMentioned: true }, - }); - - expect(result).not.toBeNull(); - expect(result?.ctxPayload.CommandAuthorized).toBe(true); - }); -}); diff --git a/src/slack/monitor/message-handler/prepare.inbound-contract.test.ts b/src/slack/monitor/message-handler/prepare.test.ts similarity index 75% rename from src/slack/monitor/message-handler/prepare.inbound-contract.test.ts rename to src/slack/monitor/message-handler/prepare.test.ts index 4e2023da6a2..91c9670e76b 100644 --- a/src/slack/monitor/message-handler/prepare.inbound-contract.test.ts +++ b/src/slack/monitor/message-handler/prepare.test.ts @@ -7,6 +7,7 @@ import type { OpenClawConfig } from "../../../config/config.js"; import type { RuntimeEnv } from "../../../runtime.js"; import type { ResolvedSlackAccount } from "../../accounts.js"; import type { SlackMessageEvent } from "../../types.js"; +import type { SlackMonitorContext } from "../context.js"; import { expectInboundContextContract } from "../../../../test/helpers/inbound-contract.js"; import { resolveAgentRoute } from "../../../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../../../routing/session-key.js"; @@ -484,3 +485,154 @@ describe("slack prepareSlackMessage inbound contract", () => { expect(prepared!.ctxPayload.Body).not.toContain("parent_user_id"); }); }); + +describe("prepareSlackMessage sender prefix", () => { + it("prefixes channel bodies with sender label", async () => { + const ctx = { + cfg: { + agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } }, + channels: { slack: {} }, + }, + 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", + allowFrom: [], + groupDmEnabled: false, + groupDmChannels: [], + defaultRequireMention: true, + groupPolicy: "open", + useAccessGroups: false, + reactionMode: "off", + reactionAllowlist: [], + replyToMode: "off", + threadHistoryScope: "channel", + threadInheritParent: false, + slashCommand: { command: "/openclaw", enabled: 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, + account: { accountId: "default", config: {} } as never, + message: { + type: "message", + channel: "C1", + channel_type: "channel", + text: "<@BOT> hello", + user: "U1", + ts: "1700000000.0001", + event_ts: "1700000000.0001", + } as never, + opts: { source: "message", wasMentioned: true }, + }); + + expect(result).not.toBeNull(); + const body = result?.ctxPayload.Body ?? ""; + expect(body).toContain("Alice (U1): <@BOT> hello"); + }); + + 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", + 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, + account: { accountId: "default", config: {} } as never, + message: { + type: "message", + channel: "C1", + channel_type: "channel", + text: "<@BOT> /new", + user: "U1", + ts: "1700000000.0002", + event_ts: "1700000000.0002", + } as never, + opts: { source: "message", wasMentioned: true }, + }); + + expect(result).not.toBeNull(); + expect(result?.ctxPayload.CommandAuthorized).toBe(true); + }); +}); diff --git a/src/slack/monitor/monitor.test.ts b/src/slack/monitor/monitor.test.ts new file mode 100644 index 00000000000..0194642f799 --- /dev/null +++ b/src/slack/monitor/monitor.test.ts @@ -0,0 +1,289 @@ +import type { App } from "@slack/bolt"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { RuntimeEnv } from "../../runtime.js"; +import type { SlackMessageEvent } from "../types.js"; +import { resolveSlackChannelConfig } from "./channel-config.js"; +import { createSlackMonitorContext, normalizeSlackChannelType } from "./context.js"; +import { resetSlackThreadStarterCacheForTest, resolveSlackThreadStarter } from "./media.js"; +import { createSlackThreadTsResolver } from "./thread-resolution.js"; + +describe("resolveSlackChannelConfig", () => { + it("uses defaultRequireMention when channels config is empty", () => { + const res = resolveSlackChannelConfig({ + channelId: "C1", + channels: {}, + defaultRequireMention: false, + }); + expect(res).toEqual({ allowed: true, requireMention: false }); + }); + + it("defaults defaultRequireMention to true when not provided", () => { + const res = resolveSlackChannelConfig({ + channelId: "C1", + channels: {}, + }); + expect(res).toEqual({ allowed: true, requireMention: true }); + }); + + it("prefers explicit channel/fallback requireMention over defaultRequireMention", () => { + const res = resolveSlackChannelConfig({ + channelId: "C1", + channels: { "*": { requireMention: true } }, + defaultRequireMention: false, + }); + expect(res).toMatchObject({ requireMention: true }); + }); + + it("uses wildcard entries when no direct channel config exists", () => { + const res = resolveSlackChannelConfig({ + channelId: "C1", + channels: { "*": { allow: true, requireMention: false } }, + defaultRequireMention: true, + }); + expect(res).toMatchObject({ + allowed: true, + requireMention: false, + matchKey: "*", + matchSource: "wildcard", + }); + }); + + it("uses direct match metadata when channel config exists", () => { + const res = resolveSlackChannelConfig({ + channelId: "C1", + channels: { C1: { allow: true, requireMention: false } }, + defaultRequireMention: true, + }); + expect(res).toMatchObject({ + matchKey: "C1", + matchSource: "direct", + }); + }); +}); + +const baseParams = () => ({ + cfg: {} as OpenClawConfig, + accountId: "default", + botToken: "token", + app: { client: {} } as App, + runtime: {} as RuntimeEnv, + botUserId: "B1", + teamId: "T1", + apiAppId: "A1", + historyLimit: 0, + sessionScope: "per-sender" as const, + mainKey: "main", + dmEnabled: true, + dmPolicy: "open" as const, + allowFrom: [], + groupDmEnabled: true, + groupDmChannels: [], + defaultRequireMention: true, + groupPolicy: "open" as const, + useAccessGroups: false, + reactionMode: "off" as const, + reactionAllowlist: [], + replyToMode: "off" as const, + slashCommand: { + enabled: false, + name: "openclaw", + sessionPrefix: "slack:slash", + ephemeral: true, + }, + textLimit: 4000, + ackReactionScope: "group-mentions", + mediaMaxBytes: 1, + removeAckAfterReply: false, +}); + +describe("normalizeSlackChannelType", () => { + it("infers channel types from ids when missing", () => { + expect(normalizeSlackChannelType(undefined, "C123")).toBe("channel"); + expect(normalizeSlackChannelType(undefined, "D123")).toBe("im"); + expect(normalizeSlackChannelType(undefined, "G123")).toBe("group"); + }); + + it("prefers explicit channel_type values", () => { + expect(normalizeSlackChannelType("mpim", "C123")).toBe("mpim"); + }); +}); + +describe("resolveSlackSystemEventSessionKey", () => { + it("defaults missing channel_type to channel sessions", () => { + const ctx = createSlackMonitorContext(baseParams()); + expect(ctx.resolveSlackSystemEventSessionKey({ channelId: "C123" })).toBe( + "agent:main:slack:channel:c123", + ); + }); +}); + +describe("isChannelAllowed with groupPolicy and channelsConfig", () => { + it("allows unlisted channels when groupPolicy is open even with channelsConfig entries", () => { + // Bug fix: when groupPolicy="open" and channels has some entries, + // unlisted channels should still be allowed (not blocked) + const ctx = createSlackMonitorContext({ + ...baseParams(), + groupPolicy: "open", + channelsConfig: { + C_LISTED: { requireMention: true }, + }, + }); + // Listed channel should be allowed + expect(ctx.isChannelAllowed({ channelId: "C_LISTED", channelType: "channel" })).toBe(true); + // Unlisted channel should ALSO be allowed when policy is "open" + expect(ctx.isChannelAllowed({ channelId: "C_UNLISTED", channelType: "channel" })).toBe(true); + }); + + it("blocks unlisted channels when groupPolicy is allowlist", () => { + const ctx = createSlackMonitorContext({ + ...baseParams(), + groupPolicy: "allowlist", + channelsConfig: { + C_LISTED: { requireMention: true }, + }, + }); + // Listed channel should be allowed + expect(ctx.isChannelAllowed({ channelId: "C_LISTED", channelType: "channel" })).toBe(true); + // Unlisted channel should be blocked when policy is "allowlist" + expect(ctx.isChannelAllowed({ channelId: "C_UNLISTED", channelType: "channel" })).toBe(false); + }); + + it("blocks explicitly denied channels even when groupPolicy is open", () => { + const ctx = createSlackMonitorContext({ + ...baseParams(), + groupPolicy: "open", + channelsConfig: { + C_ALLOWED: { allow: true }, + C_DENIED: { allow: false }, + }, + }); + // Explicitly allowed channel + expect(ctx.isChannelAllowed({ channelId: "C_ALLOWED", channelType: "channel" })).toBe(true); + // Explicitly denied channel should be blocked even with open policy + expect(ctx.isChannelAllowed({ channelId: "C_DENIED", channelType: "channel" })).toBe(false); + // Unlisted channel should be allowed with open policy + expect(ctx.isChannelAllowed({ channelId: "C_UNLISTED", channelType: "channel" })).toBe(true); + }); + + it("allows all channels when groupPolicy is open and channelsConfig is empty", () => { + const ctx = createSlackMonitorContext({ + ...baseParams(), + groupPolicy: "open", + channelsConfig: undefined, + }); + expect(ctx.isChannelAllowed({ channelId: "C_ANY", channelType: "channel" })).toBe(true); + }); +}); + +describe("resolveSlackThreadStarter cache", () => { + afterEach(() => { + resetSlackThreadStarterCacheForTest(); + vi.useRealTimers(); + }); + + it("returns cached thread starter without refetching within ttl", async () => { + const replies = vi.fn(async () => ({ + messages: [{ text: "root message", user: "U1", ts: "1000.1" }], + })); + const client = { + conversations: { replies }, + } as unknown as Parameters[0]["client"]; + + const first = await resolveSlackThreadStarter({ + channelId: "C1", + threadTs: "1000.1", + client, + }); + const second = await resolveSlackThreadStarter({ + channelId: "C1", + threadTs: "1000.1", + client, + }); + + expect(first).toEqual(second); + expect(replies).toHaveBeenCalledTimes(1); + }); + + it("expires stale cache entries and refetches after ttl", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + + const replies = vi.fn(async () => ({ + messages: [{ text: "root message", user: "U1", ts: "1000.1" }], + })); + const client = { + conversations: { replies }, + } as unknown as Parameters[0]["client"]; + + await resolveSlackThreadStarter({ + channelId: "C1", + threadTs: "1000.1", + client, + }); + + vi.setSystemTime(new Date("2026-01-01T07:00:00.000Z")); + await resolveSlackThreadStarter({ + channelId: "C1", + threadTs: "1000.1", + client, + }); + + expect(replies).toHaveBeenCalledTimes(2); + }); + + it("evicts oldest entries once cache exceeds bounded size", async () => { + const replies = vi.fn(async () => ({ + messages: [{ text: "root message", user: "U1", ts: "1000.1" }], + })); + const client = { + conversations: { replies }, + } as unknown as Parameters[0]["client"]; + + // Cache cap is 2000; add enough distinct keys to force eviction of earliest keys. + for (let i = 0; i <= 2000; i += 1) { + await resolveSlackThreadStarter({ + channelId: "C1", + threadTs: `1000.${i}`, + client, + }); + } + const callsAfterFill = replies.mock.calls.length; + + // Oldest key should be evicted and require fetch again. + await resolveSlackThreadStarter({ + channelId: "C1", + threadTs: "1000.0", + client, + }); + + expect(replies.mock.calls.length).toBe(callsAfterFill + 1); + }); +}); + +describe("createSlackThreadTsResolver", () => { + it("caches resolved thread_ts lookups", async () => { + const historyMock = vi.fn().mockResolvedValue({ + messages: [{ ts: "1", thread_ts: "9" }], + }); + const resolver = createSlackThreadTsResolver({ + // oxlint-disable-next-line typescript/no-explicit-any + client: { conversations: { history: historyMock } } as any, + cacheTtlMs: 60_000, + maxSize: 5, + }); + + const message = { + channel: "C1", + parent_user_id: "U2", + ts: "1", + } as SlackMessageEvent; + + const first = await resolver.resolve({ message, source: "message" }); + const second = await resolver.resolve({ message, source: "message" }); + + expect(first.thread_ts).toBe("9"); + expect(second.thread_ts).toBe("9"); + expect(historyMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/slack/monitor/thread-resolution.test.ts b/src/slack/monitor/thread-resolution.test.ts deleted file mode 100644 index 5de8c74bd8f..00000000000 --- a/src/slack/monitor/thread-resolution.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { SlackMessageEvent } from "../types.js"; -import { createSlackThreadTsResolver } from "./thread-resolution.js"; - -describe("createSlackThreadTsResolver", () => { - it("caches resolved thread_ts lookups", async () => { - const historyMock = vi.fn().mockResolvedValue({ - messages: [{ ts: "1", thread_ts: "9" }], - }); - const resolver = createSlackThreadTsResolver({ - // oxlint-disable-next-line typescript/no-explicit-any - client: { conversations: { history: historyMock } } as any, - cacheTtlMs: 60_000, - maxSize: 5, - }); - - const message = { - channel: "C1", - parent_user_id: "U2", - ts: "1", - } as SlackMessageEvent; - - const first = await resolver.resolve({ message, source: "message" }); - const second = await resolver.resolve({ message, source: "message" }); - - expect(first.thread_ts).toBe("9"); - expect(second.thread_ts).toBe("9"); - expect(historyMock).toHaveBeenCalledTimes(1); - }); -});