mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 04:42:44 +00:00
perf(test): consolidate slack monitor suites
This commit is contained in:
@@ -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",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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<typeof resolveSlackThreadStarter>[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<typeof resolveSlackThreadStarter>[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<typeof resolveSlackThreadStarter>[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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -7,6 +7,7 @@ import type { OpenClawConfig } from "../../../config/config.js";
|
|||||||
import type { RuntimeEnv } from "../../../runtime.js";
|
import type { RuntimeEnv } from "../../../runtime.js";
|
||||||
import type { ResolvedSlackAccount } from "../../accounts.js";
|
import type { ResolvedSlackAccount } from "../../accounts.js";
|
||||||
import type { SlackMessageEvent } from "../../types.js";
|
import type { SlackMessageEvent } from "../../types.js";
|
||||||
|
import type { SlackMonitorContext } from "../context.js";
|
||||||
import { expectInboundContextContract } from "../../../../test/helpers/inbound-contract.js";
|
import { expectInboundContextContract } from "../../../../test/helpers/inbound-contract.js";
|
||||||
import { resolveAgentRoute } from "../../../routing/resolve-route.js";
|
import { resolveAgentRoute } from "../../../routing/resolve-route.js";
|
||||||
import { resolveThreadSessionKeys } from "../../../routing/session-key.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");
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
289
src/slack/monitor/monitor.test.ts
Normal file
289
src/slack/monitor/monitor.test.ts
Normal file
@@ -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<typeof resolveSlackThreadStarter>[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<typeof resolveSlackThreadStarter>[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<typeof resolveSlackThreadStarter>[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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user