From e116b343b25224b1ae4f3102ec30fda511625203 Mon Sep 17 00:00:00 2001 From: Bek <66288351+bek91@users.noreply.github.com> Date: Tue, 21 Apr 2026 19:03:50 -0400 Subject: [PATCH] feat(slack): Annotate inbound Slack mention tokens in Slack RawBody and BodyForAgent content so the agent sees both the actionable Slack mention token and a human-readable name. (#65731) * Annotate inbound Slack mentions in raw bodies * Avoid shared regex state in Slack mention rendering * Bound Slack mention lookups with concurrency * slack: keep mention concurrency helper plugin-local * test: stabilize node core CI assertions * slack: cap mention lookups per inbound message * test: reset suite gateway runtime state * fix(slack): reuse plugin sdk concurrency helper --- .../message-handler/prepare-content.ts | 79 ++++++++++++- .../monitor/message-handler/prepare.test.ts | 106 +++++++++++++++++- .../src/monitor/message-handler/prepare.ts | 1 + src/plugin-sdk/infra-runtime.ts | 1 + 4 files changed, 183 insertions(+), 4 deletions(-) diff --git a/extensions/slack/src/monitor/message-handler/prepare-content.ts b/extensions/slack/src/monitor/message-handler/prepare-content.ts index 326e81acbd9..18d02cbdcff 100644 --- a/extensions/slack/src/monitor/message-handler/prepare-content.ts +++ b/extensions/slack/src/monitor/message-handler/prepare-content.ts @@ -1,3 +1,4 @@ +import { runTasksWithConcurrency } from "openclaw/plugin-sdk/infra-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import type { SlackFile, SlackMessageEvent } from "../../types.js"; @@ -14,6 +15,41 @@ export type SlackResolvedMessageContent = { effectiveDirectMedia: SlackMediaResult[] | null; }; +const SLACK_MENTION_RESOLUTION_CONCURRENCY = 4; +const SLACK_MENTION_RESOLUTION_MAX_LOOKUPS_PER_MESSAGE = 20; + +function collectUniqueSlackMentionIds(texts: Array): string[] { + const seen = new Set(); + const mentionIds: string[] = []; + for (const text of texts) { + if (!text) { + continue; + } + for (const match of text.matchAll(/<@([A-Z0-9]+)(?:\|[^>]+)?>/gi)) { + const userId = match[1]; + if (!userId || seen.has(userId)) { + continue; + } + seen.add(userId); + mentionIds.push(userId); + } + } + return mentionIds; +} + +function renderSlackUserMentions( + text: string | undefined, + renderedMentions: ReadonlyMap, +): string | undefined { + if (!text || renderedMentions.size === 0) { + return text; + } + return text.replace(/<@([A-Z0-9]+)(?:\|[^>]+)?>/gi, (full, userId: string) => { + const rendered = renderedMentions.get(userId); + return rendered ?? full; + }); +} + function filterInheritedParentFiles(params: { files: SlackFile[] | undefined; isThreadReply: boolean; @@ -43,6 +79,7 @@ export async function resolveSlackMessageContent(params: { isBotMessage: boolean; botToken: string; mediaMaxBytes: number; + resolveUserName?: (userId: string) => Promise<{ name?: string }>; }): Promise { const ownFiles = filterInheritedParentFiles({ files: params.message.files, @@ -90,11 +127,47 @@ export async function resolveSlackMessageContent(params: { .join("\n") : undefined; + const textParts = [ + normalizeOptionalString(params.message.text), + attachmentContent?.text, + botAttachmentText, + ]; + const renderedMentions = new Map(); + const resolveUserName = params.resolveUserName; + if (resolveUserName) { + const mentionIds = collectUniqueSlackMentionIds(textParts); + const lookupIds = mentionIds.slice(0, SLACK_MENTION_RESOLUTION_MAX_LOOKUPS_PER_MESSAGE); + const skippedLookups = mentionIds.length - lookupIds.length; + if (skippedLookups > 0) { + logVerbose( + `slack: skipping ${skippedLookups} mention lookup(s) beyond per-message cap (${SLACK_MENTION_RESOLUTION_MAX_LOOKUPS_PER_MESSAGE})`, + ); + } + const { results } = await runTasksWithConcurrency({ + tasks: lookupIds.map((userId) => async () => { + const user = await resolveUserName(userId); + const renderedName = normalizeOptionalString(user?.name); + return { userId, rendered: renderedName ? `<@${userId}> (${renderedName})` : null }; + }), + limit: SLACK_MENTION_RESOLUTION_CONCURRENCY, + }); + for (const result of results) { + if (!result) { + continue; + } + renderedMentions.set(result.userId, result.rendered); + } + } + + const renderedMessageText = renderSlackUserMentions(textParts[0], renderedMentions); + const renderedAttachmentText = renderSlackUserMentions(textParts[1], renderedMentions); + const renderedBotAttachmentText = renderSlackUserMentions(textParts[2], renderedMentions); + const rawBody = [ - normalizeOptionalString(params.message.text), - attachmentContent?.text, - botAttachmentText, + renderedMessageText, + renderedAttachmentText, + renderedBotAttachmentText, mediaPlaceholder, fileOnlyPlaceholder, ] diff --git a/extensions/slack/src/monitor/message-handler/prepare.test.ts b/extensions/slack/src/monitor/message-handler/prepare.test.ts index fc141f6bd34..a40f8fb6d84 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.test.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.test.ts @@ -8,6 +8,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { ResolvedSlackAccount } from "../../accounts.js"; import type { SlackMessageEvent } from "../../types.js"; import type { SlackMonitorContext } from "../context.js"; +import { resolveSlackMessageContent } from "./prepare-content.js"; import { prepareSlackMessage } from "./prepare.js"; import { createInboundSlackTestContext, @@ -670,17 +671,120 @@ describe("prepareSlackMessage sender prefix", () => { }); } - it("prefixes channel bodies with sender label", async () => { + it("prefixes channel bodies with sender label and annotates Slack mention tokens", async () => { const ctx = createSenderPrefixCtx({ channels: {}, slashCommand: { command: "/openclaw", enabled: true }, }); + ctx.resolveUserName = async (id: string) => ({ name: id === "U1" ? "Alice" : "Bek" }) as any; + + const result = await prepareSenderPrefixMessage(ctx, "<@BOT> hello", "1700000000.0001"); + + expect(result).not.toBeNull(); + const body = result?.ctxPayload.Body ?? ""; + expect(body).toContain("Alice (U1): <@BOT> (Bek) hello"); + expect(result?.ctxPayload.RawBody).toBe("<@BOT> (Bek) hello"); + }); + + it("keeps raw Slack mention tokens when user lookup cannot resolve them", async () => { + const ctx = createSenderPrefixCtx({ + channels: {}, + slashCommand: { command: "/openclaw", enabled: true }, + }); + ctx.resolveUserName = async (id: string) => + ({ name: id === "U1" ? "Alice" : undefined }) as any; const result = await prepareSenderPrefixMessage(ctx, "<@BOT> hello", "1700000000.0001"); expect(result).not.toBeNull(); const body = result?.ctxPayload.Body ?? ""; expect(body).toContain("Alice (U1): <@BOT> hello"); + expect(result?.ctxPayload.RawBody).toBe("<@BOT> hello"); + }); + + it("caps Slack mention username lookups per inbound message and leaves overflow mentions raw", async () => { + const mentionIds = Array.from( + { length: 22 }, + (_, index) => `U${String(index + 1).padStart(2, "0")}`, + ); + const resolveUserName = vi.fn(async (userId: string) => ({ name: `Name ${userId}` })); + + const result = await resolveSlackMessageContent({ + message: { + type: "message", + channel: "C1", + channel_type: "channel", + user: "U1", + text: mentionIds.map((userId) => `<@${userId}>`).join(" "), + ts: "1700000000.0003", + event_ts: "1700000000.0003", + } as SlackMessageEvent, + isThreadReply: false, + threadStarter: null, + isBotMessage: false, + botToken: "xoxb-test", + mediaMaxBytes: 1000, + resolveUserName, + }); + + expect(result?.rawBody).toContain("<@U01> (Name U01)"); + expect(result?.rawBody).toContain("<@U20> (Name U20)"); + expect(result?.rawBody).toContain("<@U21>"); + expect(result?.rawBody).toContain("<@U22>"); + expect(result?.rawBody).not.toContain("<@U21> ("); + expect(result?.rawBody).not.toContain("<@U22> ("); + expect(resolveUserName).toHaveBeenCalledTimes(20); + expect(resolveUserName.mock.calls.map(([userId]) => userId)).toEqual(mentionIds.slice(0, 20)); + }); + + it("shares the per-message mention lookup budget across message text and attachment text", async () => { + const messageMentionIds = Array.from( + { length: 15 }, + (_, index) => `U${String(index + 1).padStart(2, "0")}`, + ); + const attachmentMentionIds = [ + "U10", + ...Array.from({ length: 10 }, (_, index) => `U${String(index + 16).padStart(2, "0")}`), + ]; + const resolveUserName = vi.fn(async (userId: string) => ({ name: `Name ${userId}` })); + + const result = await resolveSlackMessageContent({ + message: { + type: "message", + channel: "C1", + channel_type: "channel", + user: "U1", + text: messageMentionIds.map((userId) => `<@${userId}>`).join(" "), + attachments: [ + { + is_share: true, + text: attachmentMentionIds.map((userId) => `<@${userId}>`).join(" "), + }, + ], + ts: "1700000000.0004", + event_ts: "1700000000.0004", + } as SlackMessageEvent, + isThreadReply: false, + threadStarter: null, + isBotMessage: false, + botToken: "xoxb-test", + mediaMaxBytes: 1000, + resolveUserName, + }); + + expect(result?.rawBody).toContain("<@U10> (Name U10)"); + expect(result?.rawBody).toContain("<@U20> (Name U20)"); + expect(result?.rawBody).toContain("<@U21>"); + expect(result?.rawBody).not.toContain("<@U21> ("); + expect(resolveUserName).toHaveBeenCalledTimes(20); + expect(resolveUserName.mock.calls.map(([userId]) => userId)).toEqual([ + ...messageMentionIds, + "U16", + "U17", + "U18", + "U19", + "U20", + ]); }); it("detects /new as control command when prefixed with Slack mention", async () => { diff --git a/extensions/slack/src/monitor/message-handler/prepare.ts b/extensions/slack/src/monitor/message-handler/prepare.ts index d753dd2dde8..334115aff43 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.ts @@ -557,6 +557,7 @@ export async function prepareSlackMessage(params: { isBotMessage, botToken: ctx.botToken, mediaMaxBytes: ctx.mediaMaxBytes, + resolveUserName: ctx.resolveUserName, }); if (!resolvedMessageContent) { return null; diff --git a/src/plugin-sdk/infra-runtime.ts b/src/plugin-sdk/infra-runtime.ts index 59542afcb04..b0f0514973f 100644 --- a/src/plugin-sdk/infra-runtime.ts +++ b/src/plugin-sdk/infra-runtime.ts @@ -114,5 +114,6 @@ export * from "../infra/tmp-openclaw-dir.js"; export * from "../infra/transport-ready.js"; export * from "../infra/wsl.ts"; export * from "../utils/fetch-timeout.js"; +export * from "../utils/run-with-concurrency.js"; export { createRuntimeOutboundDelegates } from "../channels/plugins/runtime-forwarders.js"; export * from "./ssrf-policy.js";