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