Slack: filter thread context by allowlist (#58380)

* Slack: filter thread context by allowlist

* Slack: honor room thread allowlists

* Slack: keep open-room thread context

* Slack: keep non-room thread context

* Changelog: add Slack thread context fix
This commit is contained in:
Jacob Tomlinson
2026-04-02 03:01:11 -07:00
committed by GitHub
parent 0e3da03193
commit ac5bc4fb37
7 changed files with 526 additions and 8 deletions

View File

@@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai
- Podman/launch: remove noisy container output from `scripts/run-openclaw-podman.sh` and align the Podman install guidance with the quieter startup flow. (#59368) Thanks @sallyom.
- MS Teams/streaming: strip already-streamed text from fallback block delivery when replies exceed the 4000-character streaming limit so long responses stop duplicating content. (#59297) Thanks @bradgroux.
- MS Teams/logging: format non-`Error` failures with the shared unknown-error helper so logs stop collapsing caught SDK or Axios objects into `[object Object]`. (#59321) Thanks @bradgroux.
- Slack/thread context: filter thread starter and history by the effective conversation allowlist without dropping valid open-room, DM, or group DM context. (#58380)
## 2026.4.1-beta.1

View File

@@ -337,6 +337,7 @@ export async function resolveSlackAttachmentContent(params: {
export type SlackThreadStarter = {
text: string;
userId?: string;
botId?: string;
ts?: string;
files?: SlackFile[];
};
@@ -391,7 +392,15 @@ export async function resolveSlackThreadStarter(params: {
ts: params.threadTs,
limit: 1,
inclusive: true,
})) as { messages?: Array<{ text?: string; user?: string; ts?: string; files?: SlackFile[] }> };
})) as {
messages?: Array<{
text?: string;
user?: string;
bot_id?: string;
ts?: string;
files?: SlackFile[];
}>;
};
const message = response?.messages?.[0];
const text = (message?.text ?? "").trim();
if (!message || !text) {
@@ -400,6 +409,7 @@ export async function resolveSlackThreadStarter(params: {
const starter: SlackThreadStarter = {
text,
userId: message.user,
botId: message.bot_id,
ts: message.ts,
files: message.files,
};

View File

@@ -0,0 +1,146 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { App } from "@slack/bolt";
import { resolveEnvelopeFormatOptions } from "openclaw/plugin-sdk/channel-inbound";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import type { SlackMessageEvent } from "../../types.js";
import { resolveSlackThreadContextData } from "./prepare-thread-context.js";
import { createInboundSlackTestContext, createSlackTestAccount } from "./prepare.test-helpers.js";
describe("resolveSlackThreadContextData", () => {
let fixtureRoot = "";
let caseId = 0;
function makeTmpStorePath() {
if (!fixtureRoot) {
throw new Error("fixtureRoot missing");
}
const dir = path.join(fixtureRoot, `case-${caseId++}`);
fs.mkdirSync(dir);
return { dir, storePath: path.join(dir, "sessions.json") };
}
beforeAll(() => {
fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-slack-thread-context-"));
});
afterAll(() => {
if (fixtureRoot) {
fs.rmSync(fixtureRoot, { recursive: true, force: true });
fixtureRoot = "";
}
});
function createThreadContext(params: { replies: unknown }) {
return createInboundSlackTestContext({
cfg: {
channels: { slack: { enabled: true, replyToMode: "all", groupPolicy: "open" } },
} as OpenClawConfig,
appClient: { conversations: { replies: params.replies } } as App["client"],
defaultRequireMention: false,
replyToMode: "all",
});
}
function createThreadMessage(overrides: Partial<SlackMessageEvent> = {}): SlackMessageEvent {
return {
channel: "C123",
channel_type: "channel",
user: "U1",
text: "current message",
ts: "101.000",
thread_ts: "100.000",
...overrides,
} as SlackMessageEvent;
}
it("omits non-allowlisted starter text and thread history messages", async () => {
const { storePath } = makeTmpStorePath();
const replies = vi.fn().mockResolvedValue({
messages: [
{ text: "starter secret", user: "U2", ts: "100.000" },
{ text: "assistant reply", bot_id: "B1", ts: "100.500" },
{ text: "blocked follow-up", user: "U2", ts: "100.700" },
{ text: "allowed follow-up", user: "U1", ts: "100.800" },
{ text: "current message", user: "U1", ts: "101.000" },
],
response_metadata: { next_cursor: "" },
});
const ctx = createThreadContext({ replies });
ctx.resolveUserName = async (id: string) => ({
name: id === "U1" ? "Alice" : "Mallory",
});
const result = await resolveSlackThreadContextData({
ctx,
account: createSlackTestAccount({ thread: { initialHistoryLimit: 20 } }),
message: createThreadMessage(),
isThreadReply: true,
threadTs: "100.000",
threadStarter: {
text: "starter secret",
userId: "U2",
ts: "100.000",
},
roomLabel: "#general",
storePath,
sessionKey: "thread-session",
allowFromLower: ["u1"],
allowNameMatching: false,
envelopeOptions: resolveEnvelopeFormatOptions({} as OpenClawConfig),
effectiveDirectMedia: null,
});
expect(result.threadStarterBody).toBeUndefined();
expect(result.threadLabel).toBe("Slack thread #general");
expect(result.threadHistoryBody).toContain("assistant reply");
expect(result.threadHistoryBody).toContain("allowed follow-up");
expect(result.threadHistoryBody).not.toContain("starter secret");
expect(result.threadHistoryBody).not.toContain("blocked follow-up");
expect(result.threadHistoryBody).not.toContain("current message");
expect(replies).toHaveBeenCalledTimes(1);
});
it("keeps starter text and history when allowNameMatching authorizes the sender", async () => {
const { storePath } = makeTmpStorePath();
const replies = vi.fn().mockResolvedValue({
messages: [
{ text: "starter from Alice", user: "U1", ts: "100.000" },
{ text: "blocked follow-up", user: "U2", ts: "100.700" },
{ text: "current message", user: "U1", ts: "101.000" },
],
response_metadata: { next_cursor: "" },
});
const ctx = createThreadContext({ replies });
ctx.resolveUserName = async (id: string) => ({
name: id === "U1" ? "Alice" : "Mallory",
});
const result = await resolveSlackThreadContextData({
ctx,
account: createSlackTestAccount({ thread: { initialHistoryLimit: 20 } }),
message: createThreadMessage(),
isThreadReply: true,
threadTs: "100.000",
threadStarter: {
text: "starter from Alice",
userId: "U1",
ts: "100.000",
},
roomLabel: "#general",
storePath,
sessionKey: "thread-session",
allowFromLower: ["alice"],
allowNameMatching: true,
envelopeOptions: resolveEnvelopeFormatOptions({} as OpenClawConfig),
effectiveDirectMedia: null,
});
expect(result.threadStarterBody).toBe("starter from Alice");
expect(result.threadLabel).toContain("starter from Alice");
expect(result.threadHistoryBody).toContain("starter from Alice");
expect(result.threadHistoryBody).not.toContain("blocked follow-up");
});
});

View File

@@ -3,6 +3,7 @@ import { readSessionUpdatedAt } from "openclaw/plugin-sdk/config-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import type { ResolvedSlackAccount } from "../../accounts.js";
import type { SlackMessageEvent } from "../../types.js";
import { resolveSlackAllowListMatch } from "../allow-list.js";
import type { SlackMonitorContext } from "../context.js";
import {
resolveSlackMedia,
@@ -19,6 +20,27 @@ export type SlackThreadContextData = {
threadStarterMedia: SlackMediaResult[] | null;
};
function isSlackThreadContextSenderAllowed(params: {
allowFromLower: string[];
allowNameMatching: boolean;
userId?: string;
userName?: string;
botId?: string;
}): boolean {
if (params.allowFromLower.length === 0 || params.botId) {
return true;
}
if (!params.userId) {
return false;
}
return resolveSlackAllowListMatch({
allowList: params.allowFromLower,
id: params.userId,
name: params.userName,
allowNameMatching: params.allowNameMatching,
}).allowed;
}
export async function resolveSlackThreadContextData(params: {
ctx: SlackMonitorContext;
account: ResolvedSlackAccount;
@@ -29,6 +51,8 @@ export async function resolveSlackThreadContextData(params: {
roomLabel: string;
storePath: string;
sessionKey: string;
allowFromLower: string[];
allowNameMatching: boolean;
envelopeOptions: ReturnType<
typeof import("openclaw/plugin-sdk/channel-inbound").resolveEnvelopeFormatOptions
>;
@@ -51,7 +75,21 @@ export async function resolveSlackThreadContextData(params: {
}
const starter = params.threadStarter;
if (starter?.text) {
const starterSenderName =
params.allowNameMatching && starter?.userId
? (await params.ctx.resolveUserName(starter.userId))?.name
: undefined;
const starterAllowed =
!starter ||
isSlackThreadContextSenderAllowed({
allowFromLower: params.allowFromLower,
allowNameMatching: params.allowNameMatching,
userId: starter.userId,
userName: starterSenderName,
botId: starter.botId,
});
if (starter?.text && starterAllowed) {
threadStarterBody = starter.text;
const snippet = starter.text.replace(/\s+/g, " ").slice(0, 80);
threadLabel = `Slack thread ${params.roomLabel}${snippet ? `: ${snippet}` : ""}`;
@@ -69,6 +107,9 @@ export async function resolveSlackThreadContextData(params: {
} else {
threadLabel = `Slack thread ${params.roomLabel}`;
}
if (starter?.text && !starterAllowed) {
logVerbose("slack: omitted non-allowlisted thread starter from context");
}
const threadInitialHistoryLimit = params.account.config?.thread?.initialHistoryLimit ?? 20;
threadSessionPreviousTimestamp = readSessionUpdatedAt({
@@ -101,8 +142,25 @@ export async function resolveSlackThreadContextData(params: {
}),
);
const allowedThreadHistory = threadHistory.filter((historyMsg) => {
const msgUser = historyMsg.userId ? userMap.get(historyMsg.userId) : null;
return isSlackThreadContextSenderAllowed({
allowFromLower: params.allowFromLower,
allowNameMatching: params.allowNameMatching,
userId: historyMsg.userId,
userName: msgUser?.name,
botId: historyMsg.botId,
});
});
const omittedHistoryCount = threadHistory.length - allowedThreadHistory.length;
if (omittedHistoryCount > 0) {
logVerbose(
`slack: omitted ${omittedHistoryCount} non-allowlisted thread message(s) from context`,
);
}
const historyParts: string[] = [];
for (const historyMsg of threadHistory) {
for (const historyMsg of allowedThreadHistory) {
const msgUser = historyMsg.userId ? userMap.get(historyMsg.userId) : null;
const msgSenderName =
msgUser?.name ?? (historyMsg.botId ? `Bot (${historyMsg.botId})` : "Unknown");
@@ -120,10 +178,12 @@ export async function resolveSlackThreadContextData(params: {
}),
);
}
threadHistoryBody = historyParts.join("\n\n");
logVerbose(
`slack: populated thread history with ${threadHistory.length} messages for new session`,
);
if (historyParts.length > 0) {
threadHistoryBody = historyParts.join("\n\n");
logVerbose(
`slack: populated thread history with ${allowedThreadHistory.length} messages for new session`,
);
}
}
}

View File

@@ -2,6 +2,7 @@ import type { App } from "@slack/bolt";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import type { ResolvedSlackAccount } from "../../accounts.js";
import type { SlackChannelConfigEntries } from "../channel-config.js";
import { createSlackMonitorContext } from "../context.js";
export function createInboundSlackTestContext(params: {
@@ -9,7 +10,7 @@ export function createInboundSlackTestContext(params: {
appClient?: App["client"];
defaultRequireMention?: boolean;
replyToMode?: "off" | "all" | "first";
channelsConfig?: Record<string, { systemPrompt: string }>;
channelsConfig?: SlackChannelConfigEntries;
}) {
return createSlackMonitorContext({
cfg: params.cfg,

View File

@@ -0,0 +1,288 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { App } from "@slack/bolt";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import type { SlackMessageEvent } from "../../types.js";
type PrepareSlackMessage = typeof import("./prepare.js").prepareSlackMessage;
type CreateInboundSlackTestContext =
typeof import("./prepare.test-helpers.js").createInboundSlackTestContext;
type CreateSlackTestAccount = typeof import("./prepare.test-helpers.js").createSlackTestAccount;
let prepareSlackMessage: PrepareSlackMessage;
let createInboundSlackTestContext: CreateInboundSlackTestContext;
let createSlackTestAccount: CreateSlackTestAccount;
let fixtureRoot = "";
let caseId = 0;
async function loadSlackPrepareModules() {
const [{ prepareSlackMessage: loadedPrepareSlackMessage }, helpers] = await Promise.all([
import("./prepare.js"),
import("./prepare.test-helpers.js"),
]);
prepareSlackMessage = loadedPrepareSlackMessage;
createInboundSlackTestContext = helpers.createInboundSlackTestContext;
createSlackTestAccount = helpers.createSlackTestAccount;
}
function makeTmpStorePath() {
if (!fixtureRoot) {
throw new Error("fixtureRoot missing");
}
const dir = path.join(fixtureRoot, `case-${caseId++}`);
fs.mkdirSync(dir);
return path.join(dir, "sessions.json");
}
describe("prepareSlackMessage thread context allowlists", () => {
beforeAll(async () => {
await loadSlackPrepareModules();
fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-slack-room-thread-context-"));
});
afterAll(() => {
if (fixtureRoot) {
fs.rmSync(fixtureRoot, { recursive: true, force: true });
fixtureRoot = "";
}
});
it("uses room users allowlist for thread context filtering", async () => {
const replies = vi
.fn()
.mockResolvedValueOnce({
messages: [{ text: "starter from room user", user: "U1", ts: "100.000" }],
})
.mockResolvedValueOnce({
messages: [
{ text: "starter from room user", user: "U1", ts: "100.000" },
{ text: "assistant reply", bot_id: "B1", ts: "100.500" },
{ text: "allowed follow-up", user: "U1", ts: "100.800" },
{ text: "current message", user: "U1", ts: "101.000" },
],
response_metadata: { next_cursor: "" },
});
const storePath = makeTmpStorePath();
const ctx = createInboundSlackTestContext({
cfg: {
session: { store: storePath },
channels: { slack: { enabled: true, replyToMode: "all", groupPolicy: "open" } },
} as OpenClawConfig,
appClient: { conversations: { replies } } as unknown as App["client"],
defaultRequireMention: false,
replyToMode: "all",
channelsConfig: {
C123: {
users: ["U1"],
requireMention: false,
},
},
});
ctx.allowFrom = ["u-owner"];
ctx.resolveUserName = async (id: string) => ({
name: id === "U1" ? "Alice" : "Owner",
});
ctx.resolveChannelName = async () => ({ name: "general", type: "channel" });
const prepared = await prepareSlackMessage({
ctx,
account: createSlackTestAccount({
replyToMode: "all",
thread: { initialHistoryLimit: 20 },
}),
message: {
channel: "C123",
channel_type: "channel",
user: "U1",
text: "current message",
ts: "101.000",
thread_ts: "100.000",
} as SlackMessageEvent,
opts: { source: "message" },
});
expect(prepared).toBeTruthy();
expect(prepared!.ctxPayload.ThreadStarterBody).toBe("starter from room user");
expect(prepared!.ctxPayload.ThreadHistoryBody).toContain("starter from room user");
expect(prepared!.ctxPayload.ThreadHistoryBody).toContain("assistant reply");
expect(prepared!.ctxPayload.ThreadHistoryBody).toContain("allowed follow-up");
expect(prepared!.ctxPayload.ThreadHistoryBody).not.toContain("current message");
expect(replies).toHaveBeenCalledTimes(2);
});
it("does not apply the owner allowlist to open-room thread context", async () => {
const replies = vi
.fn()
.mockResolvedValueOnce({
messages: [{ text: "starter from open room", user: "U2", ts: "200.000" }],
})
.mockResolvedValueOnce({
messages: [
{ text: "starter from open room", user: "U2", ts: "200.000" },
{ text: "assistant reply", bot_id: "B1", ts: "200.500" },
{ text: "open-room follow-up", user: "U2", ts: "200.800" },
{ text: "current message", user: "U2", ts: "201.000" },
],
response_metadata: { next_cursor: "" },
});
const storePath = makeTmpStorePath();
const ctx = createInboundSlackTestContext({
cfg: {
session: { store: storePath },
channels: { slack: { enabled: true, replyToMode: "all", groupPolicy: "open" } },
} as OpenClawConfig,
appClient: { conversations: { replies } } as unknown as App["client"],
defaultRequireMention: false,
replyToMode: "all",
channelsConfig: {
C124: {
requireMention: false,
},
},
});
ctx.allowFrom = ["u-owner"];
ctx.resolveUserName = async (id: string) => ({
name: id === "U2" ? "Bob" : "Owner",
});
ctx.resolveChannelName = async () => ({ name: "general", type: "channel" });
const prepared = await prepareSlackMessage({
ctx,
account: createSlackTestAccount({
replyToMode: "all",
thread: { initialHistoryLimit: 20 },
}),
message: {
channel: "C124",
channel_type: "channel",
user: "U2",
text: "current message",
ts: "201.000",
thread_ts: "200.000",
} as SlackMessageEvent,
opts: { source: "message" },
});
expect(prepared).toBeTruthy();
expect(prepared!.ctxPayload.ThreadStarterBody).toBe("starter from open room");
expect(prepared!.ctxPayload.ThreadHistoryBody).toContain("starter from open room");
expect(prepared!.ctxPayload.ThreadHistoryBody).toContain("assistant reply");
expect(prepared!.ctxPayload.ThreadHistoryBody).toContain("open-room follow-up");
expect(prepared!.ctxPayload.ThreadHistoryBody).not.toContain("current message");
expect(replies).toHaveBeenCalledTimes(2);
});
it("does not apply the owner allowlist to open DMs when dmPolicy is open", async () => {
const replies = vi
.fn()
.mockResolvedValueOnce({
messages: [{ text: "starter from open dm", user: "U3", ts: "300.000" }],
})
.mockResolvedValueOnce({
messages: [
{ text: "starter from open dm", user: "U3", ts: "300.000" },
{ text: "assistant reply", bot_id: "B1", ts: "300.500" },
{ text: "dm follow-up", user: "U3", ts: "300.800" },
{ text: "current message", user: "U3", ts: "301.000" },
],
response_metadata: { next_cursor: "" },
});
const storePath = makeTmpStorePath();
const ctx = createInboundSlackTestContext({
cfg: {
session: { store: storePath },
channels: { slack: { enabled: true, replyToMode: "all", groupPolicy: "open" } },
} as OpenClawConfig,
appClient: { conversations: { replies } } as unknown as App["client"],
defaultRequireMention: false,
replyToMode: "all",
});
ctx.allowFrom = ["u-owner"];
ctx.resolveUserName = async (id: string) => ({
name: id === "U3" ? "Dana" : "Owner",
});
const prepared = await prepareSlackMessage({
ctx,
account: createSlackTestAccount({
replyToMode: "all",
thread: { initialHistoryLimit: 20 },
}),
message: {
channel: "D300",
channel_type: "im",
user: "U3",
text: "current message",
ts: "301.000",
thread_ts: "300.000",
} as SlackMessageEvent,
opts: { source: "message" },
});
expect(prepared).toBeTruthy();
expect(prepared!.ctxPayload.ThreadStarterBody).toBe("starter from open dm");
expect(prepared!.ctxPayload.ThreadHistoryBody).toContain("starter from open dm");
expect(prepared!.ctxPayload.ThreadHistoryBody).toContain("assistant reply");
expect(prepared!.ctxPayload.ThreadHistoryBody).toContain("dm follow-up");
expect(prepared!.ctxPayload.ThreadHistoryBody).not.toContain("current message");
expect(replies).toHaveBeenCalledTimes(2);
});
it("does not apply the owner allowlist to MPIM thread context", async () => {
const replies = vi
.fn()
.mockResolvedValueOnce({
messages: [{ text: "starter from mpim", user: "U4", ts: "400.000" }],
})
.mockResolvedValueOnce({
messages: [
{ text: "starter from mpim", user: "U4", ts: "400.000" },
{ text: "assistant reply", bot_id: "B1", ts: "400.500" },
{ text: "mpim follow-up", user: "U4", ts: "400.800" },
{ text: "current message", user: "U4", ts: "401.000" },
],
response_metadata: { next_cursor: "" },
});
const storePath = makeTmpStorePath();
const ctx = createInboundSlackTestContext({
cfg: {
session: { store: storePath },
channels: { slack: { enabled: true, replyToMode: "all", groupPolicy: "open" } },
} as OpenClawConfig,
appClient: { conversations: { replies } } as unknown as App["client"],
defaultRequireMention: false,
replyToMode: "all",
});
ctx.allowFrom = ["u-owner"];
ctx.resolveUserName = async (id: string) => ({
name: id === "U4" ? "Evan" : "Owner",
});
const prepared = await prepareSlackMessage({
ctx,
account: createSlackTestAccount({
replyToMode: "all",
thread: { initialHistoryLimit: 20 },
}),
message: {
channel: "G400",
channel_type: "mpim",
user: "U4",
text: "current message",
ts: "401.000",
thread_ts: "400.000",
} as SlackMessageEvent,
opts: { source: "message" },
});
expect(prepared).toBeTruthy();
expect(prepared!.ctxPayload.ThreadStarterBody).toBe("starter from mpim");
expect(prepared!.ctxPayload.ThreadHistoryBody).toContain("starter from mpim");
expect(prepared!.ctxPayload.ThreadHistoryBody).toContain("assistant reply");
expect(prepared!.ctxPayload.ThreadHistoryBody).toContain("mpim follow-up");
expect(prepared!.ctxPayload.ThreadHistoryBody).not.toContain("current message");
expect(replies).toHaveBeenCalledTimes(2);
});
});

View File

@@ -37,6 +37,7 @@ import { hasSlackThreadParticipation } from "../../sent-thread-cache.js";
import { resolveSlackThreadContext } from "../../threading.js";
import type { SlackMessageEvent } from "../../types.js";
import {
normalizeAllowListLower,
normalizeSlackAllowOwnerEntry,
resolveSlackAllowListMatch,
resolveSlackUserAllowed,
@@ -436,6 +437,15 @@ export async function prepareSlackMessage(params: {
}).allowed;
const channelUsersAllowlistConfigured =
isRoom && Array.isArray(channelConfig?.users) && channelConfig.users.length > 0;
const threadContextAllowFromLower = isRoom
? channelUsersAllowlistConfigured
? normalizeAllowListLower(channelConfig?.users)
: []
: isDirectMessage
? ctx.dmPolicy === "open"
? []
: allowFromLower
: [];
const channelCommandAuthorized =
isRoom && channelUsersAllowlistConfigured
? resolveSlackUserAllowed({
@@ -669,6 +679,8 @@ export async function prepareSlackMessage(params: {
roomLabel,
storePath,
sessionKey,
allowFromLower: threadContextAllowFromLower,
allowNameMatching: ctx.allowNameMatching,
envelopeOptions,
effectiveDirectMedia,
});