diff --git a/plugins/platforms/slack/block_kit.py b/plugins/platforms/slack/block_kit.py index 130a4b95ab36..881814f8265a 100644 --- a/plugins/platforms/slack/block_kit.py +++ b/plugins/platforms/slack/block_kit.py @@ -146,9 +146,31 @@ def _inline_elements(text: str) -> List[Dict[str, Any]]: # ---------------------------------------------------------------------------- -def _header_block(text: str) -> Block: +def _nonempty_elements(elements: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Make a rich_text child-element list safe for Slack. + + Slack rejects any ``rich_text_section`` / ``rich_text_preformatted`` / + ``rich_text_quote`` whose ``elements`` list is empty or contains a ``text`` + element of zero length (``invalid_blocks``: "missing element" / "must be + more than 0 characters"). Empty content is common — ragged table rows are + padded with ``""``, agents emit empty code fences around empty tool output, + blank quote lines and empty list items occur in the wild — so drop + zero-length text elements and, if nothing remains, substitute a single + space, which renders as blank yet stays schema-valid. Used by every + rich_text builder so empty content can never poison the whole payload. + """ + els = [e for e in elements if not (e.get("type") == "text" and not e.get("text"))] + return els or [{"type": "text", "text": " "}] + + +def _header_block(text: str) -> Optional[Block]: # header blocks are plain_text only, 150 char cap. clean = re.sub(r"[*_~`]", "", text).strip() + if not clean: + # Emphasis-/whitespace-only header (e.g. "# ***" or "# ") reduces to + # empty; Slack rejects an empty plain_text with invalid_blocks. Skip it + # (caller drops None) rather than poison the whole payload. + return None if len(clean) > MAX_HEADER_TEXT: clean = clean[: MAX_HEADER_TEXT - 1] + "…" return {"type": "header", "text": {"type": "plain_text", "text": clean, "emoji": True}} @@ -165,7 +187,7 @@ def _preformatted_block(text: str) -> Block: "elements": [ { "type": "rich_text_preformatted", - "elements": [{"type": "text", "text": text.rstrip("\n")}], + "elements": _nonempty_elements([{"type": "text", "text": text.rstrip("\n")}]), } ], } @@ -179,7 +201,7 @@ def _quote_block(lines: List[str]) -> Block: section_children.extend(_inline_elements(ln)) return { "type": "rich_text", - "elements": [{"type": "rich_text_quote", "elements": section_children}], + "elements": [{"type": "rich_text_quote", "elements": _nonempty_elements(section_children)}], } @@ -207,7 +229,7 @@ def _list_block(items: List[Tuple[int, bool, str]]) -> Block: cur_key = key assert cur is not None cur["elements"].append( - {"type": "rich_text_section", "elements": _inline_elements(text)} + {"type": "rich_text_section", "elements": _nonempty_elements(_inline_elements(text))} ) return {"type": "rich_text", "elements": elements} @@ -252,11 +274,16 @@ def _split_row(row: str) -> List[str]: def _rich_text_cell(text: str) -> Dict[str, Any]: - """A ``rich_text`` table cell carrying inline-formatted content.""" + """A ``rich_text`` table cell carrying inline-formatted content. + + Empty cells are common (ragged rows are padded with ``""``); Slack rejects + a cell whose section is empty or carries a zero-length text element, so the + elements are routed through ``_nonempty_elements``. + """ return { "type": "rich_text", "elements": [ - {"type": "rich_text_section", "elements": _inline_elements(text)} + {"type": "rich_text_section", "elements": _nonempty_elements(_inline_elements(text))} ], } @@ -409,7 +436,9 @@ def render_blocks( hm = _HEADER_RE.match(line) if hm: flush_para() - blocks.append(_header_block(hm.group(2))) + header = _header_block(hm.group(2)) + if header is not None: + blocks.append(header) i += 1 continue diff --git a/tests/gateway/test_slack_block_kit.py b/tests/gateway/test_slack_block_kit.py index a49ed4b78def..a5805be2e594 100644 --- a/tests/gateway/test_slack_block_kit.py +++ b/tests/gateway/test_slack_block_kit.py @@ -251,3 +251,85 @@ class TestLimits: for junk in ["```unterminated\ncode", "| broken | table", "> ", "#" * 10]: # must not raise; either blocks or None render_blocks(junk) + + +class TestEmptyContentGuards: + """Empty content must never produce a Slack-rejected (invalid_blocks) payload. + + Slack rejects a rich_text_section / rich_text_preformatted / + rich_text_quote whose ``elements`` is empty or contains a zero-length + ``text`` element, and a ``header`` whose plain_text is empty. Each guard + below corresponds to a real chat.postMessage rejection observed in + production ("missing element" / "must be more than 0 characters"). + """ + + @staticmethod + def _assert_schema_valid(blocks): + def walk(o): + if isinstance(o, dict): + if o.get("type") in ( + "rich_text_section", "rich_text_preformatted", "rich_text_quote" + ): + assert o.get("elements"), f"empty {o['type']} elements" + if o.get("type") == "text": + assert len(o.get("text", "")) > 0, "zero-length text element" + if o.get("type") == "header": + assert o["text"]["text"], "empty plain_text header" + for v in o.values(): + walk(v) + elif isinstance(o, list): + for v in o: + walk(v) + + walk(blocks) + + def test_ragged_and_empty_table_cells_are_schema_valid(self): + # Blank middle cell + ragged short row (padded with "") must not emit + # an empty section or a 0-char text element. + md = ( + "| x | y | z |\n" + "| --- | --- | --- |\n" + "| 1 | | 3 |\n" # blank middle cell + "| 4 |" # ragged row -> padded with empty cells + ) + blocks = render_blocks(md) + assert blocks[0]["type"] == "table" + self._assert_schema_valid(blocks) + + def test_empty_code_fence_quote_and_list_item_are_schema_valid(self): + # Empty fenced code block (common around empty tool output), blank + # quote line, and empty list item must all stay schema-valid. + md = "```\n```\n\n> \n\n- \n- real item" + blocks = render_blocks(md) + assert blocks is not None + self._assert_schema_valid(blocks) + + def test_multiline_quote_preserves_newline_separators(self): + # _quote_block separates lines with length-1 "\n" text elements; the + # guard must KEEP them so a multi-line blockquote stays multi-line. + blocks = render_blocks("> alpha\n> bravo") + quote = None + for b in blocks: + for el in b.get("elements", []): + if isinstance(el, dict) and el.get("type") == "rich_text_quote": + quote = el + assert quote is not None, "no rich_text_quote produced" + texts = [e.get("text") for e in quote["elements"] if e.get("type") == "text"] + assert "\n" in texts, "newline separator dropped from multi-line quote" + assert any("alpha" in (t or "") for t in texts) + assert any("bravo" in (t or "") for t in texts) + + def test_emphasis_only_header_is_dropped_not_empty(self): + # "# ***" reduces to "" after marker-strip; an empty plain_text header + # is rejected by Slack, so the header is skipped entirely. + blocks = render_blocks("# ***\n\nreal body") + assert not any(b.get("type") == "header" for b in blocks) + self._assert_schema_valid(blocks) + + def test_normal_content_unaffected(self): + # Guard must not alter well-formed content. + md = "# Title\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\n> quoted\n\n- item" + blocks = render_blocks(md) + assert any(b.get("type") == "header" for b in blocks) + assert any(b.get("type") == "table" for b in blocks) + self._assert_schema_valid(blocks)