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 <steipete@gmail.com>
This commit is contained in:
notask-007 2026-07-05 01:49:43 +07:00 committed by GitHub
parent 939f9aeff5
commit 94ff3c6b85
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 366 additions and 209 deletions

View file

@ -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 ? `<!subteam^${usergroupId}>` : "";
}
case "broadcast": {
const range = readString(element.range);
return range ? `<!${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;
}

View file

@ -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<typeof resolveSlackThreadHistory>[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<typeof resolveSlackThreadStarter>[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: [

View file

@ -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 | undefined>): 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 ? `<!subteam^${usergroupId}>` : "";
}
case "broadcast": {
const range = readString(element.range);
return range ? `<!${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;

View file

@ -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,

View file

@ -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[];
};