From 5b1c2ee25f0f95253592d1f3c1f6f87e65ac4ca8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 03:46:18 +0100 Subject: [PATCH] fix(slack): wake on user-group mentions --- CHANGELOG.md | 1 + docs/channels/slack.md | 2 + .../monitor/message-handler/prepare.test.ts | 91 ++++++++++++++ .../src/monitor/message-handler/prepare.ts | 13 +- .../message-handler/subteam-mentions.test.ts | 89 ++++++++++++++ .../message-handler/subteam-mentions.ts | 112 ++++++++++++++++++ extensions/slack/src/setup-shared.ts | 1 + 7 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 extensions/slack/src/monitor/message-handler/subteam-mentions.test.ts create mode 100644 extensions/slack/src/monitor/message-handler/subteam-mentions.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e16070085f5..d21cbaa124e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai - macOS/Voice Wake: send wake-word and Push-to-Talk transcripts through the selected macOS session target instead of always falling back to main WebChat. Fixes #51040. Thanks @carl-jeffrolc. - Providers/xAI: give Grok `web_search` a 60s default timeout, harden malformed xAI Responses parsing, and return structured timeout errors instead of aborting the tool call. Fixes #58063 and #58733. Thanks @dnishimura, @marvcasasola-svg, and @Nanako0129. - Providers/configure: preserve the existing default model when adding or reauthing a provider whose plugin returns a default-model config patch. Fixes #50268. Thanks @rixcorp-oc. +- Slack/mentions: resolve `` user-group mentions through Slack `usergroups.users.list` and treat them as explicit mentions only when the bot user is a member, so mention-gated agent channels wake for real user-group mentions without config-only allowlists. Fixes #73827. Thanks @CG-Intelligence-Agent-Jack. - Slack/message tool: let `read` fetch an exact Slack message timestamp, including a specific thread reply when paired with `threadId`, instead of returning only the parent thread or recent channel history. Fixes #53943. Thanks @zomars. - Web search: point missing-key errors to `web_fetch` for known URLs and the browser tool for interactive pages. Thanks @zhaoyang97. - Web search: late-bind managed agent `web_search` calls to the current runtime config snapshot, so existing sessions do not keep stale unresolved SecretRefs after secrets reload. Fixes #75420. Thanks @richardmqq. diff --git a/docs/channels/slack.md b/docs/channels/slack.md index 856520a8822..0949fb95816 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -205,6 +205,7 @@ Base manifest (Socket Mode default): "pins:write", "reactions:read", "reactions:write", + "usergroups:read", "users:read" ] } @@ -572,6 +573,7 @@ Current Slack message actions include `send`, `upload-file`, `download-file`, `r Mention sources: - explicit app mention (`<@botId>`) + - Slack user-group mention (``) when the bot user is a member of that user group; requires `usergroups:read` - mention regex patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`) - implicit reply-to-bot thread behavior (disabled when `thread.requireExplicitMention` is `true`) diff --git a/extensions/slack/src/monitor/message-handler/prepare.test.ts b/extensions/slack/src/monitor/message-handler/prepare.test.ts index f5988d4fbdc..4e1d0466563 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.test.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.test.ts @@ -27,6 +27,7 @@ import { createSlackSessionStoreFixture, createSlackTestAccount, } from "./prepare.test-helpers.js"; +import { clearSlackSubteamMentionCacheForTest } from "./subteam-mentions.js"; const enqueueSystemEventMock = vi.hoisted(() => vi.fn()); @@ -49,6 +50,7 @@ describe("slack prepareSlackMessage inbound contract", () => { resetSlackThreadStarterCacheForTest(); clearSlackThreadParticipationCache(); clearSlackAllowFromCacheForTest(); + clearSlackSubteamMentionCacheForTest(); enqueueSystemEventMock.mockClear(); }); @@ -1183,6 +1185,95 @@ describe("slack prepareSlackMessage inbound contract", () => { expect(new Set([root!.ctxPayload.SessionKey, followUp!.ctxPayload.SessionKey]).size).toBe(1); }); + it("treats Slack user-group mentions as explicit mentions when the bot is a member", async () => { + const usergroupsUsersList = vi.fn().mockResolvedValue({ + ok: true, + users: ["U_OTHER", "B1"], + }); + const slackCtx = createInboundSlackCtx({ + cfg: { + channels: { + slack: { + enabled: true, + groupPolicy: "open", + channels: { C0AGENTS: { requireMention: true } }, + }, + }, + } as OpenClawConfig, + appClient: { + usergroups: { users: { list: usergroupsUsersList } }, + } as unknown as App["client"], + defaultRequireMention: true, + }); + slackCtx.resolveChannelName = async () => ({ name: "agents", type: "channel" }); + slackCtx.resolveUserName = async () => ({ name: "Bek" }); + + const prepared = await prepareSlackMessage({ + ctx: slackCtx, + account: createSlackAccount(), + message: { + type: "message", + channel: "C0AGENTS", + channel_type: "channel", + user: "U_BEK", + text: " triage this", + ts: "1777244692.409919", + } as SlackMessageEvent, + opts: { source: "message" }, + }); + + expect(usergroupsUsersList).toHaveBeenCalledWith({ + usergroup: "S0AGENTS", + team_id: "T1", + }); + expect(prepared).toBeTruthy(); + expect(prepared!.ctxPayload.WasMentioned).toBe(true); + }); + + it("drops Slack user-group mentions when the bot is not a member", async () => { + const usergroupsUsersList = vi.fn().mockResolvedValue({ + ok: true, + users: ["U_OTHER"], + }); + const slackCtx = createInboundSlackCtx({ + cfg: { + channels: { + slack: { + enabled: true, + groupPolicy: "open", + channels: { C0AGENTS: { requireMention: true } }, + }, + }, + } as OpenClawConfig, + appClient: { + usergroups: { users: { list: usergroupsUsersList } }, + } as unknown as App["client"], + defaultRequireMention: true, + }); + slackCtx.resolveChannelName = async () => ({ name: "agents", type: "channel" }); + slackCtx.resolveUserName = async () => ({ name: "Bek" }); + + const prepared = await prepareSlackMessage({ + ctx: slackCtx, + account: createSlackAccount(), + message: { + type: "message", + channel: "C0AGENTS", + channel_type: "channel", + user: "U_BEK", + text: " triage this", + ts: "1777244692.409920", + } as SlackMessageEvent, + opts: { source: "message" }, + }); + + expect(usergroupsUsersList).toHaveBeenCalledWith({ + usergroup: "S0AGENTS", + team_id: "T1", + }); + expect(prepared).toBeNull(); + }); + it("keeps a regex-mentioned Slack thread root and URL-only follow-up on one parent session", async () => { const { storePath } = storeFixture.makeTmpStorePath(); const rootTs = "1777244692.409919"; diff --git a/extensions/slack/src/monitor/message-handler/prepare.ts b/extensions/slack/src/monitor/message-handler/prepare.ts index b9844584fa6..09cc16ef9b2 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.ts @@ -62,6 +62,7 @@ import { resolveSlackThreadStarter } from "../thread.js"; import { resolveSlackMessageContent } from "./prepare-content.js"; import { resolveSlackRoutingContext } from "./prepare-routing.js"; import { resolveSlackThreadContextData } from "./prepare-thread-context.js"; +import { isSlackSubteamMentionForBot } from "./subteam-mentions.js"; import type { PreparedSlackMessage } from "./types.js"; const mentionRegexCache = new WeakMap>(); @@ -284,9 +285,17 @@ export async function prepareSlackMessage(params: { return null; } const { senderId, allowFromLower } = authorization; - const hasAnyMention = /<@[^>]+>/.test(message.text ?? ""); + const hasAnyMention = /<@[^>]+>|]+>/.test(message.text ?? ""); const explicitlyMentioned = Boolean( - ctx.botUserId && message.text?.includes(`<@${ctx.botUserId}>`), + ctx.botUserId && + (message.text?.includes(`<@${ctx.botUserId}>`) || + (await isSlackSubteamMentionForBot({ + client: ctx.app.client, + text: message.text, + botUserId: ctx.botUserId, + teamId: ctx.teamId, + log: logVerbose, + }))), ); const seedTopLevelRoomThreadBySource = opts.source === "app_mention" || opts.wasMentioned === true || explicitlyMentioned; diff --git a/extensions/slack/src/monitor/message-handler/subteam-mentions.test.ts b/extensions/slack/src/monitor/message-handler/subteam-mentions.test.ts new file mode 100644 index 00000000000..d1fb30170da --- /dev/null +++ b/extensions/slack/src/monitor/message-handler/subteam-mentions.test.ts @@ -0,0 +1,89 @@ +import type { WebClient } from "@slack/web-api"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearSlackSubteamMentionCacheForTest, + extractSlackSubteamMentionIds, + isSlackSubteamMentionForBot, +} from "./subteam-mentions.js"; + +function createClient(users: string[]) { + return { + usergroups: { + users: { + list: vi.fn(async () => ({ ok: true, users })), + }, + }, + } as unknown as WebClient & { + usergroups: { users: { list: ReturnType } }; + }; +} + +describe("Slack subteam mentions", () => { + beforeEach(() => { + clearSlackSubteamMentionCacheForTest(); + }); + + it("extracts unique user-group ids from Slack mention tokens", () => { + expect( + extractSlackSubteamMentionIds(" "), + ).toEqual(["S123", "S456"]); + }); + + it("matches when the bot user is a member of a mentioned user group", async () => { + const client = createClient(["U_OTHER", "U_BOT"]); + + await expect( + isSlackSubteamMentionForBot({ + client, + text: " ping", + botUserId: "u_bot", + teamId: "T1", + now: 1, + }), + ).resolves.toBe(true); + + expect(client.usergroups.users.list).toHaveBeenCalledWith({ + usergroup: "S123", + team_id: "T1", + }); + }); + + it("fails closed and caches successful membership lookups", async () => { + const client = createClient(["U_OTHER"]); + + await expect( + isSlackSubteamMentionForBot({ + client, + text: " ping", + botUserId: "U_BOT", + now: 1, + }), + ).resolves.toBe(false); + await expect( + isSlackSubteamMentionForBot({ + client, + text: " ping again", + botUserId: "U_BOT", + now: 2, + }), + ).resolves.toBe(false); + + expect(client.usergroups.users.list).toHaveBeenCalledTimes(1); + }); + + it("fails closed when Slack rejects the user-group lookup", async () => { + const log = vi.fn(); + const client = createClient([]); + client.usergroups.users.list.mockRejectedValueOnce(new Error("missing_scope")); + + await expect( + isSlackSubteamMentionForBot({ + client, + text: " ping", + botUserId: "U_BOT", + log, + }), + ).resolves.toBe(false); + expect(log).toHaveBeenCalledWith(expect.stringContaining("missing_scope")); + }); +}); diff --git a/extensions/slack/src/monitor/message-handler/subteam-mentions.ts b/extensions/slack/src/monitor/message-handler/subteam-mentions.ts new file mode 100644 index 00000000000..a6ad902e5ec --- /dev/null +++ b/extensions/slack/src/monitor/message-handler/subteam-mentions.ts @@ -0,0 +1,112 @@ +import type { WebClient } from "@slack/web-api"; +import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; + +const SUBTEAM_MENTION_RE = /]*)?>/gi; +const SUBTEAM_MEMBER_CACHE_TTL_MS = 5 * 60 * 1000; + +type CacheEntry = { + expiresAt: number; + users: ReadonlySet; +}; + +let subteamMemberCache = new WeakMap>(); + +function normalizeSlackId(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim().toUpperCase() : undefined; +} + +export function extractSlackSubteamMentionIds(text?: string | null): string[] { + if (!text) { + return []; + } + const ids = new Set(); + for (const match of text.matchAll(SUBTEAM_MENTION_RE)) { + const id = normalizeSlackId(match[1]); + if (id) { + ids.add(id); + } + } + return [...ids]; +} + +async function readSlackSubteamUsers(params: { + client: WebClient; + subteamId: string; + teamId?: string; + now: number; + log?: (message: string) => void; +}): Promise> { + let bySubteam = subteamMemberCache.get(params.client); + if (!bySubteam) { + bySubteam = new Map(); + subteamMemberCache.set(params.client, bySubteam); + } + const cacheKey = `${normalizeSlackId(params.teamId) ?? ""}:${params.subteamId}`; + const cached = bySubteam.get(cacheKey); + if (cached && cached.expiresAt > params.now) { + return cached.users; + } + + try { + const response = await params.client.usergroups.users.list({ + usergroup: params.subteamId, + ...(params.teamId ? { team_id: params.teamId } : {}), + }); + if (!response.ok) { + params.log?.( + `slack: failed to resolve user-group mention ${params.subteamId}: ${response.error ?? "unknown_error"}`, + ); + return new Set(); + } + const users = new Set( + (response.users ?? []).map((userId) => normalizeSlackId(userId)).filter(Boolean) as string[], + ); + bySubteam.set(cacheKey, { + expiresAt: params.now + SUBTEAM_MEMBER_CACHE_TTL_MS, + users, + }); + return users; + } catch (err) { + params.log?.( + `slack: failed to resolve user-group mention ${params.subteamId}: ${formatErrorMessage(err)}`, + ); + return new Set(); + } +} + +export async function isSlackSubteamMentionForBot(params: { + client: WebClient; + text?: string | null; + botUserId?: string | null; + teamId?: string; + now?: number; + log?: (message: string) => void; +}): Promise { + const botUserId = normalizeSlackId(params.botUserId); + if (!botUserId) { + return false; + } + const subteamIds = extractSlackSubteamMentionIds(params.text); + if (subteamIds.length === 0) { + return false; + } + const now = params.now ?? Date.now(); + for (const subteamId of subteamIds) { + const users = await readSlackSubteamUsers({ + client: params.client, + subteamId, + teamId: normalizeOptionalString(params.teamId), + now, + log: params.log, + }); + if (users.has(botUserId)) { + return true; + } + } + return false; +} + +export function clearSlackSubteamMentionCacheForTest(): void { + subteamMemberCache = new WeakMap>(); +} diff --git a/extensions/slack/src/setup-shared.ts b/extensions/slack/src/setup-shared.ts index f685196ebc6..dae7ccc9d72 100644 --- a/extensions/slack/src/setup-shared.ts +++ b/extensions/slack/src/setup-shared.ts @@ -56,6 +56,7 @@ export function buildSlackManifest(botName = "OpenClaw") { "pins:write", "reactions:read", "reactions:write", + "usergroups:read", "users:read", ], },