From a1dc9274786645264750696dd02940b1143624a1 Mon Sep 17 00:00:00 2001 From: dancer Date: Tue, 26 May 2026 17:58:55 +0100 Subject: [PATCH 1/3] feat(slack): expose block kit helpers --- .changeset/eight-hounds-serve.md | 5 + packages/adapter-slack/package.json | 4 + packages/adapter-slack/src/blocks/errors.ts | 6 + packages/adapter-slack/src/blocks/index.ts | 494 ++++++++++++++++++++ packages/adapter-slack/src/blocks/limits.ts | 24 + packages/adapter-slack/src/blocks/types.ts | 133 ++++++ packages/adapter-slack/tsup.config.ts | 1 + 7 files changed, 667 insertions(+) create mode 100644 .changeset/eight-hounds-serve.md create mode 100644 packages/adapter-slack/src/blocks/errors.ts create mode 100644 packages/adapter-slack/src/blocks/index.ts create mode 100644 packages/adapter-slack/src/blocks/limits.ts create mode 100644 packages/adapter-slack/src/blocks/types.ts diff --git a/.changeset/eight-hounds-serve.md b/.changeset/eight-hounds-serve.md new file mode 100644 index 00000000..ce8338bb --- /dev/null +++ b/.changeset/eight-hounds-serve.md @@ -0,0 +1,5 @@ +--- +"@chat-adapter/slack": minor +--- + +expose runtime-free Block Kit helpers for Slack card conversion diff --git a/packages/adapter-slack/package.json b/packages/adapter-slack/package.json index fb1c31d2..9e682ce2 100644 --- a/packages/adapter-slack/package.json +++ b/packages/adapter-slack/package.json @@ -25,6 +25,10 @@ "./api": { "types": "./dist/api.d.ts", "import": "./dist/api.js" + }, + "./blocks": { + "types": "./dist/blocks.d.ts", + "import": "./dist/blocks.js" } }, "files": [ diff --git a/packages/adapter-slack/src/blocks/errors.ts b/packages/adapter-slack/src/blocks/errors.ts new file mode 100644 index 00000000..2ba4c07b --- /dev/null +++ b/packages/adapter-slack/src/blocks/errors.ts @@ -0,0 +1,6 @@ +export class SlackBlockError extends Error { + constructor(message: string) { + super(message); + this.name = "SlackBlockError"; + } +} diff --git a/packages/adapter-slack/src/blocks/index.ts b/packages/adapter-slack/src/blocks/index.ts new file mode 100644 index 00000000..5b312803 --- /dev/null +++ b/packages/adapter-slack/src/blocks/index.ts @@ -0,0 +1,494 @@ +import { SlackBlockError } from "./errors"; +import { LIMITS } from "./limits"; +import type { + SlackActionsElement, + SlackBlock, + SlackBlocksOptions, + SlackButtonElement, + SlackButtonStyle, + SlackCardChild, + SlackCardElement, + SlackFieldsElement, + SlackImageElement, + SlackLinkButtonElement, + SlackLinkElement, + SlackRadioSelectElement, + SlackSelectElement, + SlackSelectOptionElement, + SlackTableElement, + SlackTextElement, + SlackTextObject, +} from "./types"; + +export { SlackBlockError } from "./errors"; +export type { + SlackActionsElement, + SlackBlock, + SlackBlocksOptions, + SlackButtonElement, + SlackButtonStyle, + SlackCardChild, + SlackCardElement, + SlackDividerElement, + SlackFieldElement, + SlackFieldsElement, + SlackImageElement, + SlackLinkButtonElement, + SlackLinkElement, + SlackRadioSelectElement, + SlackSectionElement, + SlackSelectElement, + SlackSelectOptionElement, + SlackTableAlignment, + SlackTableElement, + SlackTextElement, + SlackTextObject, + SlackTextStyle, +} from "./types"; + +const EMPTY_TEXT = " "; +const EMOJI_PATTERN = /\{\{emoji:([a-zA-Z0-9_+-]+)\}\}/g; + +export function cardToSlackBlocks( + card: SlackCardElement, + options: SlackBlocksOptions = {} +): SlackBlock[] { + const blocks: SlackBlock[] = []; + const state = { + convertEmoji: options.convertEmoji ?? convertSlackEmojiPlaceholders, + maxBlocks: options.maxBlocks ?? LIMITS.blocks, + usedTable: false, + }; + + if (card.title) { + blocks.push({ + text: plainText(card.title, state.convertEmoji, LIMITS.headerText), + type: "header", + }); + } + if (card.subtitle) { + blocks.push({ + elements: [mrkdwn(card.subtitle, state.convertEmoji, LIMITS.textObject)], + type: "context", + }); + } + if (card.imageUrl) { + blocks.push({ + alt_text: truncateText( + state.convertEmoji(card.title || "Card image"), + LIMITS.imageAlt + ), + image_url: truncateText(card.imageUrl, LIMITS.imageUrl), + type: "image", + }); + } + for (const child of card.children) { + blocks.push(...cardChildToSlackBlocks(child, state)); + } + return blocks.slice(0, state.maxBlocks); +} + +export const cardToBlockKit = cardToSlackBlocks; + +export function cardToSlackFallbackText( + card: SlackCardElement, + options: Pick = {} +): string { + const convertEmoji = options.convertEmoji ?? convertSlackEmojiPlaceholders; + const lines: string[] = []; + if (card.title) { + lines.push(`*${convertEmoji(card.title)}*`); + } + if (card.subtitle) { + lines.push(convertEmoji(card.subtitle)); + } + for (const child of card.children) { + const text = cardChildToFallbackText(child, convertEmoji); + if (text) { + lines.push(text); + } + } + return lines.join("\n"); +} + +export const cardToFallbackText = cardToSlackFallbackText; + +export function convertSlackEmojiPlaceholders(text: string): string { + return text.replace(EMOJI_PATTERN, ":$1:"); +} + +function cardChildToSlackBlocks( + child: SlackCardChild, + state: { + convertEmoji: (text: string) => string; + maxBlocks: number; + usedTable: boolean; + } +): SlackBlock[] { + switch (child.type) { + case "actions": + return [actionsToBlock(child, state.convertEmoji)]; + case "divider": + return [{ type: "divider" }]; + case "fields": + return [fieldsToBlock(child, state.convertEmoji)]; + case "image": + return [imageToBlock(child, state.convertEmoji)]; + case "link": + return [linkToBlock(child, state.convertEmoji)]; + case "section": + return child.children.flatMap((nested) => + cardChildToSlackBlocks(nested, state) + ); + case "table": + return tableToBlocks(child, state); + case "text": + return [textToBlock(child, state.convertEmoji)]; + default: + return assertNever(child); + } +} + +function textToBlock( + element: SlackTextElement, + convertEmoji: (text: string) => string +): SlackBlock { + const text = markdownBoldToSlackMrkdwn(convertEmoji(element.content)); + if (element.style === "muted") { + return { + elements: [mrkdwn(text, (value) => value, LIMITS.textObject)], + type: "context", + }; + } + return { + text: mrkdwn( + element.style === "bold" ? `*${text}*` : text, + (value) => value, + LIMITS.sectionText + ), + type: "section", + }; +} + +function imageToBlock( + element: SlackImageElement, + convertEmoji: (text: string) => string +): SlackBlock { + return { + alt_text: truncateText( + convertEmoji(element.alt || "Image"), + LIMITS.imageAlt + ), + image_url: truncateText(element.url, LIMITS.imageUrl), + type: "image", + }; +} + +function linkToBlock( + element: SlackLinkElement, + convertEmoji: (text: string) => string +): SlackBlock { + return { + text: mrkdwn( + `<${element.url}|${convertEmoji(element.label)}>`, + (value) => value, + LIMITS.sectionText + ), + type: "section", + }; +} + +function actionsToBlock( + element: SlackActionsElement, + convertEmoji: (text: string) => string +): SlackBlock { + return { + elements: element.children + .slice(0, LIMITS.actionsElements) + .map((child) => actionToElement(child, convertEmoji)), + type: "actions", + }; +} + +function actionToElement( + child: + | SlackButtonElement + | SlackLinkButtonElement + | SlackRadioSelectElement + | SlackSelectElement, + convertEmoji: (text: string) => string +): Record { + switch (child.type) { + case "button": + return buttonToElement(child, convertEmoji); + case "link-button": + return linkButtonToElement(child, convertEmoji); + case "radio_select": + return radioSelectToElement(child, convertEmoji); + case "select": + return selectToElement(child, convertEmoji); + default: + return assertNever(child); + } +} + +function buttonToElement( + button: SlackButtonElement, + convertEmoji: (text: string) => string +): Record { + return compact({ + action_id: truncateText(button.id, LIMITS.actionId), + style: mapButtonStyle(button.style), + text: plainText(button.label, convertEmoji, LIMITS.buttonText), + type: "button", + value: + button.value === undefined + ? undefined + : truncateText(button.value, LIMITS.buttonValue), + }); +} + +function linkButtonToElement( + button: SlackLinkButtonElement, + convertEmoji: (text: string) => string +): Record { + return compact({ + action_id: truncateText(`link-${button.url}`, LIMITS.actionId), + style: mapButtonStyle(button.style), + text: plainText(button.label, convertEmoji, LIMITS.buttonText), + type: "button", + url: truncateText(button.url, LIMITS.buttonUrl), + }); +} + +function selectToElement( + select: SlackSelectElement, + convertEmoji: (text: string) => string +): Record { + const options = select.options + .slice(0, LIMITS.options) + .map((option) => optionObject(option, convertEmoji, "plain_text")); + return compact({ + action_id: truncateText(select.id, LIMITS.actionId), + initial_option: options.find( + (option) => option.value === select.initialOption + ), + options, + placeholder: select.placeholder + ? plainText(select.placeholder, convertEmoji, LIMITS.placeholder) + : undefined, + type: "static_select", + }); +} + +function radioSelectToElement( + select: SlackRadioSelectElement, + convertEmoji: (text: string) => string +): Record { + const options = select.options + .slice(0, LIMITS.radioOptions) + .map((option) => optionObject(option, convertEmoji, "mrkdwn")); + return compact({ + action_id: truncateText(select.id, LIMITS.actionId), + initial_option: options.find( + (option) => option.value === select.initialOption + ), + options, + type: "radio_buttons", + }); +} + +function optionObject( + option: SlackSelectOptionElement, + convertEmoji: (text: string) => string, + textType: "mrkdwn" | "plain_text" +): Record { + return compact({ + description: option.description + ? { + text: truncateText( + convertEmoji(option.description), + LIMITS.optionDescription + ), + type: textType, + } + : undefined, + text: { + text: truncateText(convertEmoji(option.label), LIMITS.optionText), + type: textType, + }, + value: truncateText(option.value, LIMITS.optionValue), + }); +} + +function fieldsToBlock( + element: SlackFieldsElement, + convertEmoji: (text: string) => string +): SlackBlock { + return { + fields: element.children + .slice(0, LIMITS.fields) + .map((field) => + mrkdwn( + `*${markdownBoldToSlackMrkdwn(convertEmoji(field.label))}*\n${markdownBoldToSlackMrkdwn(convertEmoji(field.value))}`, + (value) => value, + LIMITS.fieldText + ) + ), + type: "section", + }; +} + +function tableToBlocks( + element: SlackTableElement, + state: { + convertEmoji: (text: string) => string; + usedTable: boolean; + } +): SlackBlock[] { + if ( + state.usedTable || + element.rows.length + 1 > LIMITS.tableRows || + element.headers.length > LIMITS.tableColumns + ) { + return [ + { + text: mrkdwn( + `\`\`\`\n${tableToAscii(element)}\n\`\`\``, + (value) => value, + LIMITS.sectionText + ), + type: "section", + }, + ]; + } + state.usedTable = true; + return [ + compact({ + column_settings: element.align + ?.slice(0, LIMITS.tableColumns) + .map((align) => (align ? { align } : null)), + rows: [ + element.headers.map((header) => rawText(header, state.convertEmoji)), + ...element.rows.map((row) => + row.map((cell) => rawText(cell, state.convertEmoji)) + ), + ], + type: "table", + }), + ]; +} + +function cardChildToFallbackText( + child: SlackCardChild, + convertEmoji: (text: string) => string +): string | undefined { + switch (child.type) { + case "actions": + return undefined; + case "divider": + return "---"; + case "fields": + return child.children + .map( + (field) => + `${convertEmoji(field.label)}: ${convertEmoji(field.value)}` + ) + .join("\n"); + case "image": + return child.alt ? convertEmoji(child.alt) : undefined; + case "link": + return `${convertEmoji(child.label)} (${child.url})`; + case "section": + return child.children + .map((nested) => cardChildToFallbackText(nested, convertEmoji)) + .filter((value): value is string => Boolean(value)) + .join("\n"); + case "table": + return tableToAscii(child); + case "text": + return convertEmoji(child.content); + default: + return assertNever(child); + } +} + +function mrkdwn( + text: string, + convertEmoji: (text: string) => string, + maxLength: number +): SlackTextObject { + return { + text: nonemptyText(truncateText(convertEmoji(text), maxLength)), + type: "mrkdwn", + }; +} + +function plainText( + text: string, + convertEmoji: (text: string) => string, + maxLength: number +): SlackTextObject { + return { + emoji: true, + text: nonemptyText(truncateText(convertEmoji(text), maxLength)), + type: "plain_text", + }; +} + +function rawText( + text: string, + convertEmoji: (text: string) => string +): Record { + return { + text: nonemptyText(convertEmoji(text)), + type: "raw_text", + }; +} + +function markdownBoldToSlackMrkdwn(text: string): string { + return text.replace(/\*\*(.+?)\*\*/g, "*$1*"); +} + +function mapButtonStyle( + style: SlackButtonStyle | undefined +): "danger" | "primary" | undefined { + return style === "danger" || style === "primary" ? style : undefined; +} + +function truncateText(text: string, maxLength: number): string { + return text.length > maxLength ? text.slice(0, maxLength) : text; +} + +function nonemptyText(text: string): string { + return text.length > 0 ? text : EMPTY_TEXT; +} + +function assertNever(value: never): never { + throw new SlackBlockError(`Unsupported Slack card element: ${String(value)}`); +} + +function compact>(value: T): T { + const output: Record = {}; + for (const [key, item] of Object.entries(value)) { + if (item !== undefined) { + output[key] = item; + } + } + return output as T; +} + +function tableToAscii(table: SlackTableElement): string { + const rows = [table.headers, ...table.rows]; + const widths = table.headers.map((_, column) => + Math.max(...rows.map((row) => row[column]?.length ?? 0)) + ); + return rows + .map((row) => + row + .map((cell, column) => (cell ?? "").padEnd(widths[column] ?? 0)) + .join(" | ") + .trimEnd() + ) + .join("\n"); +} diff --git a/packages/adapter-slack/src/blocks/limits.ts b/packages/adapter-slack/src/blocks/limits.ts new file mode 100644 index 00000000..2ad710f5 --- /dev/null +++ b/packages/adapter-slack/src/blocks/limits.ts @@ -0,0 +1,24 @@ +export const LIMITS = { + actionId: 255, + actionsElements: 25, + blockId: 255, + blocks: 50, + buttonText: 75, + buttonUrl: 3000, + buttonValue: 2000, + fields: 10, + fieldText: 2000, + headerText: 150, + imageAlt: 2000, + imageUrl: 3000, + optionDescription: 75, + optionText: 75, + optionValue: 150, + options: 100, + placeholder: 150, + radioOptions: 10, + sectionText: 3000, + tableColumns: 20, + tableRows: 100, + textObject: 3000, +} as const; diff --git a/packages/adapter-slack/src/blocks/types.ts b/packages/adapter-slack/src/blocks/types.ts new file mode 100644 index 00000000..5ea66c1c --- /dev/null +++ b/packages/adapter-slack/src/blocks/types.ts @@ -0,0 +1,133 @@ +export type SlackButtonStyle = "danger" | "default" | "primary"; +export type SlackTextStyle = "bold" | "muted" | "plain"; +export type SlackTableAlignment = "center" | "left" | "right"; + +export interface SlackCardElement { + children: SlackCardChild[]; + imageUrl?: string; + subtitle?: string; + title?: string; + type: "card"; +} + +export type SlackCardChild = + | SlackActionsElement + | SlackDividerElement + | SlackFieldsElement + | SlackImageElement + | SlackLinkElement + | SlackSectionElement + | SlackTableElement + | SlackTextElement; + +export interface SlackTextElement { + content: string; + style?: SlackTextStyle; + type: "text"; +} + +export interface SlackImageElement { + alt?: string; + type: "image"; + url: string; +} + +export interface SlackDividerElement { + type: "divider"; +} + +export interface SlackActionsElement { + children: ( + | SlackButtonElement + | SlackLinkButtonElement + | SlackRadioSelectElement + | SlackSelectElement + )[]; + type: "actions"; +} + +export interface SlackButtonElement { + callbackUrl?: string; + disabled?: boolean; + id: string; + label: string; + style?: SlackButtonStyle; + type: "button"; + value?: string; +} + +export interface SlackLinkButtonElement { + label: string; + style?: SlackButtonStyle; + type: "link-button"; + url: string; +} + +export interface SlackSelectOptionElement { + description?: string; + label: string; + value: string; +} + +export interface SlackSelectElement { + id: string; + initialOption?: string; + label: string; + options: SlackSelectOptionElement[]; + placeholder?: string; + type: "select"; +} + +export interface SlackRadioSelectElement { + id: string; + initialOption?: string; + label: string; + options: SlackSelectOptionElement[]; + type: "radio_select"; +} + +export interface SlackSectionElement { + children: SlackCardChild[]; + type: "section"; +} + +export interface SlackLinkElement { + label: string; + type: "link"; + url: string; +} + +export interface SlackFieldElement { + label: string; + type: "field"; + value: string; +} + +export interface SlackFieldsElement { + children: SlackFieldElement[]; + type: "fields"; +} + +export interface SlackTableElement { + align?: SlackTableAlignment[]; + headers: string[]; + rows: string[][]; + type: "table"; +} + +export interface SlackTextObject { + emoji?: boolean; + text: string; + type: "mrkdwn" | "plain_text"; +} + +export interface SlackBlock { + block_id?: string; + type: string; + [key: string]: unknown; +} + +export interface SlackBlocksOptions { + convertEmoji?: (text: string) => string; + maxBlocks?: number; +} diff --git a/packages/adapter-slack/tsup.config.ts b/packages/adapter-slack/tsup.config.ts index df8e1847..6410401d 100644 --- a/packages/adapter-slack/tsup.config.ts +++ b/packages/adapter-slack/tsup.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from "tsup"; export default defineConfig({ entry: { api: "src/api/index.ts", + blocks: "src/blocks/index.ts", format: "src/format/index.ts", index: "src/index.ts", webhook: "src/webhook/index.ts", From 6d24139f706c06617ea3dcdf9586774da4558f21 Mon Sep 17 00:00:00 2001 From: dancer Date: Tue, 26 May 2026 17:59:14 +0100 Subject: [PATCH 2/3] test(slack): cover block kit helpers --- .../adapter-slack/src/blocks/boundary.test.ts | 22 ++ .../adapter-slack/src/blocks/index.test.ts | 343 ++++++++++++++++++ 2 files changed, 365 insertions(+) create mode 100644 packages/adapter-slack/src/blocks/boundary.test.ts create mode 100644 packages/adapter-slack/src/blocks/index.test.ts diff --git a/packages/adapter-slack/src/blocks/boundary.test.ts b/packages/adapter-slack/src/blocks/boundary.test.ts new file mode 100644 index 00000000..90cfdbee --- /dev/null +++ b/packages/adapter-slack/src/blocks/boundary.test.ts @@ -0,0 +1,22 @@ +import { readdir, readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; + +describe("blocks import boundary", () => { + it("does not import the full adapter or runtime packages", async () => { + const directory = new URL(".", import.meta.url); + const files = await readdir(directory); + const sources = await Promise.all( + files + .filter((file) => file.endsWith(".ts") && !file.endsWith(".test.ts")) + .map((file) => readFile(new URL(file, directory), "utf8")) + ); + const source = sources.join("\n"); + + expect(source).not.toContain('from "chat"'); + expect(source).not.toContain("from '@chat-adapter/shared'"); + expect(source).not.toContain('from "@chat-adapter/shared"'); + expect(source).not.toContain('from "@slack/web-api"'); + expect(source).not.toContain('from "@slack/socket-mode"'); + expect(source).not.toContain('from "../index"'); + }); +}); diff --git a/packages/adapter-slack/src/blocks/index.test.ts b/packages/adapter-slack/src/blocks/index.test.ts new file mode 100644 index 00000000..128000b3 --- /dev/null +++ b/packages/adapter-slack/src/blocks/index.test.ts @@ -0,0 +1,343 @@ +import { describe, expect, it } from "vitest"; +import { + cardToBlockKit, + cardToFallbackText, + cardToSlackBlocks, + cardToSlackFallbackText, + convertSlackEmojiPlaceholders, + type SlackCardElement, +} from "./index"; + +function card(children: SlackCardElement["children"] = []): SlackCardElement { + return { + children, + type: "card", + }; +} + +describe("Slack Block Kit primitives", () => { + it("converts card headers and context", () => { + expect( + cardToSlackBlocks({ + children: [], + imageUrl: "https://example.com/image.png", + subtitle: "Status changed", + title: "Order", + type: "card", + }) + ).toEqual([ + { + text: { emoji: true, text: "Order", type: "plain_text" }, + type: "header", + }, + { + elements: [{ text: "Status changed", type: "mrkdwn" }], + type: "context", + }, + { + alt_text: "Order", + image_url: "https://example.com/image.png", + type: "image", + }, + ]); + }); + + it("truncates header text to Slack's header block limit", () => { + const title = "a".repeat(200); + + expect(cardToSlackBlocks({ children: [], title, type: "card" })[0]).toEqual( + { + text: { emoji: true, text: "a".repeat(150), type: "plain_text" }, + type: "header", + } + ); + }); + + it("truncates image URLs to Slack's image block limit", () => { + const longUrl = `https://example.com/${"a".repeat(4000)}`; + const topBlocks = cardToSlackBlocks({ + children: [], + imageUrl: longUrl, + title: "{{emoji:frame}}", + type: "card", + }); + + expect(topBlocks[0]).toEqual({ + text: { emoji: true, text: ":frame:", type: "plain_text" }, + type: "header", + }); + expect(topBlocks[1]).toEqual({ + alt_text: ":frame:", + image_url: `https://example.com/${"a".repeat(2980)}`, + type: "image", + }); + expect( + cardToSlackBlocks({ + children: [{ type: "image", url: longUrl }], + type: "card", + })[0] + ).toEqual({ + alt_text: "Image", + image_url: `https://example.com/${"a".repeat(2980)}`, + type: "image", + }); + }); + + it("converts text and links", () => { + expect( + cardToSlackBlocks( + card([ + { content: "plain", type: "text" }, + { content: "bold", style: "bold", type: "text" }, + { content: "muted", style: "muted", type: "text" }, + { label: "Docs", type: "link", url: "https://example.com" }, + ]) + ) + ).toEqual([ + { text: { text: "plain", type: "mrkdwn" }, type: "section" }, + { text: { text: "*bold*", type: "mrkdwn" }, type: "section" }, + { elements: [{ text: "muted", type: "mrkdwn" }], type: "context" }, + { + text: { text: "", type: "mrkdwn" }, + type: "section", + }, + ]); + }); + + it("converts actions", () => { + const blocks = cardToSlackBlocks( + card([ + { + children: [ + { + id: "approve", + label: "Approve", + style: "primary", + type: "button", + }, + { + label: "Docs", + style: "default", + type: "link-button", + url: "https://example.com/docs", + }, + { + id: "status", + label: "Status", + options: [ + { label: "Open", value: "open" }, + { label: "Closed", value: "closed" }, + ], + placeholder: "Choose", + type: "select", + }, + { + id: "plan", + label: "Plan", + options: [ + { description: "For teams", label: "Pro", value: "pro" }, + ], + type: "radio_select", + }, + ], + type: "actions", + }, + ]) + ); + + expect(blocks[0]).toMatchObject({ + elements: [ + { + action_id: "approve", + style: "primary", + text: { emoji: true, text: "Approve", type: "plain_text" }, + type: "button", + }, + { + text: { emoji: true, text: "Docs", type: "plain_text" }, + type: "button", + url: "https://example.com/docs", + }, + { + action_id: "status", + options: [ + { text: { text: "Open", type: "plain_text" }, value: "open" }, + { text: { text: "Closed", type: "plain_text" }, value: "closed" }, + ], + placeholder: { emoji: true, text: "Choose", type: "plain_text" }, + type: "static_select", + }, + { + action_id: "plan", + options: [ + { + description: { text: "For teams", type: "mrkdwn" }, + text: { text: "Pro", type: "mrkdwn" }, + value: "pro", + }, + ], + type: "radio_buttons", + }, + ], + type: "actions", + }); + }); + + it("limits action elements and select options to Slack limits", () => { + const blocks = cardToSlackBlocks( + card([ + { + children: Array.from({ length: 30 }, (_, index) => ({ + id: `b${index}`, + label: `Button ${index}`, + type: "button" as const, + })), + type: "actions", + }, + { + children: [ + { + id: "select", + label: "Select", + options: Array.from({ length: 120 }, (_, index) => ({ + label: `Option ${index}`, + value: `value-${index}`, + })), + type: "select", + }, + ], + type: "actions", + }, + ]) + ); + + expect((blocks[0].elements as unknown[]).length).toBe(25); + expect( + (blocks[1].elements as Array<{ options: unknown[] }>)[0].options.length + ).toBe(100); + }); + + it("truncates option values to Slack's option object limit", () => { + const [block] = cardToSlackBlocks( + card([ + { + children: [ + { + id: "select", + label: "Select", + options: [{ label: "Option", value: "v".repeat(200) }], + type: "select", + }, + ], + type: "actions", + }, + ]) + ); + + expect( + (block.elements as Array<{ options: Array<{ value: string }> }>)[0] + .options[0].value + ).toBe("v".repeat(150)); + }); + + it("converts fields and tables", () => { + const blocks = cardToSlackBlocks( + card([ + { + children: [ + { label: "Name", type: "field", value: "Ada" }, + { label: "Role", type: "field", value: "Engineer" }, + ], + type: "fields", + }, + { + align: ["left", "right"], + headers: ["Name", "Score"], + rows: [["Ada", "10"]], + type: "table", + }, + ]) + ); + + expect(blocks[0]).toEqual({ + fields: [ + { text: "*Name*\nAda", type: "mrkdwn" }, + { text: "*Role*\nEngineer", type: "mrkdwn" }, + ], + type: "section", + }); + expect(blocks[1]).toEqual({ + column_settings: [{ align: "left" }, { align: "right" }], + rows: [ + [ + { text: "Name", type: "raw_text" }, + { text: "Score", type: "raw_text" }, + ], + [ + { text: "Ada", type: "raw_text" }, + { text: "10", type: "raw_text" }, + ], + ], + type: "table", + }); + }); + + it("falls back to ASCII tables after one native table", () => { + const table = { + headers: ["A", "B"], + rows: [["1", "2"]], + type: "table" as const, + }; + + expect(cardToSlackBlocks(card([table, table]))[1]).toEqual({ + text: { text: "```\nA | B\n1 | 2\n```", type: "mrkdwn" }, + type: "section", + }); + }); + + it("generates Slack fallback text", () => { + expect( + cardToSlackFallbackText({ + children: [ + { content: "Hello", type: "text" }, + { + children: [{ label: "Status", type: "field", value: "Ready" }], + type: "fields", + }, + { + children: [{ id: "ok", label: "OK", type: "button" }], + type: "actions", + }, + ], + subtitle: "Sub", + title: "Title", + type: "card", + }) + ).toBe("*Title*\nSub\nHello\nStatus: Ready"); + }); + + it("keeps compatibility aliases", () => { + const input = card([{ content: "hello", type: "text" }]); + + expect(cardToBlockKit(input)).toEqual(cardToSlackBlocks(input)); + expect(cardToFallbackText(input)).toBe(cardToSlackFallbackText(input)); + }); + + it("supports custom emoji conversion", () => { + const input = card([{ content: "{{emoji:thumbs_up}}", type: "text" }]); + + expect(cardToSlackBlocks(input)[0]).toEqual({ + text: { text: ":thumbs_up:", type: "mrkdwn" }, + type: "section", + }); + expect(cardToSlackBlocks(input, { convertEmoji: () => ":+1:" })[0]).toEqual( + { + text: { text: ":+1:", type: "mrkdwn" }, + type: "section", + } + ); + expect(convertSlackEmojiPlaceholders("hi {{emoji:wave}}")).toBe( + "hi :wave:" + ); + }); +}); From 6702690057eca2991a152260cc5dd0e44a7af4d8 Mon Sep 17 00:00:00 2001 From: dancer Date: Tue, 26 May 2026 19:58:32 +0100 Subject: [PATCH 3/3] fix(slack): match truncated initial options --- .../adapter-slack/src/blocks/index.test.ts | 64 +++++++++++++++++++ packages/adapter-slack/src/blocks/index.ts | 19 ++++-- 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/packages/adapter-slack/src/blocks/index.test.ts b/packages/adapter-slack/src/blocks/index.test.ts index 128000b3..350dc80c 100644 --- a/packages/adapter-slack/src/blocks/index.test.ts +++ b/packages/adapter-slack/src/blocks/index.test.ts @@ -240,6 +240,70 @@ describe("Slack Block Kit primitives", () => { ).toBe("v".repeat(150)); }); + it("matches truncated initial options for select elements", () => { + const longValue = "v".repeat(200); + const [block] = cardToSlackBlocks( + card([ + { + children: [ + { + id: "select", + initialOption: longValue, + label: "Select", + options: [{ label: "Option", value: longValue }], + type: "select", + }, + { + id: "radio", + initialOption: longValue, + label: "Radio", + options: [{ label: "Option", value: longValue }], + type: "radio_select", + }, + ], + type: "actions", + }, + ]) + ); + + expect( + ( + block.elements as Array<{ + initial_option: { value: string }; + }> + )[0].initial_option.value + ).toBe("v".repeat(150)); + expect( + ( + block.elements as Array<{ + initial_option: { value: string }; + }> + )[1].initial_option.value + ).toBe("v".repeat(150)); + }); + + it("omits initial options when no initial value is provided", () => { + const [block] = cardToSlackBlocks( + card([ + { + children: [ + { + id: "select", + label: "Select", + options: [{ label: "Option", value: "" }], + type: "select", + }, + ], + type: "actions", + }, + ]) + ); + + expect( + (block.elements as Array<{ initial_option?: unknown }>)[0].initial_option + ).toBeUndefined(); + }); + it("converts fields and tables", () => { const blocks = cardToSlackBlocks( card([ diff --git a/packages/adapter-slack/src/blocks/index.ts b/packages/adapter-slack/src/blocks/index.ts index 5b312803..66f98492 100644 --- a/packages/adapter-slack/src/blocks/index.ts +++ b/packages/adapter-slack/src/blocks/index.ts @@ -270,9 +270,7 @@ function selectToElement( .map((option) => optionObject(option, convertEmoji, "plain_text")); return compact({ action_id: truncateText(select.id, LIMITS.actionId), - initial_option: options.find( - (option) => option.value === select.initialOption - ), + initial_option: findInitialOption(options, select.initialOption), options, placeholder: select.placeholder ? plainText(select.placeholder, convertEmoji, LIMITS.placeholder) @@ -290,14 +288,23 @@ function radioSelectToElement( .map((option) => optionObject(option, convertEmoji, "mrkdwn")); return compact({ action_id: truncateText(select.id, LIMITS.actionId), - initial_option: options.find( - (option) => option.value === select.initialOption - ), + initial_option: findInitialOption(options, select.initialOption), options, type: "radio_buttons", }); } +function findInitialOption( + options: Record[], + initialOption: string | undefined +): Record | undefined { + if (initialOption === undefined) { + return undefined; + } + const value = truncateText(initialOption, LIMITS.optionValue); + return options.find((option) => option.value === value); +} + function optionObject( option: SlackSelectOptionElement, convertEmoji: (text: string) => string,