mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 13:20:43 +00:00
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:
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -557,6 +557,7 @@ export async function prepareSlackMessage(params: {
|
||||
isBotMessage,
|
||||
botToken: ctx.botToken,
|
||||
mediaMaxBytes: ctx.mediaMaxBytes,
|
||||
resolveUserName: ctx.resolveUserName,
|
||||
});
|
||||
if (!resolvedMessageContent) {
|
||||
return null;
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user