mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-21 13:44:03 +00:00
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:
64
extensions/slack/src/block-kit-tables.test.ts
Normal file
64
extensions/slack/src/block-kit-tables.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
135
extensions/slack/src/block-kit-tables.ts
Normal file
135
extensions/slack/src/block-kit-tables.ts
Normal 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";
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
12
src/config/zod-schema.markdown-tables.test.ts
Normal file
12
src/config/zod-schema.markdown-tables.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
23
src/markdown/ir.table-block.test.ts
Normal file
23
src/markdown/ir.table-block.test.ts
Normal 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 |");
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
12
src/markdown/tables.test.ts
Normal file
12
src/markdown/tables.test.ts
Normal 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 |");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user