From 94ff3c6b852ffbec2c0befead9071647dd4d42a3 Mon Sep 17 00:00:00 2001 From: notask-007 <37237335+chthtlo@users.noreply.github.com> Date: Sun, 5 Jul 2026 01:49:43 +0700 Subject: [PATCH] fix(slack): include attachment text in thread context (#97727) * fix(slack): include attachment text in thread context * fix(slack): cover attachment block fallback * fix(slack): collect full block thread text * refactor(slack): share thread block text extraction Co-authored-by: notask-007 <37237335+chthtlo@users.noreply.github.com> --------- Co-authored-by: notask-007 <> Co-authored-by: Peter Steinberger --- extensions/slack/src/monitor/block-text.ts | 190 ++++++++++++++++ extensions/slack/src/monitor/media.test.ts | 113 ++++++++++ .../message-handler/prepare-content.ts | 206 +----------------- extensions/slack/src/monitor/thread.ts | 63 +++++- extensions/slack/src/types.ts | 3 + 5 files changed, 366 insertions(+), 209 deletions(-) create mode 100644 extensions/slack/src/monitor/block-text.ts diff --git a/extensions/slack/src/monitor/block-text.ts b/extensions/slack/src/monitor/block-text.ts new file mode 100644 index 000000000000..dac989e4aeeb --- /dev/null +++ b/extensions/slack/src/monitor/block-text.ts @@ -0,0 +1,190 @@ +import { + normalizeOptionalString, + readStringValue as readString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; + +type SlackTextObject = { + text?: unknown; +}; + +type SlackRichTextElement = { + type?: unknown; + text?: unknown; + url?: unknown; + user_id?: unknown; + channel_id?: unknown; + usergroup_id?: unknown; + name?: unknown; + range?: unknown; + elements?: unknown; +}; + +type SlackBlockLike = { + type?: unknown; + text?: unknown; + elements?: unknown; + fields?: unknown; + alt_text?: unknown; + title?: unknown; +}; + +type SlackBlocksText = { + text: string; + hasRichText: boolean; +}; + +function readTextObject(value: unknown): string | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + return normalizeOptionalString(readString((value as SlackTextObject).text)); +} + +function renderSlackRichTextLeaf(element: SlackRichTextElement): string { + switch (element.type) { + case "text": + return readString(element.text) ?? ""; + case "link": + return readString(element.text) ?? readString(element.url) ?? ""; + case "user": { + const userId = readString(element.user_id); + return userId ? `<@${userId}>` : ""; + } + case "channel": { + const channelId = readString(element.channel_id); + return channelId ? `<#${channelId}>` : ""; + } + case "usergroup": { + const usergroupId = readString(element.usergroup_id); + return usergroupId ? `` : ""; + } + case "broadcast": { + const range = readString(element.range); + return range ? `` : ""; + } + case "emoji": { + const name = readString(element.name); + return name ? `:${name}:` : ""; + } + default: + return ""; + } +} + +function renderSlackRichTextElements(elements: unknown): string { + if (!Array.isArray(elements)) { + return ""; + } + const parts: string[] = []; + for (const rawElement of elements) { + if (!rawElement || typeof rawElement !== "object") { + continue; + } + const element = rawElement as SlackRichTextElement; + switch (element.type) { + case "rich_text_section": + case "rich_text_preformatted": + case "rich_text_quote": + parts.push(renderSlackRichTextElements(element.elements)); + break; + case "rich_text_list": { + 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); + } + } + } + parts.push(listParts.join("\n")); + break; + } + default: + parts.push(renderSlackRichTextLeaf(element)); + break; + } + } + return parts.join(""); +} + +function readSlackBlockText(block: unknown): string | undefined { + if (!block || typeof block !== "object") { + return undefined; + } + const blockLike = block as SlackBlockLike; + switch (blockLike.type) { + case "rich_text": + return normalizeOptionalString(renderSlackRichTextElements(blockLike.elements)); + case "section": { + const text = readTextObject(blockLike.text); + if (text) { + return text; + } + if (!Array.isArray(blockLike.fields)) { + return undefined; + } + const fields = blockLike.fields.flatMap((field) => readTextObject(field) ?? []); + return fields.length > 0 ? fields.join("\n") : undefined; + } + case "header": + return readTextObject(blockLike.text); + case "context": { + if (!Array.isArray(blockLike.elements)) { + return undefined; + } + const parts = blockLike.elements.flatMap((element) => readTextObject(element) ?? []); + return parts.length > 0 ? parts.join(" ") : undefined; + } + case "image": + return ( + normalizeOptionalString(readString(blockLike.alt_text)) ?? readTextObject(blockLike.title) + ); + case "video": + return ( + readTextObject(blockLike.title) ?? normalizeOptionalString(readString(blockLike.alt_text)) + ); + default: + return undefined; + } +} + +export function resolveSlackBlocksText(blocks: unknown[] | undefined): SlackBlocksText | undefined { + if (!blocks?.length) { + return undefined; + } + const parts: string[] = []; + let hasRichText = false; + for (const block of blocks) { + if (block && typeof block === "object" && (block as SlackBlockLike).type === "rich_text") { + hasRichText = true; + } + const text = readSlackBlockText(block); + if (text) { + parts.push(text); + } + } + return parts.length > 0 ? { text: parts.join("\n"), hasRichText } : undefined; +} + +export function chooseSlackPrimaryText(params: { + messageText: string | undefined; + blocksText: SlackBlocksText | undefined; +}): string | undefined { + const { messageText, blocksText } = params; + if (!blocksText) { + return messageText; + } + if (!messageText) { + return blocksText.text; + } + if (blocksText.hasRichText && blocksText.text.length > messageText.length) { + return blocksText.text; + } + return blocksText.text.length > messageText.length && blocksText.text.startsWith(messageText) + ? blocksText.text + : messageText; +} diff --git a/extensions/slack/src/monitor/media.test.ts b/extensions/slack/src/monitor/media.test.ts index 7e7f8f206c7c..5a396dd07008 100644 --- a/extensions/slack/src/monitor/media.test.ts +++ b/extensions/slack/src/monitor/media.test.ts @@ -1022,6 +1022,82 @@ describe("resolveSlackThreadHistory", () => { expect(result[1]?.text).toBe("hello"); }); + it("extracts thread text from Slack attachment and block surfaces", async () => { + const replies = vi.fn().mockResolvedValueOnce({ + messages: [ + { + text: " ", + bot_id: "BMONITOR", + ts: "1.000", + attachments: [ + { + title: "Filesystem on /dev/sda1 has only 14.93% available space left.", + fallback: "Alert: filesystem space is low", + fields: [{ title: "Host", value: "dc2.ipa.mgt" }], + }, + ], + }, + { + text: " ", + bot_id: "BMONITOR", + ts: "2.000", + blocks: [{ type: "section", text: { type: "mrkdwn", text: "Pod restart rate is high" } }], + }, + { + text: " ", + bot_id: "BMONITOR", + ts: "3.000", + attachments: [ + { + blocks: [ + { type: "header", text: { type: "plain_text", text: "Alert firing" } }, + { + type: "section", + fields: [ + { type: "mrkdwn", text: "*host:* dc2.ipa.mgt" }, + { type: "mrkdwn", text: "*device:* /dev/sda1" }, + ], + }, + { + type: "section", + text: { type: "mrkdwn", text: "Free space below threshold" }, + }, + ], + }, + ], + }, + { + text: " line one\nline two ", + ts: "4.000", + }, + ], + response_metadata: { next_cursor: "" }, + }); + const client = { + conversations: { replies }, + } as unknown as Parameters[0]["client"]; + + const result = await resolveSlackThreadHistory({ + channelId: "C1", + threadTs: "1.000", + client, + limit: 10, + }); + + expect(result.map((entry) => entry.text)).toEqual([ + "Filesystem on /dev/sda1 has only 14.93% available space left.\nAlert: filesystem space is low\nHost\ndc2.ipa.mgt", + "Pod restart rate is high", + "Alert firing\n*host:* dc2.ipa.mgt\n*device:* /dev/sda1\nFree space below threshold", + "line one\nline two", + ]); + expect(result.map((entry) => entry.botId)).toEqual([ + "BMONITOR", + "BMONITOR", + "BMONITOR", + undefined, + ]); + }); + it("returns empty when limit is zero without calling Slack API", async () => { const replies = vi.fn(); const client = { @@ -1106,6 +1182,43 @@ describe("resolveSlackThreadStarter", () => { expect(vi.mocked(logVerbose)).not.toHaveBeenCalled(); }); + it("returns the starter text from Slack attachments when bot message text is empty", async () => { + const replies = vi.fn().mockResolvedValueOnce({ + messages: [ + { + text: " ", + bot_id: "BMONITOR", + ts: "1.000", + attachments: [ + { + pretext: "[FIRING:1] HostFilesystemSpaceLow", + title: "Filesystem on /dev/sda1 has only 14.93% available space left.", + fallback: "dc2.ipa.mgt /dev/sda1 low free space", + }, + ], + }, + ], + }); + const client = { + conversations: { replies }, + } as unknown as Parameters[0]["client"]; + + const result = await resolveSlackThreadStarter({ + channelId: "C1", + threadTs: "1.000", + client, + }); + + expect(result).toEqual({ + text: "[FIRING:1] HostFilesystemSpaceLow\nFilesystem on /dev/sda1 has only 14.93% available space left.\ndc2.ipa.mgt /dev/sda1 low free space", + userId: undefined, + botId: "BMONITOR", + ts: "1.000", + files: undefined, + }); + expect(vi.mocked(logVerbose)).not.toHaveBeenCalled(); + }); + it("returns a placeholder starter when the root message only has files", async () => { const replies = vi.fn().mockResolvedValueOnce({ messages: [ diff --git a/extensions/slack/src/monitor/message-handler/prepare-content.ts b/extensions/slack/src/monitor/message-handler/prepare-content.ts index 14a604265c80..7131a2cf8fc9 100644 --- a/extensions/slack/src/monitor/message-handler/prepare-content.ts +++ b/extensions/slack/src/monitor/message-handler/prepare-content.ts @@ -3,12 +3,10 @@ import type { WebClient as SlackWebClient } from "@slack/web-api"; import { runTasksWithConcurrency } from "openclaw/plugin-sdk/concurrency-runtime"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; -import { - normalizeOptionalString, - readStringValue as readString, -} from "openclaw/plugin-sdk/string-coerce-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { formatSlackFileReference } from "../../file-reference.js"; import type { SlackFile, SlackMessageEvent } from "../../types.js"; +import { chooseSlackPrimaryText, resolveSlackBlocksText } from "../block-text.js"; import { MAX_SLACK_MEDIA_FILES, type SlackMediaResult } from "../media-types.js"; import type { SlackThreadStarter } from "../thread.js"; @@ -21,36 +19,6 @@ 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; -}; - -type SlackRichTextElement = { - type?: unknown; - text?: unknown; - url?: unknown; - user_id?: unknown; - channel_id?: unknown; - usergroup_id?: unknown; - name?: unknown; - range?: unknown; - elements?: unknown; -}; - -type SlackBlockLike = { - type?: unknown; - text?: unknown; - elements?: unknown; - fields?: unknown; - alt_text?: unknown; - title?: unknown; -}; - -type SlackBlocksText = { - text: string; - hasRichText: boolean; -}; - const loadSlackMediaModule = createLazyRuntimeModule(() => import("../media.js")); function collectUniqueSlackMentionIds(texts: Array): string[] { @@ -87,176 +55,6 @@ function renderSlackUserMentions( }); } -function readTextObject(value: unknown): string | undefined { - if (!value || typeof value !== "object") { - return undefined; - } - return normalizeOptionalString(readString((value as SlackTextObject).text)); -} - -function renderSlackRichTextLeaf(element: SlackRichTextElement): string { - switch (element.type) { - case "text": - return readString(element.text) ?? ""; - case "link": - return readString(element.text) ?? readString(element.url) ?? ""; - case "user": { - const userId = readString(element.user_id); - return userId ? `<@${userId}>` : ""; - } - case "channel": { - const channelId = readString(element.channel_id); - return channelId ? `<#${channelId}>` : ""; - } - case "usergroup": { - const usergroupId = readString(element.usergroup_id); - return usergroupId ? `` : ""; - } - case "broadcast": { - const range = readString(element.range); - return range ? `` : ""; - } - case "emoji": { - const name = readString(element.name); - return name ? `:${name}:` : ""; - } - default: - return ""; - } -} - -function renderSlackRichTextElements(elements: unknown): string { - if (!Array.isArray(elements)) { - return ""; - } - const parts: string[] = []; - for (const rawElement of elements) { - if (!rawElement || typeof rawElement !== "object") { - continue; - } - const element = rawElement as SlackRichTextElement; - switch (element.type) { - case "rich_text_section": - case "rich_text_preformatted": - case "rich_text_quote": { - parts.push(renderSlackRichTextElements(element.elements)); - break; - } - case "rich_text_list": { - 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; - } - default: - parts.push(renderSlackRichTextLeaf(element)); - break; - } - } - return parts.join(""); -} - -function readSlackBlockText(block: unknown): string | undefined { - if (!block || typeof block !== "object") { - return undefined; - } - const blockLike = block as SlackBlockLike; - switch (blockLike.type) { - case "rich_text": - return normalizeOptionalString(renderSlackRichTextElements(blockLike.elements)); - case "section": { - const text = readTextObject(blockLike.text); - if (text) { - return text; - } - if (Array.isArray(blockLike.fields)) { - 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; - } - case "header": - return readTextObject(blockLike.text); - case "context": { - if (!Array.isArray(blockLike.elements)) { - return undefined; - } - 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": - return ( - normalizeOptionalString(readString(blockLike.alt_text)) ?? readTextObject(blockLike.title) - ); - case "video": - return ( - readTextObject(blockLike.title) ?? normalizeOptionalString(readString(blockLike.alt_text)) - ); - default: - return undefined; - } -} - -function resolveSlackBlocksText(blocks: unknown[] | undefined): SlackBlocksText | undefined { - if (!blocks?.length) { - return undefined; - } - const parts: string[] = []; - let hasRichText = false; - for (const block of blocks) { - if (block && typeof block === "object" && (block as SlackBlockLike).type === "rich_text") { - hasRichText = true; - } - const text = readSlackBlockText(block); - if (text) { - parts.push(text); - } - } - return parts.length > 0 ? { text: parts.join("\n"), hasRichText } : undefined; -} - -function chooseSlackPrimaryText(params: { - messageText: string | undefined; - blocksText: SlackBlocksText | undefined; -}): string | undefined { - const { messageText, blocksText } = params; - if (!blocksText) { - return messageText; - } - if (!messageText) { - return blocksText.text; - } - if (blocksText.hasRichText && blocksText.text.length > messageText.length) { - return blocksText.text; - } - return blocksText.text.length > messageText.length && blocksText.text.startsWith(messageText) - ? blocksText.text - : messageText; -} - function filterInheritedParentFiles(params: { files: SlackFile[] | undefined; isThreadReply: boolean; diff --git a/extensions/slack/src/monitor/thread.ts b/extensions/slack/src/monitor/thread.ts index d75f722d46c7..e2ac4a0cb531 100644 --- a/extensions/slack/src/monitor/thread.ts +++ b/extensions/slack/src/monitor/thread.ts @@ -6,8 +6,10 @@ import { asDateTimestampMs, resolveExpiresAtMsFromDurationMs, } from "openclaw/plugin-sdk/number-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { formatSlackFileReferenceList } from "../file-reference.js"; -import type { SlackFile } from "../types.js"; +import type { SlackAttachment, SlackFile } from "../types.js"; +import { resolveSlackBlocksText } from "./block-text.js"; import { logVerbose } from "./thread.runtime.js"; export type SlackThreadStarter = { @@ -45,6 +47,52 @@ function formatSlackFilePlaceholder(files: SlackFile[] | undefined): string { return `[attached: ${formatSlackFileReferenceList(files)}]`; } +function pushUniqueText(parts: string[], value: string | undefined): void { + const text = normalizeOptionalString(value); + if (text && !parts.includes(text)) { + parts.push(text); + } +} + +function resolveSlackBlocksFallbackText(blocks: unknown[] | undefined): string | undefined { + return resolveSlackBlocksText(blocks)?.text; +} + +function resolveSlackAttachmentFallbackText( + attachments: SlackAttachment[] | undefined, +): string | undefined { + if (!Array.isArray(attachments) || attachments.length === 0) { + return undefined; + } + + const parts: string[] = []; + for (const attachment of attachments) { + pushUniqueText(parts, attachment.pretext); + pushUniqueText(parts, attachment.title); + pushUniqueText(parts, attachment.text); + pushUniqueText(parts, attachment.fallback); + for (const field of attachment.fields ?? []) { + pushUniqueText(parts, field.title); + pushUniqueText(parts, field.value); + } + pushUniqueText(parts, resolveSlackBlocksFallbackText(attachment.blocks)); + pushUniqueText(parts, resolveSlackBlocksFallbackText(attachment.message_blocks)); + } + return parts.length > 0 ? parts.join("\n") : undefined; +} + +function resolveSlackMessageText(message: { + text?: string; + blocks?: unknown[]; + attachments?: SlackAttachment[]; +}): string | undefined { + return ( + normalizeOptionalString(message.text) ?? + resolveSlackAttachmentFallbackText(message.attachments) ?? + resolveSlackBlocksFallbackText(message.blocks) + ); +} + export async function resolveSlackThreadStarter(params: { channelId: string; threadTs: string; @@ -73,10 +121,12 @@ export async function resolveSlackThreadStarter(params: { bot_id?: string; ts?: string; files?: SlackFile[]; + blocks?: unknown[]; + attachments?: SlackAttachment[]; }>; }; const message = response?.messages?.[0]; - const text = (message?.text ?? "").trim(); + const text = message ? resolveSlackMessageText(message) : undefined; const files = message?.files?.length ? message.files : undefined; if (!message || (!text && !files)) { return null; @@ -126,6 +176,8 @@ type SlackRepliesPageMessage = { bot_id?: string; ts?: string; files?: SlackFile[]; + blocks?: unknown[]; + attachments?: SlackAttachment[]; }; type SlackRepliesPage = { @@ -168,8 +220,9 @@ export async function resolveSlackThreadHistory(params: { })) as SlackRepliesPage; for (const msg of response.messages ?? []) { - // Keep messages with text OR file attachments. - if (!msg.text?.trim() && !msg.files?.length) { + const text = resolveSlackMessageText(msg); + // Keep messages with text, Slack attachment/block fallback text, or file attachments. + if (!text && !msg.files?.length) { continue; } if (params.currentMessageTs && msg.ts === params.currentMessageTs) { @@ -187,7 +240,7 @@ export async function resolveSlackThreadHistory(params: { return retained.map((msg) => ({ // For file-only messages, create a placeholder showing attached filenames. - text: msg.text?.trim() ? msg.text : formatSlackFilePlaceholder(msg.files), + text: resolveSlackMessageText(msg) ?? formatSlackFilePlaceholder(msg.files), userId: msg.user, botId: msg.bot_id, ts: msg.ts, diff --git a/extensions/slack/src/types.ts b/extensions/slack/src/types.ts index 7c8ceba624ee..d3c9fe785181 100644 --- a/extensions/slack/src/types.ts +++ b/extensions/slack/src/types.ts @@ -11,6 +11,7 @@ export type SlackFile = { export type SlackAttachment = { fallback?: string; + title?: string; text?: string; pretext?: string; author_name?: string; @@ -26,6 +27,8 @@ export type SlackAttachment = { image_height?: number; thumb_url?: string; files?: SlackFile[]; + fields?: Array<{ title?: string; value?: string }>; + blocks?: unknown[]; message_blocks?: unknown[]; };