fix(slack): guard rich_text builders against empty content rejected as invalid_blocks

Slack rejects a rich_text_section / rich_text_preformatted / rich_text_quote
whose elements list is empty or contains a zero-length text element, and a
header whose plain_text is empty. The Block Kit renderer emits exactly those
shapes for common inputs: a markdown table with a blank cell or a ragged
(short) row, an empty fenced code block, a blank quote line, an empty list
item, and an emphasis-only header ("# ***"). Any one of them poisons the
whole payload — chat.postMessage fails with invalid_blocks ("missing element"
/ "must be more than 0 characters") and the message loses its rich rendering
entirely.

Route every rich_text builder's child elements through _nonempty_elements
(drop zero-length text elements; substitute a single space when nothing
remains) and skip headers that reduce to empty after markdown-marker
stripping. Observed in production via live chat.postMessage rejections;
regression tests cover each case plus a well-formed-content control.

Complementary to #56618 (column_settings hardening + no-blocks retry): that
change makes Block Kit rejections recoverable, this one makes the common
empty-content cases render correctly in the first place. No overlapping hunks.
This commit is contained in:
kamon 2026-07-06 21:36:48 +09:00 committed by Teknium
parent 870770b31e
commit d91a143977
2 changed files with 118 additions and 7 deletions

View file

@ -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

View file

@ -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)