From ce973332f664df984ea6fd2e80d5dbcb7f463aff Mon Sep 17 00:00:00 2001 From: Colin Date: Mon, 16 Feb 2026 14:02:27 -0500 Subject: [PATCH] Slack: add media block fallback text handling --- src/slack/actions.blocks.test.ts | 58 ++++++++++++++++++- src/slack/actions.ts | 4 +- src/slack/blocks-fallback.test.ts | 31 ++++++++++ src/slack/blocks-fallback.ts | 95 +++++++++++++++++++++++++++++++ src/slack/send.blocks.test.ts | 55 +++++++++++++++++- src/slack/send.ts | 4 +- 6 files changed, 243 insertions(+), 4 deletions(-) create mode 100644 src/slack/blocks-fallback.test.ts create mode 100644 src/slack/blocks-fallback.ts diff --git a/src/slack/actions.blocks.test.ts b/src/slack/actions.blocks.test.ts index 3746b5c41..8337bea25 100644 --- a/src/slack/actions.blocks.test.ts +++ b/src/slack/actions.blocks.test.ts @@ -42,12 +42,68 @@ describe("editSlackMessage blocks", () => { expect.objectContaining({ channel: "C123", ts: "171234.567", - text: " ", + text: "Shared a Block Kit message", blocks: [{ type: "divider" }], }), ); }); + it("uses image block text as edit fallback", async () => { + const client = createClient(); + + await editSlackMessage("C123", "171234.567", "", { + token: "xoxb-test", + client, + blocks: [{ type: "image", image_url: "https://example.com/a.png", alt_text: "Chart" }], + }); + + expect(client.chat.update).toHaveBeenCalledWith( + expect.objectContaining({ + text: "Chart", + }), + ); + }); + + it("uses video block title as edit fallback", async () => { + const client = createClient(); + + await editSlackMessage("C123", "171234.567", "", { + token: "xoxb-test", + client, + blocks: [ + { + type: "video", + title: { type: "plain_text", text: "Walkthrough" }, + video_url: "https://example.com/demo.mp4", + thumbnail_url: "https://example.com/thumb.jpg", + alt_text: "demo", + }, + ], + }); + + expect(client.chat.update).toHaveBeenCalledWith( + expect.objectContaining({ + text: "Walkthrough", + }), + ); + }); + + it("uses generic file fallback text for file blocks", async () => { + const client = createClient(); + + await editSlackMessage("C123", "171234.567", "", { + token: "xoxb-test", + client, + blocks: [{ type: "file", source: "remote", external_id: "F123" }], + }); + + expect(client.chat.update).toHaveBeenCalledWith( + expect.objectContaining({ + text: "Shared a file", + }), + ); + }); + it("rejects empty blocks arrays", async () => { const client = createClient(); diff --git a/src/slack/actions.ts b/src/slack/actions.ts index 080467da0..d72fe51a4 100644 --- a/src/slack/actions.ts +++ b/src/slack/actions.ts @@ -2,6 +2,7 @@ import type { Block, KnownBlock, WebClient } from "@slack/web-api"; import { loadConfig } from "../config/config.js"; import { logVerbose } from "../globals.js"; import { resolveSlackAccount } from "./accounts.js"; +import { buildSlackBlocksFallbackText } from "./blocks-fallback.js"; import { validateSlackBlocksArray } from "./blocks-input.js"; import { createSlackWebClient } from "./client.js"; import { sendMessageSlack } from "./send.js"; @@ -172,10 +173,11 @@ export async function editSlackMessage( ) { const client = await getClient(opts); const blocks = opts.blocks == null ? undefined : validateSlackBlocksArray(opts.blocks); + const trimmedContent = content.trim(); await client.chat.update({ channel: channelId, ts: messageId, - text: content || " ", + text: trimmedContent || (blocks ? buildSlackBlocksFallbackText(blocks) : " "), ...(blocks ? { blocks } : {}), }); } diff --git a/src/slack/blocks-fallback.test.ts b/src/slack/blocks-fallback.test.ts new file mode 100644 index 000000000..538ba8142 --- /dev/null +++ b/src/slack/blocks-fallback.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { buildSlackBlocksFallbackText } from "./blocks-fallback.js"; + +describe("buildSlackBlocksFallbackText", () => { + it("prefers header text", () => { + expect( + buildSlackBlocksFallbackText([ + { type: "header", text: { type: "plain_text", text: "Deploy status" } }, + ] as never), + ).toBe("Deploy status"); + }); + + it("uses image alt text", () => { + expect( + buildSlackBlocksFallbackText([ + { type: "image", image_url: "https://example.com/image.png", alt_text: "Latency chart" }, + ] as never), + ).toBe("Latency chart"); + }); + + it("uses generic defaults for file and unknown blocks", () => { + expect( + buildSlackBlocksFallbackText([ + { type: "file", source: "remote", external_id: "F123" }, + ] as never), + ).toBe("Shared a file"); + expect(buildSlackBlocksFallbackText([{ type: "divider" }] as never)).toBe( + "Shared a Block Kit message", + ); + }); +}); diff --git a/src/slack/blocks-fallback.ts b/src/slack/blocks-fallback.ts new file mode 100644 index 000000000..28151cae3 --- /dev/null +++ b/src/slack/blocks-fallback.ts @@ -0,0 +1,95 @@ +import type { Block, KnownBlock } from "@slack/web-api"; + +type PlainTextObject = { text?: string }; + +type SlackBlockWithFields = { + type?: string; + text?: PlainTextObject & { type?: string }; + title?: PlainTextObject; + alt_text?: string; + elements?: Array<{ text?: string; type?: string }>; +}; + +function cleanCandidate(value: string | undefined): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const normalized = value.replace(/\s+/g, " ").trim(); + return normalized.length > 0 ? normalized : undefined; +} + +function readSectionText(block: SlackBlockWithFields): string | undefined { + return cleanCandidate(block.text?.text); +} + +function readHeaderText(block: SlackBlockWithFields): string | undefined { + return cleanCandidate(block.text?.text); +} + +function readImageText(block: SlackBlockWithFields): string | undefined { + return cleanCandidate(block.alt_text) ?? cleanCandidate(block.title?.text); +} + +function readVideoText(block: SlackBlockWithFields): string | undefined { + return cleanCandidate(block.title?.text) ?? cleanCandidate(block.alt_text); +} + +function readContextText(block: SlackBlockWithFields): string | undefined { + if (!Array.isArray(block.elements)) { + return undefined; + } + const textParts = block.elements + .map((element) => cleanCandidate(element.text)) + .filter((value): value is string => Boolean(value)); + return textParts.length > 0 ? textParts.join(" ") : undefined; +} + +export function buildSlackBlocksFallbackText(blocks: (Block | KnownBlock)[]): string { + for (const raw of blocks) { + const block = raw as SlackBlockWithFields; + switch (block.type) { + case "header": { + const text = readHeaderText(block); + if (text) { + return text; + } + break; + } + case "section": { + const text = readSectionText(block); + if (text) { + return text; + } + break; + } + case "image": { + const text = readImageText(block); + if (text) { + return text; + } + return "Shared an image"; + } + case "video": { + const text = readVideoText(block); + if (text) { + return text; + } + return "Shared a video"; + } + case "file": { + return "Shared a file"; + } + case "context": { + const text = readContextText(block); + if (text) { + return text; + } + break; + } + default: + break; + } + } + + return "Shared a Block Kit message"; +} diff --git a/src/slack/send.blocks.test.ts b/src/slack/send.blocks.test.ts index c84c9bf4a..54130725b 100644 --- a/src/slack/send.blocks.test.ts +++ b/src/slack/send.blocks.test.ts @@ -43,13 +43,66 @@ describe("sendMessageSlack blocks", () => { expect(client.chat.postMessage).toHaveBeenCalledWith( expect.objectContaining({ channel: "C123", - text: " ", + text: "Shared a Block Kit message", blocks: [{ type: "divider" }], }), ); expect(result).toEqual({ messageId: "171234.567", channelId: "C123" }); }); + it("derives fallback text from image blocks", async () => { + const client = createClient(); + await sendMessageSlack("channel:C123", "", { + token: "xoxb-test", + client, + blocks: [{ type: "image", image_url: "https://example.com/a.png", alt_text: "Build chart" }], + }); + + expect(client.chat.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + text: "Build chart", + }), + ); + }); + + it("derives fallback text from video blocks", async () => { + const client = createClient(); + await sendMessageSlack("channel:C123", "", { + token: "xoxb-test", + client, + blocks: [ + { + type: "video", + title: { type: "plain_text", text: "Release demo" }, + video_url: "https://example.com/demo.mp4", + thumbnail_url: "https://example.com/thumb.jpg", + alt_text: "demo", + }, + ], + }); + + expect(client.chat.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + text: "Release demo", + }), + ); + }); + + it("derives fallback text from file blocks", async () => { + const client = createClient(); + await sendMessageSlack("channel:C123", "", { + token: "xoxb-test", + client, + blocks: [{ type: "file", source: "remote", external_id: "F123" }], + }); + + expect(client.chat.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + text: "Shared a file", + }), + ); + }); + it("rejects blocks combined with mediaUrl", async () => { const client = createClient(); await expect( diff --git a/src/slack/send.ts b/src/slack/send.ts index eafd3d47b..83c56202d 100644 --- a/src/slack/send.ts +++ b/src/slack/send.ts @@ -15,6 +15,7 @@ import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { logVerbose } from "../globals.js"; import { loadWebMedia } from "../web/media.js"; import { resolveSlackAccount } from "./accounts.js"; +import { buildSlackBlocksFallbackText } from "./blocks-fallback.js"; import { validateSlackBlocksArray } from "./blocks-input.js"; import { createSlackWebClient } from "./client.js"; import { markdownToSlackMrkdwnChunks } from "./format.js"; @@ -245,10 +246,11 @@ export async function sendMessageSlack( if (opts.mediaUrl) { throw new Error("Slack send does not support blocks with mediaUrl"); } + const fallbackText = trimmedMessage || buildSlackBlocksFallbackText(blocks); const response = await postSlackMessageBestEffort({ client, channelId, - text: trimmedMessage || " ", + text: fallbackText, threadTs: opts.threadTs, identity: opts.identity, blocks,