fix(slack): recover long dm text from blocks

This commit is contained in:
Peter Steinberger
2026-05-02 02:42:54 +01:00
parent 04f1fd4d1f
commit 7987fac21a
4 changed files with 206 additions and 5 deletions

View File

@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Slack: recover full inbound DM text from top-level rich-text blocks when Slack sends a shortened message preview, so long direct messages still reach the agent intact. Fixes #55358. Thanks @tonyjwinter.
- Replies: strip legacy `[TOOL_CALL]{tool => ..., args => ...}[/TOOL_CALL]` pseudo-call text from user-facing replies and flag it in tool-call diagnostics instead of showing raw tool syntax in channels. Fixes #63610. Thanks @canh0chua.
- WhatsApp: close long-lived web sockets through Baileys `end(error)` before falling back to raw websocket close, so listener teardown runs Baileys cleanup instead of leaving zombie sockets. Fixes #52442. Thanks @essendigitalgroup-cyber.
- Twitch/plugins: emit a flat JSON Schema for Twitch channel config so single-account and multi-account configs validate before runtime load, and add source-checkout diagnostics for missing pnpm workspace dependencies. Thanks @vincentkoc.

View File

@@ -14,6 +14,31 @@ type SlackResolvedMessageContent = {
const SLACK_MENTION_RESOLUTION_CONCURRENCY = 4;
const SLACK_MENTION_RESOLUTION_MAX_LOOKUPS_PER_MESSAGE = 20;
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 SlackMediaModule = typeof import("../media.js");
let slackMediaModulePromise: Promise<SlackMediaModule> | undefined;
@@ -54,6 +79,152 @@ function renderSlackUserMentions(
});
}
function readString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
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 listText = Array.isArray(element.elements)
? element.elements
.map((child) =>
child && typeof child === "object"
? renderSlackRichTextElements((child as SlackRichTextElement).elements)
: "",
)
.filter(Boolean)
.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 = blockLike.fields.map(readTextObject).filter(Boolean);
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 = blockLike.elements.map(readTextObject).filter(Boolean);
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): string | undefined {
if (!blocks?.length) {
return undefined;
}
const parts = blocks.map(readSlackBlockText).filter(Boolean);
return parts.length > 0 ? parts.join("\n") : undefined;
}
function chooseSlackPrimaryText(params: {
messageText: string | undefined;
blocksText: string | undefined;
}): string | undefined {
const { messageText, blocksText } = params;
if (!blocksText) {
return messageText;
}
if (!messageText) {
return blocksText;
}
return blocksText.length > messageText.length && blocksText.startsWith(messageText)
? blocksText
: messageText;
}
function filterInheritedParentFiles(params: {
files: SlackFile[] | undefined;
isThreadReply: boolean;
@@ -143,11 +314,12 @@ export async function resolveSlackMessageContent(params: {
.join("\n")
: undefined;
const textParts = [
normalizeOptionalString(params.message.text),
attachmentContent?.text,
botAttachmentText,
];
const blocksText = resolveSlackBlocksText(params.message.blocks);
const primaryText = chooseSlackPrimaryText({
messageText: normalizeOptionalString(params.message.text),
blocksText,
});
const textParts = [primaryText, attachmentContent?.text, botAttachmentText];
const renderedMentions = new Map<string, string | null>();
const resolveUserName = params.resolveUserName;
if (resolveUserName) {

View File

@@ -409,6 +409,33 @@ describe("slack prepareSlackMessage inbound contract", () => {
expect(prepared!.ctxPayload.RawBody).toContain("[Forwarded message from Bob]\nForwarded hello");
});
it("recovers full Slack DM text from top-level rich text blocks when text is only a preview", async () => {
const preview = "Yo Molty what is uppppp ".repeat(7).slice(0, 160);
const fullText = `${preview}and this tail should still reach the agent`;
const prepared = await prepareWithDefaultCtx(
createSlackMessage({
text: preview,
blocks: [
{
type: "rich_text",
block_id: "b1",
elements: [
{
type: "rich_text_section",
elements: [{ type: "text", text: fullText }],
},
],
},
],
}),
);
expect(prepared).toBeTruthy();
expect(prepared!.ctxPayload.RawBody).toBe(fullText);
expect(prepared!.ctxPayload.BodyForAgent).toContain(fullText);
});
it("ignores non-forward attachments when no direct text/files are present", async () => {
const prepared = await prepareWithDefaultCtx(
createSlackMessage({

View File

@@ -41,6 +41,7 @@ export type SlackMessageEvent = {
parent_user_id?: string;
channel: string;
channel_type?: "im" | "mpim" | "channel" | "group";
blocks?: unknown[];
files?: SlackFile[];
attachments?: SlackAttachment[];
};