Skip to content

Commit d6783d5

Browse files
committed
fix: preserve slack mention source metadata
1 parent 95a1c91 commit d6783d5

8 files changed

Lines changed: 201 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai
99
- Telegram: preserve the channel-specific 10-option poll cap in the unified outbound adapter so over-limit polls are rejected before send. (#78762) Thanks @obviyus.
1010
- Runtime/install: raise the supported Node 22 floor to `22.16+` so native SQLite query handling can rely on the `node:sqlite` statement metadata API while continuing to recommend Node 24. (#78921)
1111
- Discord/voice: stream ElevenLabs TTS directly into Discord playback and send ElevenLabs latency optimization as the documented query parameter so spoken replies can start sooner.
12+
- Slack: preserve explicit Slack mention targets, user-group ids, implicit thread wake reasons, and mention source metadata in inbound prompt context, and nudge implicit thread wakes without direct bot mentions to stay quiet unless they add specific value while keeping existing mention-gating behavior unchanged. Fixes #79025. Thanks @bek91.
1213
- Discord/voice: keep TTS playback running when another user starts speaking, ignore new capture during playback to avoid feedback loops, and downgrade expected receive-stream aborts to verbose diagnostics.
1314
- Telegram: treat successful same-chat `message` tool outbound sends during an inbound telegram turn as delivered when deciding whether to emit the rewritten silent reply fallback (#78685). Thanks @neeravmakwana.
1415
- Gateway/tasks: reconcile stale CLI run-context tasks whose live run context disappeared even when a child session row remains, and apply the default bounded reload deferral timeout to channel hot reloads so stale task records cannot block Discord/Slack/Telegram reloads forever.

docs/channels/slack.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -907,6 +907,8 @@ Current Slack message actions include `send`, `upload-file`, `download-file`, `r
907907
- mention regex patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`)
908908
- implicit reply-to-bot thread behavior (disabled when `thread.requireExplicitMention` is `true`)
909909

910+
Slack prompt metadata preserves the collapsed wake decision (`was_mentioned`) and the provider-native mention detail for that turn, including `explicitly_mentioned_bot`, `mentioned_user_ids`, `mentioned_subteam_ids`, `implicit_mention_kinds`, and `mention_source`. This lets agents distinguish an explicit mention of another Slack user inside an implicitly waking thread from a direct bot mention. When a turn wakes from implicit thread participation without an explicit bot mention, OpenClaw also nudges the agent to reply only when it has specific additive value, such as correcting an important mistake or adding requested context.
911+
910912
Per-channel controls (`channels.slack.channels.<id>`; names only via startup resolution or `dangerouslyAllowNameMatching`):
911913

912914
- `requireMention`
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
6980bbdd2836edf693dc24ee59253feadc5843cad3cde6efbef3bd1ffc8acf24
1+
30bca8f93a8c52894fc94636b0a3561dce212fbb72a30d45276ab063486c857f

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1388,6 +1388,43 @@ describe("slack prepareSlackMessage inbound contract", () => {
13881388
expect(new Set([root!.ctxPayload.SessionKey, followUp!.ctxPayload.SessionKey]).size).toBe(1);
13891389
});
13901390

1391+
it("preserves explicit Slack mention targets when an implicit thread wake mentions someone else", async () => {
1392+
const { storePath } = storeFixture.makeTmpStorePath();
1393+
const slackCtx = createInboundSlackCtx({
1394+
cfg: {
1395+
session: { store: storePath },
1396+
channels: { slack: { enabled: true, replyToMode: "all", groupPolicy: "open" } },
1397+
} as OpenClawConfig,
1398+
defaultRequireMention: true,
1399+
replyToMode: "all",
1400+
});
1401+
slackCtx.resolveChannelName = async () => ({ name: "proj-openclaw", type: "channel" });
1402+
slackCtx.resolveUserName = async () => ({ name: "Bek" });
1403+
1404+
const prepared = await prepareSlackMessage({
1405+
ctx: slackCtx,
1406+
account: createSlackAccount({ replyToMode: "all" }),
1407+
message: {
1408+
type: "message",
1409+
channel: "C0AHZFCAS1K",
1410+
channel_type: "channel",
1411+
user: "U_BEK",
1412+
text: "<@UOTHER> can you check this?",
1413+
ts: "1777244714.000100",
1414+
thread_ts: "1777244692.409919",
1415+
parent_user_id: "B1",
1416+
} as SlackMessageEvent,
1417+
opts: { source: "message" },
1418+
});
1419+
1420+
expect(prepared).toBeTruthy();
1421+
expect(prepared!.ctxPayload.WasMentioned).toBe(true);
1422+
expect(prepared!.ctxPayload.ExplicitlyMentionedBot).toBe(false);
1423+
expect(prepared!.ctxPayload.MentionedUserIds).toEqual(["UOTHER"]);
1424+
expect(prepared!.ctxPayload.ImplicitMentionKinds).toEqual(["reply_to_bot"]);
1425+
expect(prepared!.ctxPayload.MentionSource).toBe("implicit_thread");
1426+
});
1427+
13911428
it("treats Slack user-group mentions as explicit mentions when the bot is a member", async () => {
13921429
const usergroupsUsersList = vi.fn().mockResolvedValue({
13931430
ok: true,
@@ -1431,6 +1468,9 @@ describe("slack prepareSlackMessage inbound contract", () => {
14311468
});
14321469
expect(prepared).toBeTruthy();
14331470
expect(prepared!.ctxPayload.WasMentioned).toBe(true);
1471+
expect(prepared!.ctxPayload.ExplicitlyMentionedBot).toBe(true);
1472+
expect(prepared!.ctxPayload.MentionedSubteamIds).toEqual(["S0AGENTS"]);
1473+
expect(prepared!.ctxPayload.MentionSource).toBe("subteam");
14341474
});
14351475

14361476
it("drops Slack user-group mentions when the bot is not a member", async () => {

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

Lines changed: 69 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ import type { PreparedSlackMessage } from "./types.js";
6868

6969
const mentionRegexCache = new WeakMap<SlackMonitorContext, Map<string, RegExp[]>>();
7070
const SLACK_ANY_MENTION_RE = /<@[^>]+>|<!subteam\^[^>]+>/;
71+
const SLACK_USER_MENTION_RE = /<@([A-Z0-9]+)(?:\|[^>]+)?>/gi;
72+
const SLACK_SUBTEAM_MENTION_RE = /<!subteam\^([A-Z0-9]+)(?:\|[^>]+)?>/gi;
7173
const SLACK_SUBTEAM_MENTION_MARKER = "<!subteam^";
7274

7375
function resolveCachedMentionRegexes(
@@ -112,6 +114,43 @@ type SlackAuthorizationContext = {
112114
allowFromLower: string[];
113115
};
114116

117+
function collectUniqueSlackMentionIds(text: string, regex: RegExp): string[] {
118+
const ids: string[] = [];
119+
regex.lastIndex = 0;
120+
for (const match of text.matchAll(regex)) {
121+
const id = normalizeOptionalString(match[1]);
122+
if (id && !ids.includes(id)) {
123+
ids.push(id);
124+
}
125+
}
126+
return ids;
127+
}
128+
129+
function resolveSlackMentionSource(params: {
130+
explicitBotMention: boolean;
131+
explicitSubteamMention: boolean;
132+
matchedImplicitMentionKinds: readonly string[];
133+
shouldBypassMention: boolean;
134+
wasMentioned: boolean;
135+
}): NonNullable<FinalizedMsgContext["MentionSource"]> {
136+
if (params.explicitBotMention) {
137+
return "explicit_bot";
138+
}
139+
if (params.explicitSubteamMention) {
140+
return "subteam";
141+
}
142+
if (params.matchedImplicitMentionKinds.length > 0) {
143+
return "implicit_thread";
144+
}
145+
if (params.shouldBypassMention) {
146+
return "command_bypass";
147+
}
148+
if (params.wasMentioned) {
149+
return "mention_pattern";
150+
}
151+
return "none";
152+
}
153+
115154
async function resolveSlackConversationContext(params: {
116155
ctx: SlackMonitorContext;
117156
account: ResolvedSlackAccount;
@@ -289,20 +328,24 @@ export async function prepareSlackMessage(params: {
289328
}
290329
const { senderId, allowFromLower } = authorization;
291330
const messageText = message.text ?? "";
331+
const mentionedUserIds = collectUniqueSlackMentionIds(messageText, SLACK_USER_MENTION_RE);
332+
const mentionedSubteamIds = collectUniqueSlackMentionIds(messageText, SLACK_SUBTEAM_MENTION_RE);
292333
const hasAnyMention = SLACK_ANY_MENTION_RE.test(messageText);
293334
const hasSubteamMention = messageText.includes(SLACK_SUBTEAM_MENTION_MARKER);
294-
const explicitlyMentioned = Boolean(
295-
ctx.botUserId &&
296-
(messageText.includes(`<@${ctx.botUserId}>`) ||
297-
(hasSubteamMention &&
298-
(await isSlackSubteamMentionForBot({
299-
client: ctx.app.client,
300-
text: messageText,
301-
botUserId: ctx.botUserId,
302-
teamId: ctx.teamId,
303-
log: logVerbose,
304-
})))),
335+
const explicitlyMentionedBotUser = Boolean(
336+
ctx.botUserId && mentionedUserIds.includes(ctx.botUserId),
305337
);
338+
const explicitlyMentionedBotSubteam =
339+
Boolean(ctx.botUserId && hasSubteamMention) &&
340+
(await isSlackSubteamMentionForBot({
341+
client: ctx.app.client,
342+
text: messageText,
343+
botUserId: ctx.botUserId,
344+
teamId: ctx.teamId,
345+
log: logVerbose,
346+
}));
347+
const explicitlyMentioned =
348+
explicitlyMentionedBotUser || explicitlyMentionedBotSubteam || opts.source === "app_mention";
306349
const seedTopLevelRoomThreadBySource =
307350
opts.source === "app_mention" || opts.wasMentioned === true || explicitlyMentioned;
308351
let routing = resolveSlackRoutingContext({
@@ -524,6 +567,14 @@ export async function prepareSlackMessage(params: {
524567
},
525568
});
526569
const effectiveWasMentioned = mentionDecision.effectiveWasMentioned;
570+
const matchedImplicitMentionKinds = mentionDecision.matchedImplicitMentionKinds;
571+
const mentionSource = resolveSlackMentionSource({
572+
explicitBotMention: explicitlyMentionedBotUser || opts.source === "app_mention",
573+
explicitSubteamMention: explicitlyMentionedBotSubteam,
574+
matchedImplicitMentionKinds,
575+
shouldBypassMention: mentionDecision.shouldBypassMention,
576+
wasMentioned,
577+
});
527578
if (isRoom && shouldRequireMention && mentionDecision.shouldSkip) {
528579
ctx.logger.info({ channel: message.channel, reason: "no-mention" }, "skipping channel message");
529580
const pendingText = (message.text ?? "").trim();
@@ -792,6 +843,13 @@ export async function prepareSlackMessage(params: {
792843
ThreadLabel: threadLabel,
793844
Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
794845
WasMentioned: isRoomish ? effectiveWasMentioned : undefined,
846+
ExplicitlyMentionedBot: isRoomish ? explicitlyMentioned : undefined,
847+
MentionedUserIds: isRoomish && mentionedUserIds.length > 0 ? mentionedUserIds : undefined,
848+
MentionedSubteamIds:
849+
isRoomish && mentionedSubteamIds.length > 0 ? mentionedSubteamIds : undefined,
850+
ImplicitMentionKinds:
851+
isRoomish && matchedImplicitMentionKinds.length > 0 ? matchedImplicitMentionKinds : undefined,
852+
MentionSource: isRoomish ? mentionSource : undefined,
795853
MediaPath: firstMedia?.path,
796854
MediaType: firstMedia?.contentType,
797855
MediaUrl: firstMedia?.path,

src/auto-reply/reply/inbound-meta.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,11 @@ describe("buildInboundUserContextPrefix", () => {
494494
const text = buildInboundUserContextPrefix({
495495
ChatType: "group",
496496
WasMentioned: true,
497+
ExplicitlyMentionedBot: false,
498+
MentionedUserIds: [" U_OTHER ", "", "U_HELPER"],
499+
MentionedSubteamIds: [" S_ONCALL "],
500+
ImplicitMentionKinds: ["bot_thread_participant"],
501+
MentionSource: "implicit_thread",
497502
ReplyToBody: "quoted",
498503
ForwardedFrom: "sender",
499504
ThreadStarterBody: "starter",
@@ -503,12 +508,44 @@ describe("buildInboundUserContextPrefix", () => {
503508
const conversationInfo = parseConversationInfoPayload(text);
504509
expect(conversationInfo["is_group_chat"]).toBe(true);
505510
expect(conversationInfo["was_mentioned"]).toBe(true);
511+
expect(conversationInfo["explicitly_mentioned_bot"]).toBe(false);
512+
expect(conversationInfo["mentioned_user_ids"]).toEqual(["U_OTHER", "U_HELPER"]);
513+
expect(conversationInfo["mentioned_subteam_ids"]).toEqual(["S_ONCALL"]);
514+
expect(conversationInfo["implicit_mention_kinds"]).toEqual(["bot_thread_participant"]);
515+
expect(conversationInfo["mention_source"]).toBe("implicit_thread");
506516
expect(conversationInfo["has_reply_context"]).toBe(true);
507517
expect(conversationInfo["has_forwarded_context"]).toBe(true);
508518
expect(conversationInfo["has_thread_starter"]).toBe(true);
509519
expect(conversationInfo["history_count"]).toBe(1);
510520
});
511521

522+
it("nudges implicit thread wakes without explicit bot mentions to stay selective", () => {
523+
const text = buildInboundUserContextPrefix({
524+
ChatType: "group",
525+
WasMentioned: true,
526+
ExplicitlyMentionedBot: false,
527+
MentionedUserIds: ["UOTHER"],
528+
ImplicitMentionKinds: ["reply_to_bot"],
529+
MentionSource: "implicit_thread",
530+
} as TemplateContext);
531+
532+
expect(text).toContain("This turn woke from implicit thread participation");
533+
expect(text).toContain("reply only when you have a specific useful addition");
534+
expect(text).toContain("otherwise use the current chat's no-response behavior");
535+
});
536+
537+
it("does not add selective reply guidance for explicit bot mentions", () => {
538+
const text = buildInboundUserContextPrefix({
539+
ChatType: "group",
540+
WasMentioned: true,
541+
ExplicitlyMentionedBot: true,
542+
MentionedUserIds: ["B1"],
543+
MentionSource: "explicit_bot",
544+
} as TemplateContext);
545+
546+
expect(text).not.toContain("This turn woke from implicit thread participation");
547+
});
548+
512549
it("trims sender_id in conversation info", () => {
513550
const text = buildInboundUserContextPrefix({
514551
ChatType: "group",

src/auto-reply/reply/inbound-meta.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@ function normalizePromptMetadataString(value: unknown): string | undefined {
2525
return sanitized || undefined;
2626
}
2727

28+
function normalizePromptMetadataStringArray(value: unknown): string[] | undefined {
29+
if (!Array.isArray(value)) {
30+
return undefined;
31+
}
32+
const normalized = value
33+
.map((entry) => normalizePromptMetadataString(entry))
34+
.filter((entry): entry is string => Boolean(entry));
35+
return normalized.length > 0 ? normalized : undefined;
36+
}
37+
2838
function sanitizePromptBody(value: unknown): string | undefined {
2939
if (typeof value !== "string") {
3040
return undefined;
@@ -75,6 +85,21 @@ function formatUntrustedJsonBlock(label: string, payload: unknown): string {
7585
].join("\n");
7686
}
7787

88+
function buildMentionReplyGuidance(ctx: TemplateContext, isDirect: boolean): string | undefined {
89+
if (
90+
isDirect ||
91+
normalizePromptMetadataString(ctx.MentionSource) !== "implicit_thread" ||
92+
ctx.ExplicitlyMentionedBot !== false
93+
) {
94+
return undefined;
95+
}
96+
return [
97+
"OpenClaw reply guidance:",
98+
"This turn woke from implicit thread participation, not a direct bot mention.",
99+
"If the message is addressed to someone else, reply only when you have a specific useful addition, such as correcting an important mistake or adding requested context; otherwise use the current chat's no-response behavior.",
100+
].join(" ");
101+
}
102+
78103
function buildLocationContextPayload(ctx: TemplateContext): Record<string, unknown> | undefined {
79104
const payload = {
80105
latitude: typeof ctx.LocationLat === "number" ? ctx.LocationLat : undefined,
@@ -227,6 +252,12 @@ export function buildInboundUserContextPrefix(
227252
is_forum: ctx.IsForum === true ? true : undefined,
228253
is_group_chat: !isDirect ? true : undefined,
229254
was_mentioned: ctx.WasMentioned === true ? true : undefined,
255+
explicitly_mentioned_bot:
256+
typeof ctx.ExplicitlyMentionedBot === "boolean" ? ctx.ExplicitlyMentionedBot : undefined,
257+
mentioned_user_ids: normalizePromptMetadataStringArray(ctx.MentionedUserIds),
258+
mentioned_subteam_ids: normalizePromptMetadataStringArray(ctx.MentionedSubteamIds),
259+
implicit_mention_kinds: normalizePromptMetadataStringArray(ctx.ImplicitMentionKinds),
260+
mention_source: normalizePromptMetadataString(ctx.MentionSource),
230261
has_reply_context: sanitizePromptBody(ctx.ReplyToBody) ? true : undefined,
231262
has_forwarded_context: normalizePromptMetadataString(ctx.ForwardedFrom) ? true : undefined,
232263
has_thread_starter: sanitizePromptBody(ctx.ThreadStarterBody) ? true : undefined,
@@ -238,6 +269,10 @@ export function buildInboundUserContextPrefix(
238269
formatUntrustedJsonBlock("Conversation info (untrusted metadata):", conversationInfo),
239270
);
240271
}
272+
const mentionReplyGuidance = buildMentionReplyGuidance(ctx, isDirect);
273+
if (mentionReplyGuidance) {
274+
blocks.push(mentionReplyGuidance);
275+
}
241276

242277
const senderInfo = {
243278
label: resolveSenderLabel({

src/auto-reply/templating.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,22 @@ export type MsgContext = {
188188
/** Platform bot username when command mentions should be normalized. */
189189
BotUsername?: string;
190190
WasMentioned?: boolean;
191+
/** True when this turn explicitly mentioned the current bot target. */
192+
ExplicitlyMentionedBot?: boolean;
193+
/** Provider-native explicit user mention ids present on this turn. */
194+
MentionedUserIds?: string[];
195+
/** Provider-native explicit user-group/subteam mention ids present on this turn. */
196+
MentionedSubteamIds?: string[];
197+
/** Provider-native implicit mention wake reasons present on this turn. */
198+
ImplicitMentionKinds?: string[];
199+
/** Provider-native source that caused the current mention decision. */
200+
MentionSource?:
201+
| "explicit_bot"
202+
| "subteam"
203+
| "mention_pattern"
204+
| "implicit_thread"
205+
| "command_bypass"
206+
| "none";
191207
CommandAuthorized?: boolean;
192208
CommandSource?: "text" | "native";
193209
CommandTargetSessionKey?: string;

0 commit comments

Comments
 (0)