fix(slack): restore table block mode seam (#57591)

* fix(slack): restore table block mode seam

Restore the shared markdown/config seam needed for Slack Block Kit table support, while coercing non-Slack block mode back to code.

* fix(slack): narrow table block seam defaults

Keep Slack table block mode opt-in in this seam-only PR, clamp collected placeholder offsets, and align fallback-table rendering with Slack block limits.

* fix(slack): bound table fallback rendering

Avoid spread-based maxima and bound Slack table fallback rendering by row, column, cell-width, and total-output limits to prevent resource exhaustion.

* fix(slack): keep block mode inactive in seam PR

Keep markdown table block mode schema-valid but runtime-resolved to code until the Slack send path is wired to emit table attachments.

* fix(slack): normalize configured block mode safely

Accept configured markdown table block mode at parse time, then normalize it back to code during runtime resolution so seam-only branches do not drop table content.
This commit is contained in:
Vincent Koc
2026-03-30 19:25:01 +09:00
committed by GitHub
parent 56be744a7a
commit 54f7221465
11 changed files with 307 additions and 9 deletions

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import {
markdownTableToSlackTableBlock,
renderSlackTableFallbackText,
} from "./block-kit-tables.js";
describe("markdownTableToSlackTableBlock", () => {
it("caps rows and columns to Slack's limits", () => {
const table = {
headers: Array.from({ length: 25 }, (_, index) => `H${index}`),
rows: Array.from({ length: 120 }, () => Array.from({ length: 25 }, (_, index) => `V${index}`)),
};
const block = markdownTableToSlackTableBlock(table);
expect(block.column_settings).toHaveLength(20);
expect(block.rows).toHaveLength(100);
expect(block.rows[0]).toHaveLength(20);
});
});
describe("renderSlackTableFallbackText", () => {
it("matches the block helper's empty-header behavior", () => {
const rendered = renderSlackTableFallbackText({
headers: ["", ""],
rows: [["A", "1"]],
});
expect(rendered).not.toContain("| | |");
expect(rendered).toContain("| A | 1 |");
});
it("applies the same row and column caps as the block helper", () => {
const rendered = renderSlackTableFallbackText({
headers: Array.from({ length: 25 }, (_, index) => `H${index}`),
rows: Array.from({ length: 120 }, () => Array.from({ length: 25 }, (_, index) => `V${index}`)),
});
const lines = rendered.split("\n");
expect(lines.length).toBeGreaterThan(1);
expect(rendered.length).toBeLessThanOrEqual(4000);
expect(lines[0]?.split("|").length ?? 0).toBeLessThanOrEqual(22);
});
it("truncates extremely wide cells to keep fallback rendering bounded", () => {
const rendered = renderSlackTableFallbackText({
headers: ["A"],
rows: [["x".repeat(5000)]],
});
expect(rendered.length).toBeLessThanOrEqual(4000);
expect(rendered).toContain("...");
});
it("does not depend on spread Math.max over huge row arrays", () => {
const rendered = renderSlackTableFallbackText({
headers: ["A"],
rows: Array.from({ length: 5000 }, (_, index) => [`row-${index}`]),
});
expect(rendered.length).toBeLessThanOrEqual(4000);
expect(rendered).toContain("row-0");
});
});

View File

@@ -0,0 +1,135 @@
import type { MarkdownTableData } from "openclaw/plugin-sdk/text-runtime";
const SLACK_MAX_TABLE_COLUMNS = 20;
const SLACK_MAX_TABLE_ROWS = 100;
const SLACK_MAX_FALLBACK_CELL_WIDTH = 80;
const SLACK_MAX_FALLBACK_TEXT_LENGTH = 4000;
type SlackTableCell = {
type: "raw_text";
text: string;
};
export type SlackTableBlock = {
type: "table";
column_settings: {
is_wrapped: boolean;
}[];
rows: SlackTableCell[][];
};
function hasVisibleHeaders(headers: string[]): boolean {
for (const header of headers) {
if (header.length > 0) {
return true;
}
}
return false;
}
function getCappedRowCount(rows: string[][]): number {
return Math.min(rows.length, SLACK_MAX_TABLE_ROWS);
}
function getMaxColumnCount(headers: string[], rows: string[][]): number {
let maxColumns = headers.length;
const rowCount = getCappedRowCount(rows);
for (let index = 0; index < rowCount; index += 1) {
const rowLength = rows[index]?.length ?? 0;
if (rowLength > maxColumns) {
maxColumns = rowLength;
}
}
return Math.min(maxColumns, SLACK_MAX_TABLE_COLUMNS);
}
function truncateFallbackCell(value: string): string {
if (value.length <= SLACK_MAX_FALLBACK_CELL_WIDTH) {
return value;
}
return `${value.slice(0, SLACK_MAX_FALLBACK_CELL_WIDTH - 3)}...`;
}
export function markdownTableToSlackTableBlock(table: MarkdownTableData): SlackTableBlock {
const columnCount = getMaxColumnCount(table.headers, table.rows);
if (columnCount === 0) {
return { type: "table", column_settings: [], rows: [] };
}
const makeRow = (cells: string[]): SlackTableCell[] =>
Array.from({ length: columnCount }, (_, index) => ({
type: "raw_text",
text: cells[index] ?? "",
}));
const rows = [
...(hasVisibleHeaders(table.headers) ? [makeRow(table.headers)] : []),
...table.rows.slice(0, SLACK_MAX_TABLE_ROWS).map(makeRow),
].slice(0, SLACK_MAX_TABLE_ROWS);
return {
type: "table",
column_settings: Array.from({ length: columnCount }, () => ({ is_wrapped: true })),
rows,
};
}
export function buildSlackTableAttachment(table: MarkdownTableData): { blocks: SlackTableBlock[] } {
return {
blocks: [markdownTableToSlackTableBlock(table)],
};
}
export function renderSlackTableFallbackText(table: MarkdownTableData): string {
const hasHeaders = hasVisibleHeaders(table.headers);
const cappedRows = table.rows.slice(0, SLACK_MAX_TABLE_ROWS);
const rows = [
...(hasHeaders ? [table.headers] : []),
...cappedRows,
].filter((row) => row.length > 0);
if (rows.length === 0) {
return "Table";
}
const columnCount = getMaxColumnCount(table.headers, cappedRows);
const widths = Array.from({ length: columnCount }, () => 1);
const safeRows = rows.map((row) =>
Array.from({ length: columnCount }, (_, columnIndex) =>
truncateFallbackCell(row[columnIndex] ?? ""),
),
);
for (const row of safeRows) {
for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) {
const width = row[columnIndex]?.length ?? 0;
if (width > (widths[columnIndex] ?? 1)) {
widths[columnIndex] = width;
}
}
}
const lines: string[] = [];
let totalLength = 0;
for (let rowIndex = 0; rowIndex < safeRows.length; rowIndex += 1) {
const cells = Array.from({ length: columnCount }, (_, columnIndex) =>
(safeRows[rowIndex]?.[columnIndex] ?? "").padEnd(widths[columnIndex] ?? 1),
);
const line = `| ${cells.join(" | ")} |`;
if (totalLength + line.length > SLACK_MAX_FALLBACK_TEXT_LENGTH) {
break;
}
lines.push(line);
totalLength += line.length + 1;
if (rowIndex === 0 && hasHeaders) {
const separator = `| ${widths.map((width) => "-".repeat(width)).join(" | ")} |`;
if (totalLength + separator.length > SLACK_MAX_FALLBACK_TEXT_LENGTH) {
break;
}
lines.push(separator);
totalLength += separator.length + 1;
}
}
return lines.length > 0 ? lines.join("\n") : "Table";
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_TABLE_MODES } from "./markdown-tables.js";
import { DEFAULT_TABLE_MODES, resolveMarkdownTableMode } from "./markdown-tables.js";
describe("DEFAULT_TABLE_MODES", () => {
it("mattermost mode is off", () => {
@@ -13,4 +13,24 @@ describe("DEFAULT_TABLE_MODES", () => {
it("whatsapp mode is bullets", () => {
expect(DEFAULT_TABLE_MODES.get("whatsapp")).toBe("bullets");
});
it("slack has no special default in this seam-only slice", () => {
expect(DEFAULT_TABLE_MODES.get("slack")).toBeUndefined();
});
});
describe("resolveMarkdownTableMode", () => {
it("defaults to code for slack", () => {
expect(resolveMarkdownTableMode({ channel: "slack" })).toBe("code");
});
it("coerces explicit block mode to code for slack", () => {
const cfg = { channels: { slack: { markdown: { tables: "block" as const } } } };
expect(resolveMarkdownTableMode({ cfg, channel: "slack" })).toBe("code");
});
it("coerces explicit block mode to code for non-slack channels", () => {
const cfg = { channels: { telegram: { markdown: { tables: "block" as const } } } };
expect(resolveMarkdownTableMode({ cfg, channel: "telegram" })).toBe("code");
});
});

View File

@@ -21,7 +21,7 @@ export const DEFAULT_TABLE_MODES = new Map<string, MarkdownTableMode>([
]);
const isMarkdownTableMode = (value: unknown): value is MarkdownTableMode =>
value === "off" || value === "bullets" || value === "code";
value === "off" || value === "bullets" || value === "code" || value === "block";
function resolveMarkdownModeFromSection(
section: MarkdownConfigSection | undefined,
@@ -58,5 +58,8 @@ export function resolveMarkdownTableMode(params: {
(params.cfg as Record<string, unknown> | undefined)?.[channel]) as
| MarkdownConfigSection
| undefined;
return resolveMarkdownModeFromSection(section, params.accountId) ?? defaultMode;
const resolved = resolveMarkdownModeFromSection(section, params.accountId) ?? defaultMode;
// "block" stays schema-valid for the shared markdown seam, but this PR
// keeps runtime delivery on safe text rendering until Slack send support lands.
return resolved === "block" ? "code" : resolved;
}

View File

@@ -31,10 +31,10 @@ export type BlockStreamingChunkConfig = {
breakPreference?: "paragraph" | "newline" | "sentence";
};
export type MarkdownTableMode = "off" | "bullets" | "code";
export type MarkdownTableMode = "off" | "bullets" | "code" | "block";
export type MarkdownConfig = {
/** Table rendering mode (off|bullets|code). */
/** Table rendering mode (off|bullets|code|block). */
tables?: MarkdownTableMode;
};

View File

@@ -367,7 +367,7 @@ export const BlockStreamingChunkSchema = z
})
.strict();
export const MarkdownTableModeSchema = z.enum(["off", "bullets", "code"]);
export const MarkdownTableModeSchema = z.enum(["off", "bullets", "code", "block"]);
export const MarkdownConfigSchema = z
.object({

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { MarkdownTableModeSchema } from "./zod-schema.core.js";
describe("MarkdownTableModeSchema", () => {
it("accepts block mode", () => {
expect(() => MarkdownTableModeSchema.parse("block")).not.toThrow();
});
it("rejects unsupported values", () => {
expect(() => MarkdownTableModeSchema.parse("plain")).toThrow();
});
});

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { markdownToIRWithMeta } from "./ir.js";
describe("markdownToIRWithMeta tableMode block", () => {
it("collects table metadata without inlining table text", () => {
const { ir, hasTables, tables } = markdownToIRWithMeta(
"Before\n\n| Name | Age |\n|---|---|\n| Alice | 30 |\n\nAfter",
{ tableMode: "block" },
);
expect(hasTables).toBe(true);
expect(tables).toEqual([
{
headers: ["Name", "Age"],
rows: [["Alice", "30"]],
placeholderOffset: ir.text.indexOf("After"),
},
]);
expect(ir.text).toContain("Before");
expect(ir.text).toContain("After");
expect(ir.text).not.toContain("| Name | Age |");
});
});

View File

@@ -51,6 +51,15 @@ export type MarkdownIR = {
links: MarkdownLinkSpan[];
};
export type MarkdownTableData = {
headers: string[];
rows: string[][];
};
export type MarkdownTableMeta = MarkdownTableData & {
placeholderOffset: number;
};
type OpenStyle = {
style: MarkdownStyle;
start: number;
@@ -86,6 +95,7 @@ type RenderState = RenderTarget & {
tableMode: MarkdownTableMode;
table: TableState | null;
hasTables: boolean;
collectedTables: MarkdownTableMeta[];
};
export type MarkdownParseOptions = {
@@ -94,7 +104,7 @@ export type MarkdownParseOptions = {
headingStyle?: "none" | "bold";
blockquotePrefix?: string;
autolink?: boolean;
/** How to render tables (off|bullets|code). Default: off. */
/** How to render tables (off|bullets|code|block). Default: off. */
tableMode?: MarkdownTableMode;
};
@@ -400,6 +410,17 @@ function appendCellTextOnly(state: RenderState, cell: TableCell) {
// Do not append styles - this is used for code blocks where inner styles would overlap
}
function collectTableBlock(state: RenderState) {
if (!state.table) {
return;
}
state.collectedTables.push({
headers: state.table.headers.map((cell) => trimCell(cell).text),
rows: state.table.rows.map((row) => row.map((cell) => trimCell(cell).text)),
placeholderOffset: state.text.length,
});
}
function appendTableBulletValue(
state: RenderState,
params: {
@@ -696,6 +717,8 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
renderTableAsBullets(state);
} else if (state.tableMode === "code") {
renderTableAsCode(state);
} else if (state.tableMode === "block") {
collectTableBlock(state);
}
}
state.table = null;
@@ -893,7 +916,7 @@ export function markdownToIR(markdown: string, options: MarkdownParseOptions = {
export function markdownToIRWithMeta(
markdown: string,
options: MarkdownParseOptions = {},
): { ir: MarkdownIR; hasTables: boolean } {
): { ir: MarkdownIR; hasTables: boolean; tables: MarkdownTableMeta[] } {
const env: RenderEnv = { listStack: [] };
const md = createMarkdownIt(options);
const tokens = md.parse(markdown ?? "", env as unknown as object);
@@ -916,6 +939,7 @@ export function markdownToIRWithMeta(
tableMode,
table: null,
hasTables: false,
collectedTables: [],
};
renderTokens(tokens as MarkdownToken[], state);
@@ -943,6 +967,10 @@ export function markdownToIRWithMeta(
links: clampLinkSpans(state.links, finalLength),
},
hasTables: state.hasTables,
tables: state.collectedTables.map((table) => ({
...table,
placeholderOffset: Math.min(table.placeholderOffset, finalLength),
})),
};
}

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { convertMarkdownTables } from "./tables.js";
describe("convertMarkdownTables", () => {
it("falls back to code rendering for block mode", () => {
const rendered = convertMarkdownTables("| A | B |\n|---|---|\n| 1 | 2 |", "block");
expect(rendered).toContain("```");
expect(rendered).toContain("| A | B |");
expect(rendered).toContain("| 1 | 2 |");
});
});

View File

@@ -14,12 +14,13 @@ export function convertMarkdownTables(markdown: string, mode: MarkdownTableMode)
if (!markdown || mode === "off") {
return markdown;
}
const effectiveMode = mode === "block" ? "code" : mode;
const { ir, hasTables } = markdownToIRWithMeta(markdown, {
linkify: false,
autolink: false,
headingStyle: "none",
blockquotePrefix: "",
tableMode: mode,
tableMode: effectiveMode,
});
if (!hasTables) {
return markdown;