mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-03 05:12:15 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user