mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
fix(slack): wake on user-group mentions
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
112
extensions/slack/src/monitor/message-handler/subteam-mentions.ts
Normal file
112
extensions/slack/src/monitor/message-handler/subteam-mentions.ts
Normal 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>>();
|
||||
}
|
||||
@@ -56,6 +56,7 @@ export function buildSlackManifest(botName = "OpenClaw") {
|
||||
"pins:write",
|
||||
"reactions:read",
|
||||
"reactions:write",
|
||||
"usergroups:read",
|
||||
"users:read",
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user