Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 36 additions & 7 deletions plugins/platforms/slack/block_kit.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,31 @@ def _walk_emphasis(s: str, style: Dict[str, bool]) -> None:
# ----------------------------------------------------------------------------


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}}
Expand All @@ -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")}]),
}
],
}
Expand All @@ -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)}],
}


Expand Down Expand Up @@ -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}

Expand Down Expand Up @@ -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))}
],
}

Expand Down Expand Up @@ -402,7 +429,9 @@ def flush_para() -> None:
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

Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
"sage@Sages-Mac-mini.local": "thestudionorth", # PR #60015 salvage (mcp: parent-death watchdog for stdio children; commit under unlinked local identity)
"4087127+vampyren@users.noreply.github.com": "vampyren", # PR #59830 salvage (kanban: grab-to-pan board scrolling; original commit under unlinked local identity)
"spiky02plateau@users.noreply.github.com": "spiky02plateau", # PR #32824 salvage (usage: fetch Codex account limits from the credential pool in pool-only setups; superseded by #60028)
"kamon@gao-ai.com": "kamonspecial", # slack rich_blocks fixes (config bridge + rich_text empty-content guards)
"taylorhp@gmail.com": "hwrdprkns", # PR #36896 salvage (secrets: 1Password op:// secret source + shared _cache substrate, adapted onto the SecretSource interface)
"ishengeqi@163.com": "isheng-eqi", # PR #59428 salvage (cron: reject past one-shot timestamps in update_job fallback + resume_job; #59395). Also PR #59446 salvage (cron: advance one-shot next_run_at before dispatch so concurrent gateway+desktop schedulers can't double-execute; #59229).
"derek2000139@qq.com": "derek2000139", # PR #57838 salvage (desktop/windows: pre-write update marker before quit dwell so the renderer's waitForUpdateToFinish gate parks instead of respawning a backend that re-locks venv .pyd files mid-update)
Expand Down
82 changes: 82 additions & 0 deletions tests/gateway/test_slack_block_kit.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,3 +228,85 @@ def test_never_raises_on_garbage(self):
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)
Loading