fix(slack): wake on user-group mentions

This commit is contained in:
Peter Steinberger
2026-05-02 03:46:18 +01:00
parent f739edcf4c
commit 5b1c2ee25f
7 changed files with 307 additions and 2 deletions

View File

@@ -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 `<!subteam^...>` 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.

View File

@@ -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 (`<!subteam^S...>`) 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`)

View File

@@ -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: "<!subteam^S0AGENTS|agents> 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: "<!subteam^S0AGENTS|agents> 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";

View File

@@ -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<SlackMonitorContext, Map<string, RegExp[]>>();
@@ -284,9 +285,17 @@ export async function prepareSlackMessage(params: {
return null;
}
const { senderId, allowFromLower } = authorization;
const hasAnyMention = /<@[^>]+>/.test(message.text ?? "");
const hasAnyMention = /<@[^>]+>|<!subteam\^[^>]+>/.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;

View File

@@ -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<typeof vi.fn> } };
};
}
describe("Slack subteam mentions", () => {
beforeEach(() => {
clearSlackSubteamMentionCacheForTest();
});
it("extracts unique user-group ids from Slack mention tokens", () => {
expect(
extractSlackSubteamMentionIds("<!subteam^S123|eng> <!subteam^s456> <!subteam^S123>"),
).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: "<!subteam^S123|eng> 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: "<!subteam^S123> ping",
botUserId: "U_BOT",
now: 1,
}),
).resolves.toBe(false);
await expect(
isSlackSubteamMentionForBot({
client,
text: "<!subteam^S123> 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: "<!subteam^S123> ping",
botUserId: "U_BOT",
log,
}),
).resolves.toBe(false);
expect(log).toHaveBeenCalledWith(expect.stringContaining("missing_scope"));
});
});

View File

@@ -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 = /<!subteam\^([A-Z0-9]+)(?:\|[^>]*)?>/gi;
const SUBTEAM_MEMBER_CACHE_TTL_MS = 5 * 60 * 1000;
type CacheEntry = {
expiresAt: number;
users: ReadonlySet<string>;
};
let subteamMemberCache = new WeakMap<WebClient, Map<string, CacheEntry>>();
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<string>();
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<ReadonlySet<string>> {
let bySubteam = subteamMemberCache.get(params.client);
if (!bySubteam) {
bySubteam = new Map<string, CacheEntry>();
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<boolean> {
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<WebClient, Map<string, CacheEntry>>();
}

View File

@@ -56,6 +56,7 @@ export function buildSlackManifest(botName = "OpenClaw") {
"pins:write",
"reactions:read",
"reactions:write",
"usergroups:read",
"users:read",
],
},