Skip to content

Commit 4ec7842

Browse files
hanamizukisteipete
andauthored
feat(slack): add ignoreOtherMentions channel config (#53467)
* feat(slack): add ignoreOtherMentions channel config Mirrors the existing Discord `ignoreOtherMentions` option for Slack channels. When set on a channel entry, drop channel/group/MPIM messages that mention another user or subteam but not this bot — the inverse of `requireMention`. Useful in busy channels where the bot would otherwise reply to side conversations. Implementation lives in `prepareSlackMessage` right after the `allowBots: "mentions"` bot-drop gate and before the `shouldRequireMention` non-mention drop, so it integrates with the new `messageIngress` ingress pipeline. The gate is conditional on `canDetectMention` (botUserId resolvable via `auth.test` or explicit mention regexes configured) to avoid false drops when we have no reliable way to tell bot vs non-bot mentions apart. Slack implicit mentions (thread participation) are intentionally NOT respected here — they fire for every message in a bot-participated thread, so honoring them would defeat the feature in any active thread. The gate matches on `wasMentioned` (explicit) and `hasAnyMention`, with the existing `shouldBypassMention` override (e.g. authorized commands) also respected. Includes config types, zod schema, channel-config resolution, prepare-message implementation, full test coverage in `prepare.test.ts`, regenerated channel + docs baselines, and Slack docs entry. * refactor(slack): harden other-mention filtering --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent 03fe813 commit 4ec7842

8 files changed

Lines changed: 284 additions & 64 deletions

File tree

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
4950ca686f135891c6bd42d4c1eb68945f3167b52cf115e17f8aac10d5388784 config-baseline.json
2-
c48fd78804feef7ff8028477b92bfd18ea252c03c249ef31dfb241a5e191b799 config-baseline.core.json
3-
d27ac1e30c6f3ef7292f33ad9737d4372dd140675293cbddc38364e476f06410 config-baseline.channel.json
1+
88a4b5241395f05e30f84b1f92be9517a64e35efe07a4012e760c160c320aecd config-baseline.json
2+
ef148d059b1b73b3d5ed568bd5cdbf8d4681a41a110341016d2d0318749df064 config-baseline.core.json
3+
3642204f860750da2ce0d43a338f982a206273084bf9afe645ebd759fed0a23a config-baseline.channel.json
44
e228f29f17758763a098b0ccd10c6db7fc9df84840437ed3f88a88cf945b8078 config-baseline.plugin.json

docs/channels/slack.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,6 +1031,7 @@ Current Slack message actions include `send`, `upload-file`, `download-file`, `r
10311031
Per-channel controls (`channels.slack.channels.<id>`; names only via startup resolution or `dangerouslyAllowNameMatching`):
10321032

10331033
- `requireMention`
1034+
- `ignoreOtherMentions`
10341035
- `users` (allowlist)
10351036
- `allowBots`
10361037
- `skills`
@@ -1039,6 +1040,8 @@ Current Slack message actions include `send`, `upload-file`, `download-file`, `r
10391040
- `toolsBySender` key format: `channel:`, `id:`, `e164:`, `username:`, `name:`, or `"*"` wildcard
10401041
(legacy unprefixed keys still map to `id:` only)
10411042

1043+
`ignoreOtherMentions` defaults to `false`. When `true`, channel messages that mention another user or user group but not this bot are stored as pending context and not handled. DMs and group DMs are unaffected. The filter requires a bot user ID from `auth.test`; if that identity is unavailable, messages pass through unchanged.
1044+
10421045
`allowBots` is conservative for channels and private channels: bot-authored room messages are accepted only when the sending bot is explicitly listed in that room's `users` allowlist, or when at least one explicit Slack owner ID from `channels.slack.allowFrom` is currently a room member. Wildcards and display-name owner entries do not satisfy owner presence. Owner presence uses Slack `conversations.members`; make sure the app has the matching read scope for the room type (`channels:read` for public channels, `groups:read` for private channels). If the member lookup fails, OpenClaw drops the bot-authored room message.
10431046

10441047
Accepted bot-authored Slack messages use shared [bot loop protection](/channels/bot-loop-protection). Configure `channels.defaults.botLoopProtection` for the default budget, then override with `channels.slack.botLoopProtection` or `channels.slack.channels.<id>.botLoopProtection` when a workspace or channel needs a different limit.

extensions/slack/src/monitor/channel-config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { normalizeSlackSlug } from "./allow-list.js";
1313
export type SlackChannelConfigResolved = {
1414
allowed: boolean;
1515
requireMention: boolean;
16+
ignoreOtherMentions?: boolean;
1617
allowBots?: boolean | "mentions";
1718
botLoopProtection?: ChannelBotLoopProtectionConfig;
1819
users?: Array<string | number>;
@@ -25,6 +26,7 @@ export type SlackChannelConfigResolved = {
2526
type SlackChannelConfigEntry = {
2627
enabled?: boolean;
2728
requireMention?: boolean;
29+
ignoreOtherMentions?: boolean;
2830
allowBots?: boolean | "mentions";
2931
botLoopProtection?: ChannelBotLoopProtectionConfig;
3032
users?: Array<string | number>;
@@ -113,6 +115,10 @@ export function resolveSlackChannelConfig(params: {
113115
const requireMention =
114116
firstDefined(resolved.requireMention, fallback?.requireMention, requireMentionDefault) ??
115117
requireMentionDefault;
118+
const ignoreOtherMentions = firstDefined(
119+
resolved.ignoreOtherMentions,
120+
fallback?.ignoreOtherMentions,
121+
);
116122
const allowBots = firstDefined(resolved.allowBots, fallback?.allowBots);
117123
const botLoopProtection = mergePairLoopGuardConfig(
118124
fallback?.botLoopProtection,
@@ -124,6 +130,7 @@ export function resolveSlackChannelConfig(params: {
124130
const result: SlackChannelConfigResolved = {
125131
allowed,
126132
requireMention,
133+
ignoreOtherMentions,
127134
allowBots,
128135
botLoopProtection,
129136
users,

extensions/slack/src/monitor/message-handler/prepare.test.ts

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1394,6 +1394,192 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`;
13941394
expect(prepared.ctxPayload.RawBody).toContain("bot DM");
13951395
});
13961396

1397+
it("drops channel message mentioning another user when ignoreOtherMentions=true", async () => {
1398+
const slackCtx = createInboundSlackCtx({
1399+
cfg: {
1400+
channels: { slack: { enabled: true } },
1401+
} as OpenClawConfig,
1402+
defaultRequireMention: false,
1403+
channelsConfig: { "*": { ignoreOtherMentions: true } },
1404+
});
1405+
slackCtx.historyLimit = 5;
1406+
1407+
const prepared = await prepareMessageWith(
1408+
slackCtx,
1409+
defaultAccount,
1410+
createSlackMessage({
1411+
channel: "C123",
1412+
channel_type: "channel",
1413+
text: "<@U456> hey",
1414+
}),
1415+
);
1416+
1417+
expect(prepared).toBeNull();
1418+
expect(Array.from(slackCtx.channelHistories.values()).flat()).toMatchObject([
1419+
{ body: "<@U456> hey", sender: "U1" },
1420+
]);
1421+
});
1422+
1423+
it("drops other-user mentions even in a bot-participated thread", async () => {
1424+
const slackCtx = createInboundSlackCtx({
1425+
cfg: { channels: { slack: { enabled: true } } } as OpenClawConfig,
1426+
defaultRequireMention: false,
1427+
channelsConfig: { "*": { ignoreOtherMentions: true } },
1428+
});
1429+
recordSlackThreadParticipation("default", "C123", "10.000");
1430+
1431+
const prepared = await prepareMessageWith(
1432+
slackCtx,
1433+
defaultAccount,
1434+
createSlackMessage({
1435+
channel: "C123",
1436+
channel_type: "channel",
1437+
text: "<@U456> hey",
1438+
thread_ts: "10.000",
1439+
}),
1440+
);
1441+
1442+
expect(prepared).toBeNull();
1443+
});
1444+
1445+
it("drops a user-group mention when the bot is not a member", async () => {
1446+
const usergroupsUsersList = vi.fn().mockResolvedValue({ ok: true, users: ["U456"] });
1447+
const slackCtx = createInboundSlackCtx({
1448+
cfg: { channels: { slack: { enabled: true } } } as OpenClawConfig,
1449+
appClient: {
1450+
usergroups: { users: { list: usergroupsUsersList } },
1451+
} as unknown as App["client"],
1452+
defaultRequireMention: false,
1453+
channelsConfig: { "*": { ignoreOtherMentions: true } },
1454+
});
1455+
1456+
const prepared = await prepareMessageWith(
1457+
slackCtx,
1458+
defaultAccount,
1459+
createSlackMessage({
1460+
channel: "C123",
1461+
channel_type: "channel",
1462+
text: "<!subteam^S123|team> hey",
1463+
}),
1464+
);
1465+
1466+
expect(prepared).toBeNull();
1467+
expect(usergroupsUsersList).toHaveBeenCalledWith({ usergroup: "S123", team_id: "T1" });
1468+
});
1469+
1470+
it("does not drop channel message mentioning bot alongside another user when ignoreOtherMentions=true", async () => {
1471+
const slackCtx = createInboundSlackCtx({
1472+
cfg: {
1473+
channels: { slack: { enabled: true } },
1474+
} as OpenClawConfig,
1475+
defaultRequireMention: false,
1476+
channelsConfig: { "*": { ignoreOtherMentions: true } },
1477+
});
1478+
1479+
const prepared = await prepareMessageWith(
1480+
slackCtx,
1481+
defaultAccount,
1482+
createSlackMessage({
1483+
channel: "C123",
1484+
channel_type: "channel",
1485+
text: "<@B1> <@U456> hey",
1486+
}),
1487+
);
1488+
1489+
assertPrepared(prepared);
1490+
});
1491+
1492+
it("does not drop DM mentioning another user when ignoreOtherMentions=true", async () => {
1493+
const slackCtx = createInboundSlackCtx({
1494+
cfg: {
1495+
channels: { slack: { enabled: true } },
1496+
} as OpenClawConfig,
1497+
defaultRequireMention: false,
1498+
channelsConfig: { "*": { ignoreOtherMentions: true } },
1499+
});
1500+
1501+
const prepared = await prepareMessageWith(
1502+
slackCtx,
1503+
defaultAccount,
1504+
createSlackMessage({
1505+
channel: "D123",
1506+
channel_type: "im",
1507+
text: "<@U456> hey",
1508+
}),
1509+
);
1510+
1511+
assertPrepared(prepared);
1512+
});
1513+
1514+
it("does not drop channel message with no user mentions when ignoreOtherMentions=true", async () => {
1515+
const slackCtx = createInboundSlackCtx({
1516+
cfg: {
1517+
channels: { slack: { enabled: true } },
1518+
} as OpenClawConfig,
1519+
defaultRequireMention: false,
1520+
channelsConfig: { "*": { ignoreOtherMentions: true } },
1521+
});
1522+
1523+
const prepared = await prepareMessageWith(
1524+
slackCtx,
1525+
defaultAccount,
1526+
createSlackMessage({
1527+
channel: "C123",
1528+
channel_type: "channel",
1529+
text: "hello team",
1530+
}),
1531+
);
1532+
1533+
assertPrepared(prepared);
1534+
});
1535+
1536+
it("does not drop when botUserId is unresolved (no native identity)", async () => {
1537+
const slackCtx = createInboundSlackCtx({
1538+
cfg: {
1539+
channels: { slack: { enabled: true } },
1540+
} as OpenClawConfig,
1541+
defaultRequireMention: false,
1542+
channelsConfig: { "*": { ignoreOtherMentions: true } },
1543+
});
1544+
slackCtx.botUserId = undefined as unknown as string;
1545+
1546+
const prepared = await prepareMessageWith(
1547+
slackCtx,
1548+
defaultAccount,
1549+
createSlackMessage({
1550+
channel: "C123",
1551+
channel_type: "channel",
1552+
text: "<@U456> hey",
1553+
}),
1554+
);
1555+
1556+
assertPrepared(prepared);
1557+
});
1558+
1559+
it("does not drop when botUserId is unresolved even with mention regexes configured", async () => {
1560+
const slackCtx = createInboundSlackCtx({
1561+
cfg: {
1562+
channels: { slack: { enabled: true } },
1563+
messages: { groupChat: { mentionPatterns: ["\\bmy-bot\\b"] } },
1564+
} as OpenClawConfig,
1565+
defaultRequireMention: false,
1566+
channelsConfig: { "*": { ignoreOtherMentions: true } },
1567+
});
1568+
slackCtx.botUserId = undefined as unknown as string;
1569+
1570+
const prepared = await prepareMessageWith(
1571+
slackCtx,
1572+
defaultAccount,
1573+
createSlackMessage({
1574+
channel: "C123",
1575+
channel_type: "channel",
1576+
text: "<@U456> hey",
1577+
}),
1578+
);
1579+
1580+
assertPrepared(prepared);
1581+
});
1582+
13971583
it("drops bot-authored room messages when owner presence lookup fails (#59284)", async () => {
13981584
const members = vi.fn().mockRejectedValue(new Error("missing_scope"));
13991585
const slackCtx = createInboundSlackCtx({

extensions/slack/src/monitor/message-handler/prepare.ts

Lines changed: 72 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -866,6 +866,63 @@ export async function prepareSlackMessage(params: {
866866
resolvedSenderName = message.user ?? message.bot_id ?? "unknown";
867867
return resolvedSenderName;
868868
};
869+
const recordDroppedHistory = async (
870+
reason: "slack-no-mention" | "slack-other-mention",
871+
): Promise<void> => {
872+
const pendingText = (message.text ?? "").trim();
873+
const historyMediaCandidate = buildSlackHistoryMediaCandidateMessage(message);
874+
const fallbackFile = message.files?.length
875+
? `[Slack file: ${formatSlackFileReference(message.files[0])}]`
876+
: "";
877+
const fallbackSharedMedia =
878+
!fallbackFile && historyMediaCandidate ? "[Slack media attachment]" : "";
879+
const pendingBody = pendingText || fallbackFile || fallbackSharedMedia;
880+
const skippedThreadStarter =
881+
historyMediaCandidate && isThreadReply && threadTs
882+
? await resolveSlackThreadStarter({
883+
channelId: message.channel,
884+
threadTs,
885+
client: ctx.app.client,
886+
})
887+
: null;
888+
const senderName = pendingBody ? await resolveSenderName() : undefined;
889+
await recordDroppedChannelInboundHistory({
890+
input: {
891+
id: message.ts ?? `${message.channel}:${Date.now()}`,
892+
timestamp: resolveSlackTimestampMs(message.ts),
893+
rawText: pendingBody,
894+
textForAgent: pendingBody,
895+
raw: message,
896+
},
897+
admission: { kind: "drop", reason, recordHistory: true },
898+
preflight: {
899+
message: pendingBody
900+
? {
901+
rawBody: pendingBody,
902+
body: pendingBody,
903+
bodyForAgent: pendingBody,
904+
senderLabel: senderName,
905+
envelopeFrom: senderName,
906+
}
907+
: undefined,
908+
history: {
909+
key: historyKey,
910+
historyMap: ctx.channelHistories,
911+
limit: ctx.historyLimit,
912+
recordOnDrop: true,
913+
mediaLimit: SLACK_HISTORY_MEDIA_MAX_ATTACHMENTS,
914+
},
915+
media: () =>
916+
resolveSlackHistoryMediaForPendingRecord({
917+
ctx,
918+
message,
919+
isThreadReply,
920+
threadStarter: skippedThreadStarter,
921+
isBotMessage,
922+
}),
923+
},
924+
});
925+
};
869926
const senderNameForAuth = ctx.allowNameMatching ? await resolveSenderName() : undefined;
870927

871928
const allowTextCommands = shouldHandleTextCommands({
@@ -978,62 +1035,23 @@ export async function prepareSlackMessage(params: {
9781035
return null;
9791036
}
9801037

1038+
// Thread participation is broad on Slack; only an explicit bot mention escapes this gate.
1039+
// Native bot identity distinguishes bot pings from other Slack mentions.
1040+
const ignoreOtherMentions = channelConfig?.ignoreOtherMentions ?? false;
1041+
if (isRoom && ignoreOtherMentions && Boolean(ctx.botUserId) && hasAnyMention && !wasMentioned) {
1042+
logInboundDrop({
1043+
log: logVerbose,
1044+
channel: "slack",
1045+
reason: "other-mention",
1046+
target: senderId,
1047+
});
1048+
await recordDroppedHistory("slack-other-mention");
1049+
return null;
1050+
}
1051+
9811052
if (isRoom && shouldRequireMention && messageIngress.activationAccess.shouldSkip) {
9821053
ctx.logger.info({ channel: message.channel, reason: "no-mention" }, "skipping channel message");
983-
const pendingText = (message.text ?? "").trim();
984-
const historyMediaCandidate = buildSlackHistoryMediaCandidateMessage(message);
985-
const fallbackFile = message.files?.length
986-
? `[Slack file: ${formatSlackFileReference(message.files[0])}]`
987-
: "";
988-
const fallbackSharedMedia =
989-
!fallbackFile && historyMediaCandidate ? "[Slack media attachment]" : "";
990-
const pendingBody = pendingText || fallbackFile || fallbackSharedMedia;
991-
const skippedThreadStarter =
992-
historyMediaCandidate && isThreadReply && threadTs
993-
? await resolveSlackThreadStarter({
994-
channelId: message.channel,
995-
threadTs,
996-
client: ctx.app.client,
997-
})
998-
: null;
999-
const timestamp = resolveSlackTimestampMs(message.ts);
1000-
const senderName = pendingBody ? await resolveSenderName() : undefined;
1001-
await recordDroppedChannelInboundHistory({
1002-
input: {
1003-
id: message.ts ?? `${message.channel}:${Date.now()}`,
1004-
timestamp,
1005-
rawText: pendingBody,
1006-
textForAgent: pendingBody,
1007-
raw: message,
1008-
},
1009-
admission: { kind: "drop", reason: "slack-no-mention", recordHistory: true },
1010-
preflight: {
1011-
message: pendingBody
1012-
? {
1013-
rawBody: pendingBody,
1014-
body: pendingBody,
1015-
bodyForAgent: pendingBody,
1016-
senderLabel: senderName,
1017-
envelopeFrom: senderName,
1018-
}
1019-
: undefined,
1020-
history: {
1021-
key: historyKey,
1022-
historyMap: ctx.channelHistories,
1023-
limit: ctx.historyLimit,
1024-
recordOnDrop: true,
1025-
mediaLimit: SLACK_HISTORY_MEDIA_MAX_ATTACHMENTS,
1026-
},
1027-
media: () =>
1028-
resolveSlackHistoryMediaForPendingRecord({
1029-
ctx,
1030-
message,
1031-
isThreadReply,
1032-
threadStarter: skippedThreadStarter,
1033-
isBotMessage,
1034-
}),
1035-
},
1036-
});
1054+
await recordDroppedHistory("slack-no-mention");
10371055
return null;
10381056
}
10391057

src/config/bundled-channel-config-metadata.generated.ts

Lines changed: 7 additions & 7 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)