feat(slack): add thread.requireExplicitMention config option (#58276)

* feat(slack): add thread.requireExplicitMention config option

When requireMention is true in a Slack channel, replying inside a thread
where the bot previously participated currently bypasses mention gating
via implicit mention detection. This makes the bot respond to every
thread message even without an explicit @mention.

Add channels.slack.thread.requireExplicitMention (default: false) which,
when set to true, suppresses implicit thread mentions. Only explicit
@bot mentions will trigger replies inside threads.

Closes #34389
Closes #49972

* slack: refresh changelog and generated config artifacts

* slack: restore bundled channel metadata generation

---------

Co-authored-by: praktika-devops <devops@praktika.ai>
Co-authored-by: George Pickett <gpickett00@gmail.com>
This commit is contained in:
Praktika Engineer
2026-04-07 04:05:11 +04:00
committed by GitHub
parent 98b76d83ea
commit b8c8139138
15 changed files with 162 additions and 5 deletions

View File

@@ -45,6 +45,9 @@ Docs: https://docs.openclaw.ai
- Tools/web search/Exa: show Exa Search in onboarding and configure provider pickers again by marking the bundled Exa provider as setup-visible. Thanks @vincentkoc.
- Docs/i18n: relocalize final localized-page links after translation and remove the zh-CN homepage redirect override so localized Mintlify pages resolve to the correct language roots again. (#61796) Thanks @hxy91819.
- Plugins/provider hooks: stop recursive provider snapshot loads from overflowing the stack during plugin initialization, while still preserving cached nested provider-hook results. (#61922, #61938, #61946, #61951)
- Exec/runtime events: mark background `notifyOnExit` summaries and ACP parent-stream relays as untrusted system events so lower-trust runtime output no longer re-enters later turns as trusted `System:` text.
- Hooks/wake: queue direct and mapped wake-hook payloads as untrusted system events so external wake content no longer enters the main session as trusted input. (#62003)
- Slack/thread mentions: add `channels.slack.thread.requireExplicitMention` so Slack channels that already require mentions can also require explicit `@bot` mentions inside bot-participated threads. (#58276) Thanks @praktika-engineer.
## 2026.4.5

View File

@@ -1,4 +1,4 @@
afae767b9ec71e2566ec92bd3760408a2b339210ddac66c4d02c5f25d0089d31 config-baseline.json
e742d7392b2d9b90a6cd9cdf0fb2878951474f17fdeb2a58d4b44271a83f2153 config-baseline.core.json
66edc86a9d16db1b9e9e7dd99b7032e2d9bcfb9ff210256a21f4b4f088cb3dc1 config-baseline.channel.json
f13d08dc73c57307c847643c6b4a4dda899e44f95d97e15ff44dc0f8e7387675 config-baseline.plugin.json
6ad199bff1771839d1ab1129c2bb27ff583cf5a2e60a5603fa87b8a34c0856d0 config-baseline.json
cd556f8c976e535c710b5273c895bc5763650d67090e30dedc82cf227b2034d6 config-baseline.core.json
d22f4414b79ee03d896e58d875c80523bcc12303cbacb1700261e6ec73945187 config-baseline.channel.json
1891bcb68d80ab8b7546a2946b5a9d82b18c3e92ffd2c834d15928e73fa11564 config-baseline.plugin.json

View File

@@ -399,7 +399,7 @@ Current Slack message actions include `send`, `upload-file`, `download-file`, `r
- explicit app mention (`<@botId>`)
- mention regex patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`)
- implicit reply-to-bot thread behavior
- implicit reply-to-bot thread behavior (disabled when `thread.requireExplicitMention` is `true`)
Per-channel controls (`channels.slack.channels.<id>`; names only via startup resolution or `dangerouslyAllowNameMatching`):
@@ -423,6 +423,7 @@ Current Slack message actions include `send`, `upload-file`, `download-file`, `r
- Thread replies can create thread session suffixes (`:thread:<threadTs>`) when applicable.
- `channels.slack.thread.historyScope` default is `thread`; `thread.inheritParent` default is `false`.
- `channels.slack.thread.initialHistoryLimit` controls how many existing thread messages are fetched when a new thread session starts (default `20`; set `0` to disable).
- `channels.slack.thread.requireExplicitMention` (default `false`): when `true`, suppress implicit thread mentions so the bot only responds to explicit `@bot` mentions inside threads, even when the bot already participated in the thread. Without this, replies in a bot-participated thread bypass `requireMention` gating.
Reply threading controls:

View File

@@ -0,0 +1 @@
export { MattermostChannelConfigSchema } from "./config-surface.js";

View File

@@ -109,4 +109,8 @@ export const slackChannelConfigUiHints = {
label: "Slack Thread Initial History Limit",
help: "Maximum number of existing Slack thread messages to fetch when starting a new thread session (default: 20, set to 0 to disable).",
},
"thread.requireExplicitMention": {
label: "Slack Thread Require Explicit Mention",
help: "If true, require an explicit @mention even inside threads where the bot has participated. Suppresses implicit thread mention behavior so the bot only responds to explicit @bot mentions in threads (default: false).",
},
} satisfies Record<string, ChannelConfigUiHint>;

View File

@@ -34,6 +34,7 @@ function createTestContext() {
replyToMode: "off",
threadHistoryScope: "thread",
threadInheritParent: false,
threadRequireExplicitMention: false,
slashCommand: {
enabled: true,
name: "openclaw",

View File

@@ -54,6 +54,7 @@ export type SlackMonitorContext = {
replyToMode: "off" | "first" | "all" | "batched";
threadHistoryScope: "thread" | "channel";
threadInheritParent: boolean;
threadRequireExplicitMention: boolean;
slashCommand: Required<import("openclaw/plugin-sdk/config-runtime").SlackSlashCommandConfig>;
textLimit: number;
ackReactionScope: string;
@@ -118,6 +119,7 @@ export function createSlackMonitorContext(params: {
replyToMode: SlackMonitorContext["replyToMode"];
threadHistoryScope: SlackMonitorContext["threadHistoryScope"];
threadInheritParent: SlackMonitorContext["threadInheritParent"];
threadRequireExplicitMention: SlackMonitorContext["threadRequireExplicitMention"];
slashCommand: SlackMonitorContext["slashCommand"];
textLimit: number;
ackReactionScope: string;
@@ -418,6 +420,7 @@ export function createSlackMonitorContext(params: {
replyToMode: params.replyToMode,
threadHistoryScope: params.threadHistoryScope,
threadInheritParent: params.threadInheritParent,
threadRequireExplicitMention: params.threadRequireExplicitMention,
slashCommand: params.slashCommand,
textLimit: params.textLimit,
ackReactionScope: params.ackReactionScope,

View File

@@ -11,6 +11,7 @@ export function createInboundSlackTestContext(params: {
defaultRequireMention?: boolean;
replyToMode?: "off" | "all" | "first";
channelsConfig?: SlackChannelConfigEntries;
threadRequireExplicitMention?: boolean;
}) {
return createSlackMonitorContext({
cfg: params.cfg,
@@ -39,6 +40,7 @@ export function createInboundSlackTestContext(params: {
replyToMode: params.replyToMode ?? "off",
threadHistoryScope: "thread",
threadInheritParent: false,
threadRequireExplicitMention: params.threadRequireExplicitMention ?? false,
slashCommand: {
enabled: false,
name: "openclaw",

View File

@@ -645,6 +645,7 @@ describe("prepareSlackMessage sender prefix", () => {
replyToMode: "off",
threadHistoryScope: "channel",
threadInheritParent: false,
threadRequireExplicitMention: false,
slashCommand: params.slashCommand,
textLimit: 2000,
ackReactionScope: "off",
@@ -710,3 +711,121 @@ describe("prepareSlackMessage sender prefix", () => {
expect(result?.ctxPayload.CommandAuthorized).toBe(true);
});
});
describe("slack thread.requireExplicitMention", () => {
let fixtureRoot = "";
let caseId = 0;
function makeTmpStorePath() {
if (!fixtureRoot) {
throw new Error("fixtureRoot missing");
}
const dir = path.join(fixtureRoot, `require-explicit-${caseId++}`);
fs.mkdirSync(dir);
return { dir, storePath: path.join(dir, "sessions.json") };
}
beforeAll(() => {
fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-slack-explicit-mention-"));
});
afterAll(() => {
if (fixtureRoot) {
fs.rmSync(fixtureRoot, { recursive: true, force: true });
fixtureRoot = "";
}
});
function createCtxWithExplicitMention(requireExplicitMention: boolean) {
const ctx = createInboundSlackTestContext({
cfg: {
channels: { slack: { enabled: true } },
session: {},
} as OpenClawConfig,
threadRequireExplicitMention: requireExplicitMention,
});
ctx.resolveUserName = async () => ({ name: "Alice" }) as any;
return ctx;
}
it("drops thread reply without explicit mention when requireExplicitMention is true", async () => {
const ctx = createCtxWithExplicitMention(true);
const { storePath } = makeTmpStorePath();
vi.spyOn(
await import("openclaw/plugin-sdk/config-runtime"),
"resolveStorePath",
).mockReturnValue(storePath);
const account = createSlackTestAccount();
const message: SlackMessageEvent = {
type: "message",
channel: "C123",
channel_type: "channel",
user: "U1",
text: "hello",
ts: "1700000001.000001",
thread_ts: "1700000000.000000",
parent_user_id: "B1", // bot is thread parent
};
const result = await prepareSlackMessage({
ctx,
account,
message,
opts: { source: "message" },
});
expect(result).toBeNull();
});
it("allows thread reply with explicit @mention when requireExplicitMention is true", async () => {
const ctx = createCtxWithExplicitMention(true);
const { storePath } = makeTmpStorePath();
vi.spyOn(
await import("openclaw/plugin-sdk/config-runtime"),
"resolveStorePath",
).mockReturnValue(storePath);
const account = createSlackTestAccount();
const message: SlackMessageEvent = {
type: "message",
channel: "C123",
channel_type: "channel",
user: "U1",
text: "<@B1> hello",
ts: "1700000001.000002",
thread_ts: "1700000000.000000",
parent_user_id: "B1",
};
const result = await prepareSlackMessage({
ctx,
account,
message,
opts: { source: "message" },
});
expect(result).not.toBeNull();
});
it("allows thread reply without explicit mention when requireExplicitMention is false (default)", async () => {
const ctx = createCtxWithExplicitMention(false);
const { storePath } = makeTmpStorePath();
vi.spyOn(
await import("openclaw/plugin-sdk/config-runtime"),
"resolveStorePath",
).mockReturnValue(storePath);
const account = createSlackTestAccount();
const message: SlackMessageEvent = {
type: "message",
channel: "C123",
channel_type: "channel",
user: "U1",
text: "hello",
ts: "1700000001.000003",
thread_ts: "1700000000.000000",
parent_user_id: "B1",
};
const result = await prepareSlackMessage({
ctx,
account,
message,
opts: { source: "message" },
});
expect(result).not.toBeNull();
});
});

View File

@@ -384,6 +384,7 @@ export async function prepareSlackMessage(params: {
},
}));
const implicitMention = Boolean(
!ctx.threadRequireExplicitMention &&
!isDirectMessage &&
ctx.botUserId &&
message.thread_ts &&

View File

@@ -142,6 +142,7 @@ const baseParams = () => ({
mediaMaxBytes: 1,
threadHistoryScope: "thread" as const,
threadInheritParent: false,
threadRequireExplicitMention: false,
removeAckAfterReply: false,
});

View File

@@ -312,6 +312,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const replyToMode = slackCfg.replyToMode ?? "off";
const threadHistoryScope = slackCfg.thread?.historyScope ?? "thread";
const threadInheritParent = slackCfg.thread?.inheritParent ?? false;
const threadRequireExplicitMention = slackCfg.thread?.requireExplicitMention ?? false;
const slashCommand = resolveSlackSlashCommandConfig(opts.slashCommand ?? slackCfg.slashCommand);
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId, {
fallbackLimit: SLACK_TEXT_LIMIT,
@@ -423,6 +424,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
replyToMode,
threadHistoryScope,
threadInheritParent,
threadRequireExplicitMention,
slashCommand,
textLimit,
ackReactionScope,

View File

@@ -10840,6 +10840,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
minimum: 0,
maximum: 9007199254740991,
},
requireExplicitMention: {
type: "boolean",
},
},
additionalProperties: false,
},
@@ -11746,6 +11749,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
minimum: 0,
maximum: 9007199254740991,
},
requireExplicitMention: {
type: "boolean",
},
},
additionalProperties: false,
},
@@ -12130,6 +12136,10 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
label: "Slack Thread Initial History Limit",
help: "Maximum number of existing Slack thread messages to fetch when starting a new thread session (default: 20, set to 0 to disable).",
},
"thread.requireExplicitMention": {
label: "Slack Thread Require Explicit Mention",
help: "If true, require an explicit @mention even inside threads where the bot has participated. Suppresses implicit thread mention behavior so the bot only responds to explicit @bot mentions in threads (default: false).",
},
},
},
{

View File

@@ -96,6 +96,14 @@ export type SlackThreadConfig = {
inheritParent?: boolean;
/** Maximum number of thread messages to fetch as context when starting a new thread session (default: 20). Set to 0 to disable thread history fetching. */
initialHistoryLimit?: number;
/**
* If true, require explicit @mention even inside threads where the bot has
* previously participated. By default (false), replying in a thread where
* the bot is a participant counts as an implicit mention and bypasses
* requireMention gating. Set to true to suppress implicit thread mentions
* so only explicit @bot mentions trigger replies in threads.
*/
requireExplicitMention?: boolean;
};
export type SlackAccountConfig = {

View File

@@ -865,6 +865,7 @@ export const SlackThreadSchema = z
historyScope: z.enum(["thread", "channel"]).optional(),
inheritParent: z.boolean().optional(),
initialHistoryLimit: z.number().int().min(0).optional(),
requireExplicitMention: z.boolean().optional(),
})
.strict();