perf(slack): trim thread context allocation

This commit is contained in:
Vincent Koc
2026-05-03 10:47:13 -07:00
parent 46995235ed
commit 0caa419f76
3 changed files with 120 additions and 61 deletions

View File

@@ -13,6 +13,7 @@ type SlackResolvedMessageContent = {
const SLACK_MENTION_RESOLUTION_CONCURRENCY = 4;
const SLACK_MENTION_RESOLUTION_MAX_LOOKUPS_PER_MESSAGE = 20;
const SLACK_USER_MENTION_RE = /<@([A-Z0-9]+)(?:\|[^>]+)?>/gi;
type SlackTextObject = {
text?: unknown;
@@ -54,7 +55,8 @@ function collectUniqueSlackMentionIds(texts: Array<string | undefined>): string[
if (!text) {
continue;
}
for (const match of text.matchAll(/<@([A-Z0-9]+)(?:\|[^>]+)?>/gi)) {
SLACK_USER_MENTION_RE.lastIndex = 0;
for (const match of text.matchAll(SLACK_USER_MENTION_RE)) {
const userId = match[1];
if (!userId || seen.has(userId)) {
continue;
@@ -73,7 +75,8 @@ function renderSlackUserMentions(
if (!text || renderedMentions.size === 0) {
return text;
}
return text.replace(/<@([A-Z0-9]+)(?:\|[^>]+)?>/gi, (full, userId: string) => {
SLACK_USER_MENTION_RE.lastIndex = 0;
return text.replace(SLACK_USER_MENTION_RE, (full, userId: string) => {
const rendered = renderedMentions.get(userId);
return rendered ?? full;
});
@@ -139,16 +142,19 @@ function renderSlackRichTextElements(elements: unknown): string {
break;
}
case "rich_text_list": {
const listText = Array.isArray(element.elements)
? element.elements
.map((child) =>
child && typeof child === "object"
? renderSlackRichTextElements((child as SlackRichTextElement).elements)
: "",
)
.filter(Boolean)
.join("\n")
: "";
const listParts: string[] = [];
if (Array.isArray(element.elements)) {
for (const child of element.elements) {
if (!child || typeof child !== "object") {
continue;
}
const rendered = renderSlackRichTextElements((child as SlackRichTextElement).elements);
if (rendered) {
listParts.push(rendered);
}
}
}
const listText = listParts.join("\n");
parts.push(listText);
break;
}
@@ -174,7 +180,13 @@ function readSlackBlockText(block: unknown): string | undefined {
return text;
}
if (Array.isArray(blockLike.fields)) {
const fields = blockLike.fields.map(readTextObject).filter(Boolean);
const fields: string[] = [];
for (const field of blockLike.fields) {
const fieldText = readTextObject(field);
if (fieldText) {
fields.push(fieldText);
}
}
return fields.length > 0 ? fields.join("\n") : undefined;
}
return undefined;
@@ -185,7 +197,13 @@ function readSlackBlockText(block: unknown): string | undefined {
if (!Array.isArray(blockLike.elements)) {
return undefined;
}
const parts = blockLike.elements.map(readTextObject).filter(Boolean);
const parts: string[] = [];
for (const element of blockLike.elements) {
const text = readTextObject(element);
if (text) {
parts.push(text);
}
}
return parts.length > 0 ? parts.join(" ") : undefined;
}
case "image":
@@ -205,7 +223,13 @@ function resolveSlackBlocksText(blocks: unknown[] | undefined): string | undefin
if (!blocks?.length) {
return undefined;
}
const parts = blocks.map(readSlackBlockText).filter(Boolean);
const parts: string[] = [];
for (const block of blocks) {
const text = readSlackBlockText(block);
if (text) {
parts.push(text);
}
}
return parts.length > 0 ? parts.join("\n") : undefined;
}
@@ -302,17 +326,19 @@ export async function resolveSlackMessageContent(params: {
: undefined;
const fileOnlyPlaceholder = fileOnlyFallback ? `[Slack file: ${fileOnlyFallback}]` : undefined;
const botAttachmentText =
params.isBotMessage && !attachmentContent?.text
? (params.message.attachments ?? [])
.map(
(attachment) =>
normalizeOptionalString(attachment.text) ??
normalizeOptionalString(attachment.fallback),
)
.filter(Boolean)
.join("\n")
: undefined;
let botAttachmentText: string | undefined;
if (params.isBotMessage && !attachmentContent?.text) {
const botAttachmentTextParts: string[] = [];
for (const attachment of params.message.attachments ?? []) {
const text =
normalizeOptionalString(attachment.text) ?? normalizeOptionalString(attachment.fallback);
if (text) {
botAttachmentTextParts.push(text);
}
}
botAttachmentText =
botAttachmentTextParts.length > 0 ? botAttachmentTextParts.join("\n") : undefined;
}
const blocksText = resolveSlackBlocksText(params.message.blocks);
const primaryText = chooseSlackPrimaryText({

View File

@@ -1,4 +1,5 @@
import { formatInboundEnvelope } from "openclaw/plugin-sdk/channel-inbound";
import { runTasksWithConcurrency } from "openclaw/plugin-sdk/concurrency-runtime";
import type { ContextVisibilityMode } from "openclaw/plugin-sdk/config-types";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import {
@@ -29,6 +30,8 @@ type SlackThreadContextData = {
threadStarterMedia: SlackMediaResult[] | null;
};
const SLACK_THREAD_CONTEXT_USER_LOOKUP_CONCURRENCY = 4;
function isSlackThreadContextSenderAllowed(params: {
allowFromLower: string[];
allowNameMatching: boolean;
@@ -50,6 +53,38 @@ function isSlackThreadContextSenderAllowed(params: {
}).allowed;
}
async function resolveSlackThreadUserMap(params: {
ctx: SlackMonitorContext;
messages: SlackThreadStarter[];
}): Promise<Map<string, { name?: string }>> {
const uniqueUserIds: string[] = [];
const seen = new Set<string>();
for (const item of params.messages) {
if (!item.userId || seen.has(item.userId)) {
continue;
}
seen.add(item.userId);
uniqueUserIds.push(item.userId);
}
const userMap = new Map<string, { name?: string }>();
if (uniqueUserIds.length === 0) {
return userMap;
}
const { results } = await runTasksWithConcurrency({
tasks: uniqueUserIds.map((id) => async () => {
const user = await params.ctx.resolveUserName(id);
return user ? { id, user } : null;
}),
limit: SLACK_THREAD_CONTEXT_USER_LOOKUP_CONCURRENCY,
});
for (const result of results) {
if (result) {
userMap.set(result.id, result.user);
}
}
return userMap;
}
export async function resolveSlackThreadContextData(params: {
ctx: SlackMonitorContext;
account: ResolvedSlackAccount;
@@ -92,7 +127,7 @@ export async function resolveSlackThreadContextData(params: {
const starter = params.threadStarter;
const starterSenderName =
params.allowNameMatching && starter?.userId
params.allowNameMatching && params.allowFromLower.length > 0 && starter?.userId
? (await params.ctx.resolveUserName(starter.userId))?.name
: undefined;
const starterIsCurrentBot = Boolean(
@@ -174,39 +209,37 @@ export async function resolveSlackThreadContextData(params: {
const omittedCurrentBotHistoryCount =
threadHistory.length - threadHistoryWithoutCurrentBot.length;
const uniqueUserIds = [
...new Set(
threadHistoryWithoutCurrentBot
.map((item) => item.userId)
.filter((id): id is string => Boolean(id)),
),
];
const userMap = new Map<string, { name?: string }>();
await Promise.all(
uniqueUserIds.map(async (id) => {
const user = await params.ctx.resolveUserName(id);
if (user) {
userMap.set(id, user);
}
}),
);
const userMapForFilter =
params.contextVisibilityMode !== "all" &&
params.allowNameMatching &&
params.allowFromLower.length > 0
? await resolveSlackThreadUserMap({
ctx: params.ctx,
messages: threadHistoryWithoutCurrentBot,
})
: new Map<string, { name?: string }>();
const { items: filteredThreadHistory, omitted: omittedHistoryCount } =
filterSupplementalContextItems({
items: threadHistoryWithoutCurrentBot,
mode: params.contextVisibilityMode,
kind: "thread",
isSenderAllowed: (historyMsg) => {
const msgUser = historyMsg.userId ? userMap.get(historyMsg.userId) : null;
return isSlackThreadContextSenderAllowed({
allowFromLower: params.allowFromLower,
allowNameMatching: params.allowNameMatching,
userId: historyMsg.userId,
userName: msgUser?.name,
botId: historyMsg.botId,
params.contextVisibilityMode === "all"
? { items: threadHistoryWithoutCurrentBot, omitted: 0 }
: filterSupplementalContextItems({
items: threadHistoryWithoutCurrentBot,
mode: params.contextVisibilityMode,
kind: "thread",
isSenderAllowed: (historyMsg) => {
const msgUser = historyMsg.userId ? userMapForFilter.get(historyMsg.userId) : null;
return isSlackThreadContextSenderAllowed({
allowFromLower: params.allowFromLower,
allowNameMatching: params.allowNameMatching,
userId: historyMsg.userId,
userName: msgUser?.name,
botId: historyMsg.botId,
});
},
});
},
});
const userMap = await resolveSlackThreadUserMap({
ctx: params.ctx,
messages: filteredThreadHistory,
});
if (omittedHistoryCount > 0 || omittedCurrentBotHistoryCount > 0) {
logVerbose(
`slack: omitted ${omittedHistoryCount + omittedCurrentBotHistoryCount} thread message(s) from context (mode=${params.contextVisibilityMode})`,

View File

@@ -163,9 +163,9 @@ export async function resolveSlackThreadHistory(params: {
continue;
}
retained.push(msg);
if (retained.length > maxMessages) {
retained.shift();
}
}
if (retained.length > maxMessages) {
retained.splice(0, retained.length - maxMessages);
}
const next = response.response_metadata?.next_cursor;