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
This commit is contained in:
Bek
2026-04-21 19:03:50 -04:00
committed by GitHub
parent 6bf56d8637
commit e116b343b2
4 changed files with 183 additions and 4 deletions

View File

@@ -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 | undefined>): string[] {
const seen = new Set<string>();
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, string | null>,
): 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<SlackResolvedMessageContent | null> {
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<string, string | null>();
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,
]

View File

@@ -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 () => {

View File

@@ -557,6 +557,7 @@ export async function prepareSlackMessage(params: {
isBotMessage,
botToken: ctx.botToken,
mediaMaxBytes: ctx.mediaMaxBytes,
resolveUserName: ctx.resolveUserName,
});
if (!resolvedMessageContent) {
return null;

View File

@@ -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";