diff --git a/plugins/platforms/slack/block_kit.py b/plugins/platforms/slack/block_kit.py index 67faa7632781..56c8809ade5e 100644 --- a/plugins/platforms/slack/block_kit.py +++ b/plugins/platforms/slack/block_kit.py @@ -35,6 +35,10 @@ from typing import Any, Dict, List, Optional, Tuple MAX_BLOCKS = 50 MAX_SECTION_TEXT = 3000 MAX_HEADER_TEXT = 150 +# Native table block limits (https://docs.slack.dev/reference/block-kit/blocks/table-block) +MAX_TABLE_ROWS = 100 +MAX_TABLE_COLS = 20 +MAX_TABLE_CHARS = 10000 # aggregate across all cells Block = Dict[str, Any] @@ -208,15 +212,95 @@ def _section_block(text: str) -> Block: # ---------------------------------------------------------------------------- -# Table handling (best-effort monospace fallback) +# Table handling — native Block Kit ``table`` block, monospace fallback # ---------------------------------------------------------------------------- +def _parse_alignment(sep_line: str) -> List[str]: + """Parse a markdown separator row (``|:--|:-:|--:|``) into column aligns. + + Returns a list of ``"left"``/``"center"``/``"right"`` per column. + """ + aligns: List[str] = [] + for cell in sep_line.strip().strip("|").split("|"): + c = cell.strip() + left = c.startswith(":") + right = c.endswith(":") + if left and right: + aligns.append("center") + elif right: + aligns.append("right") + else: + aligns.append("left") + return aligns + + +def _split_row(row: str) -> List[str]: + """Split a markdown table row into trimmed cell strings. + + Respects backslash-escaped pipes (``\\|``) so they aren't treated as + column separators. + """ + # Temporarily protect escaped pipes, split on real ones, then restore. + protected = row.strip().strip("|").replace(r"\|", "\x00PIPE\x00") + return [c.strip().replace("\x00PIPE\x00", "|") for c in protected.split("|")] + + +def _rich_text_cell(text: str) -> Dict[str, Any]: + """A ``rich_text`` table cell carrying inline-formatted content.""" + return { + "type": "rich_text", + "elements": [ + {"type": "rich_text_section", "elements": _inline_elements(text)} + ], + } + + +def _table_block(rows: List[str], sep_line: str) -> Optional[Block]: + """Build a native Slack ``table`` block from markdown pipe-table rows. + + ``rows`` includes the header row (index 0) and body rows; ``sep_line`` is + the ``|---|`` alignment row (already consumed by the caller). Returns + ``None`` when the table exceeds Slack's limits (100 rows / 20 cols / + 10,000 aggregate cell chars) or parses to nothing — the caller then falls + back to the monospace preformatted rendering. + """ + parsed = [_split_row(r) for r in rows if r.strip()] + if not parsed: + return None + ncols = max(len(r) for r in parsed) + # Reject rather than silently truncate beyond Slack's structural limits. + if len(parsed) > MAX_TABLE_ROWS or ncols > MAX_TABLE_COLS: + return None + for r in parsed: + r.extend([""] * (ncols - len(r))) + + total_chars = sum(len(c) for r in parsed for c in r) + if total_chars > MAX_TABLE_CHARS: + return None + + aligns = _parse_alignment(sep_line) + column_settings: List[Optional[Dict[str, Any]]] = [] + for c in range(min(ncols, MAX_TABLE_COLS)): + align = aligns[c] if c < len(aligns) else "left" + # Only emit a setting when it differs from the default (left, no wrap); + # use null to skip a column, per the Slack schema. + column_settings.append({"align": align} if align != "left" else None) + + block: Block = { + "type": "table", + "rows": [[_rich_text_cell(cell) for cell in row] for row in parsed], + } + if any(cs is not None for cs in column_settings): + block["column_settings"] = column_settings + return block + + def _render_table(rows: List[str]) -> str: - """Render markdown pipe-table rows as aligned monospace text.""" + """Render markdown pipe-table rows as aligned monospace text (fallback).""" parsed: List[List[str]] = [] for r in rows: - cells = [c.strip() for c in r.strip().strip("|").split("|")] + cells = _split_row(r) parsed.append(cells) if not parsed: return "\n".join(rows) @@ -320,12 +404,20 @@ def render_blocks( # Pipe table: current line has a pipe AND next line is a separator if "|" in line and i + 1 < n and _TABLE_SEP_RE.match(lines[i + 1]): flush_para() - trows = [line] + header_row = line + sep_line = lines[i + 1] + trows = [header_row] i += 2 # skip header + separator while i < n and "|" in lines[i] and lines[i].strip(): trows.append(lines[i]) i += 1 - blocks.append(_preformatted_block(_render_table(trows))) + # Prefer a native Block Kit table; fall back to aligned + # monospace when it exceeds Slack's table limits or won't parse. + table = _table_block(trows, sep_line) + if table is not None: + blocks.append(table) + else: + blocks.append(_preformatted_block(_render_table(trows))) continue # Blockquote group diff --git a/tests/gateway/test_slack_block_kit.py b/tests/gateway/test_slack_block_kit.py index 5b1be679f59b..6dc0d74c7780 100644 --- a/tests/gateway/test_slack_block_kit.py +++ b/tests/gateway/test_slack_block_kit.py @@ -92,7 +92,7 @@ class TestInlineFormatting: class TestTables: - def test_pipe_table_renders_preformatted(self): + def test_pipe_table_renders_native_table_block(self): md = ( "| Name | Status |\n" "|------|--------|\n" @@ -101,13 +101,72 @@ class TestTables: ) blocks = render_blocks(md) assert len(blocks) == 1 + assert blocks[0]["type"] == "table" + rows = blocks[0]["rows"] + # header + 2 body rows, 2 columns each + assert len(rows) == 3 + assert all(len(r) == 2 for r in rows) + # cells are rich_text carrying the values + assert str(rows[0]).count("Name") == 1 + assert "fail" in str(rows[2]) + + def test_alignment_parsed_into_column_settings(self): + md = ( + "| L | C | R |\n" + "|:---|:--:|---:|\n" + "| 1 | 2 | 3 |" + ) + blocks = render_blocks(md) + cs = blocks[0]["column_settings"] + # left is default -> null; center/right emitted + assert cs[0] is None + assert cs[1] == {"align": "center"} + assert cs[2] == {"align": "right"} + + def test_inline_formatting_inside_cells(self): + md = ( + "| Item | Link |\n" + "|------|------|\n" + "| **bold** | [x](https://e.io) |" + ) + blocks = render_blocks(md) + body = blocks[0]["rows"][1] + # bold styled text element in first cell + bold = [ + el for el in body[0]["elements"][0]["elements"] + if el.get("style", {}).get("bold") + ] + assert bold + # link element in second cell + links = [el for el in body[1]["elements"][0]["elements"] if el["type"] == "link"] + assert links and links[0]["url"] == "https://e.io" + + def test_oversized_table_falls_back_to_monospace(self): + # 120 rows > MAX_TABLE_ROWS -> monospace rich_text fallback, not a table + big = "| a | b |\n|---|---|\n" + "\n".join(f"| x{i} | y |" for i in range(120)) + blocks = render_blocks(big) + assert blocks[0]["type"] == "rich_text" # preformatted fallback + assert blocks[0]["elements"][0]["type"] == "rich_text_preformatted" + + def test_too_many_columns_falls_back_to_monospace(self): + header = "|" + "|".join(f"c{i}" for i in range(25)) + "|" + sep = "|" + "|".join("-" for _ in range(25)) + "|" + row = "|" + "|".join("v" for _ in range(25)) + "|" + blocks = render_blocks(f"{header}\n{sep}\n{row}") assert blocks[0]["type"] == "rich_text" - pre = blocks[0]["elements"][0] - assert pre["type"] == "rich_text_preformatted" - text = pre["elements"][0]["text"] - # header cell values preserved and column aligned - assert "Name" in text and "Status" in text - assert "fail" in text + + def test_escaped_pipe_not_a_column_separator(self): + md = ( + "| Expr | Meaning |\n" + "|------|--------|\n" + "| a \\| b | or |" + ) + blocks = render_blocks(md) + assert blocks[0]["type"] == "table" + # the escaped-pipe cell stays a single cell containing a literal pipe + body = blocks[0]["rows"][1] + assert len(body) == 2 + assert "|" in str(body[0]) class TestLimits: diff --git a/website/docs/user-guide/messaging/slack.md b/website/docs/user-guide/messaging/slack.md index aa9b817f6eaf..34827cf79d0e 100644 --- a/website/docs/user-guide/messaging/slack.md +++ b/website/docs/user-guide/messaging/slack.md @@ -346,10 +346,11 @@ platforms: # Render agent messages as Slack Block Kit blocks (default: false). # When true, the final agent message is sent with structured blocks — - # section headers, dividers, and true nested lists (via rich_text) — - # instead of flat mrkdwn text. A plain-text fallback is always sent - # alongside for notifications/accessibility. Markdown tables are - # rendered as aligned monospace (Block Kit has no native table block). + # section headers, dividers, true nested lists (via rich_text), and + # native Block Kit tables — instead of flat mrkdwn text. A plain-text + # fallback is always sent alongside for notifications/accessibility. + # Tables exceeding Slack's limits (100 rows / 20 cols / 10k chars) + # gracefully fall back to aligned monospace. rich_blocks: false ``` @@ -358,7 +359,7 @@ platforms: | `platforms.slack.reply_to_mode` | `"first"` | Threading mode for multi-part messages: `"off"`, `"first"`, or `"all"` | | `platforms.slack.extra.reply_in_thread` | `true` | When `false`, channel messages get direct replies instead of threads. Messages inside existing threads still reply in-thread. | | `platforms.slack.extra.reply_broadcast` | `false` | When `true`, thread replies are also posted to the main channel. Only the first chunk is broadcast. | -| `platforms.slack.extra.rich_blocks` | `false` | When `true`, agent messages are rendered as [Block Kit](https://docs.slack.dev/block-kit/) blocks (headers, dividers, true nested lists). A plain-text fallback is always sent. Tables render as aligned monospace. No app reinstall required — it's a send-side change only. | +| `platforms.slack.extra.rich_blocks` | `false` | When `true`, agent messages are rendered as [Block Kit](https://docs.slack.dev/block-kit/) blocks (headers, dividers, true nested lists, and native tables). A plain-text fallback is always sent. Tables over Slack's limits fall back to aligned monospace. No app reinstall required — it's a send-side change only. | ### Session Isolation diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/slack.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/slack.md index 06c3a8f4dc75..02ee4c9cf0cf 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/slack.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/slack.md @@ -301,10 +301,10 @@ platforms: # 将 Agent 消息渲染为 Slack Block Kit 区块(默认:false)。 # 为 true 时,最终的 Agent 消息会以结构化区块发送——包括 - # 章节标题、分隔线以及真正的嵌套列表(通过 rich_text)—— - # 而非扁平的 mrkdwn 文本。同时始终附带纯文本回退内容, - # 用于通知和无障碍访问。Markdown 表格会渲染为对齐的等宽文本 - # (Block Kit 没有原生表格区块)。 + # 章节标题、分隔线、真正的嵌套列表(通过 rich_text)以及 + # 原生 Block Kit 表格——而非扁平的 mrkdwn 文本。同时始终附带 + # 纯文本回退内容,用于通知和无障碍访问。超出 Slack 限制 + # (100 行 / 20 列 / 1 万字符)的表格会优雅地回退为对齐的等宽文本。 rich_blocks: false ``` @@ -313,7 +313,7 @@ platforms: | `platforms.slack.reply_to_mode` | `"first"` | 多部分消息的话题模式:`"off"`、`"first"` 或 `"all"` | | `platforms.slack.extra.reply_in_thread` | `true` | 为 `false` 时,频道消息直接回复而非话题。已在话题中的消息仍在话题中回复。 | | `platforms.slack.extra.reply_broadcast` | `false` | 为 `true` 时,话题回复也会发布到主频道。仅广播第一个分块。 | -| `platforms.slack.extra.rich_blocks` | `false` | 为 `true` 时,Agent 消息会渲染为 [Block Kit](https://docs.slack.dev/block-kit/) 区块(标题、分隔线、真正的嵌套列表)。始终附带纯文本回退。表格渲染为对齐的等宽文本。无需重新安装应用——这仅是发送端的改动。 | +| `platforms.slack.extra.rich_blocks` | `false` | 为 `true` 时,Agent 消息会渲染为 [Block Kit](https://docs.slack.dev/block-kit/) 区块(标题、分隔线、真正的嵌套列表以及原生表格)。始终附带纯文本回退。超出 Slack 限制的表格会回退为对齐的等宽文本。无需重新安装应用——这仅是发送端的改动。 | ### 会话隔离