mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
Consolidated defensive layer for the invalid_blocks / msg_too_long bug class (#56615, #62054, #53693): one malformed or oversized block fails the ENTIRE chat.postMessage / chat.update call, so approval cards never update and messages silently drop. New block_kit.sanitize_blocks() is applied at every call site that attaches blocks (send/edit via _maybe_blocks, send_exec_approval, send_slash_confirm, and the approval/slash-confirm chat.update button handlers). It: - truncates section/context text to the 3000-char cap with an ellipsis (covers interaction payloads where Slack's HTML-escaping of < > & inflates text past the limit budgeted at send time) - truncates header text to its 150-char cap - drops empty blocks (no text / elements / rows) - replaces null table column_settings entries with {} and trims default trailing entries (Slack requires every entry be an object) - caps the payload at Slack's 50-block maximum - returns None when nothing valid remains so callers fall back to the plain-text payload; never raises Also registers contributor mappings for the salvaged PRs in this cluster (tw0316, kamonspecial, sowork-skills).
667 lines
26 KiB
Python
667 lines
26 KiB
Python
"""Render agent markdown into Slack Block Kit blocks.
|
|
|
|
Opt-in (``slack.extra.rich_blocks: true``) alternative to the flat mrkdwn
|
|
``text`` payload produced by :meth:`SlackAdapter.format_message`. Block Kit
|
|
gives us real structural primitives — section headers, dividers, and true
|
|
*nested* lists via ``rich_text`` — that plain mrkdwn can only approximate.
|
|
|
|
Design constraints (why this module is deliberately conservative):
|
|
|
|
* **Markdown pipe-tables render as native ``table`` blocks** — real grid
|
|
cells with per-column alignment and inline-formatted ``rich_text`` content.
|
|
A table that exceeds Slack's limits (100 rows / 20 cols / 10k aggregate
|
|
cell chars) or won't parse falls back to aligned monospace
|
|
``rich_text_preformatted`` so a large table never breaks the message.
|
|
* **Slack caps a message at 50 blocks** and a ``section``/text object at 3000
|
|
characters. :func:`render_blocks` enforces both and, if the content simply
|
|
cannot be expressed within them, returns ``None`` so the caller falls back
|
|
to the plain-text path. A rich render is a nice-to-have; it must never lose
|
|
a message.
|
|
* **Every blocks payload MUST ship a ``text`` fallback.** Slack uses it for
|
|
notifications, screen readers, and old clients. This module only builds the
|
|
``blocks`` list; the adapter pairs it with the existing mrkdwn string.
|
|
|
|
The renderer never raises: any unexpected input degrades to ``None`` (caller
|
|
uses plain text). It is a pure function of its input — no Slack client, no
|
|
adapter state — so it is trivially unit-testable.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
# Slack Block Kit hard limits (https://docs.slack.dev/reference/block-kit/blocks)
|
|
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]
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Line classification
|
|
# ----------------------------------------------------------------------------
|
|
|
|
_HR_RE = re.compile(r"^\s{0,3}([-*_])(?:\s*\1){2,}\s*$")
|
|
_HEADER_RE = re.compile(r"^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$")
|
|
_FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,})(.*)$")
|
|
_ORDERED_RE = re.compile(r"^(\s*)(\d+)[.)]\s+(.*)$")
|
|
_BULLET_RE = re.compile(r"^(\s*)[-*+]\s+(.*)$")
|
|
_QUOTE_RE = re.compile(r"^\s{0,3}>\s?(.*)$")
|
|
_TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)+\|?\s*$")
|
|
|
|
|
|
def _is_list_line(line: str) -> bool:
|
|
"""True if ``line`` is a markdown list item (bullet or ordered)."""
|
|
return bool(_BULLET_RE.match(line) or _ORDERED_RE.match(line))
|
|
|
|
|
|
def _indent_level(spaces: str) -> int:
|
|
"""Map leading whitespace to a nesting level (2 spaces or 1 tab per level)."""
|
|
width = 0
|
|
for ch in spaces:
|
|
width += 4 if ch == "\t" else 1
|
|
return min(width // 2, 5) # Slack rich_text_list supports up to indent 5
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Inline markdown → rich_text elements
|
|
# ----------------------------------------------------------------------------
|
|
|
|
# Order matters: code first (opaque), then links, then emphasis.
|
|
_INLINE_CODE_RE = re.compile(r"`([^`]+)`")
|
|
_LINK_RE = re.compile(r"(?<!!)\[([^\]]+)\]\(([^()\s]+(?:\([^()]*\)[^()\s]*)*)\)")
|
|
_BOLD_RE = re.compile(r"(?:\*\*|__)(.+?)(?:\*\*|__)")
|
|
_ITALIC_RE = re.compile(r"(?<![\*_])(?:\*|_)(?![\*_\s])(.+?)(?<![\*_\s])(?:\*|_)(?![\*_])")
|
|
_STRIKE_RE = re.compile(r"~~(.+?)~~")
|
|
|
|
|
|
def _inline_elements(text: str) -> List[Dict[str, Any]]:
|
|
"""Parse a run of inline markdown into rich_text section child elements.
|
|
|
|
Produces ``text`` elements (optionally styled bold/italic/strike/code) and
|
|
``link`` elements. Unmatched markup is emitted verbatim as plain text, so
|
|
this never loses characters.
|
|
"""
|
|
elements: List[Dict[str, Any]] = []
|
|
|
|
def emit_text(s: str, style: Optional[Dict[str, bool]] = None) -> None:
|
|
if not s:
|
|
return
|
|
el: Dict[str, Any] = {"type": "text", "text": s}
|
|
if style:
|
|
el["style"] = style
|
|
elements.append(el)
|
|
|
|
# Tokenize by the highest-priority markers first using a single scan.
|
|
# We recursively split on code, then links, then emphasis to keep spans
|
|
# from overlapping incorrectly.
|
|
def walk(s: str, style: Dict[str, bool]) -> None:
|
|
pos = 0
|
|
# inline code is opaque — no nested styling
|
|
for m in _INLINE_CODE_RE.finditer(s):
|
|
_walk_links(s[pos:m.start()], style)
|
|
code_style = dict(style)
|
|
code_style["code"] = True
|
|
emit_text(m.group(1), code_style or None)
|
|
pos = m.end()
|
|
_walk_links(s[pos:], style)
|
|
|
|
def _walk_links(s: str, style: Dict[str, bool]) -> None:
|
|
pos = 0
|
|
for m in _LINK_RE.finditer(s):
|
|
_walk_emphasis(s[pos:m.start()], style)
|
|
link_el: Dict[str, Any] = {"type": "link", "url": m.group(2), "text": m.group(1)}
|
|
if style:
|
|
link_el["style"] = dict(style)
|
|
elements.append(link_el)
|
|
pos = m.end()
|
|
_walk_emphasis(s[pos:], style)
|
|
|
|
def _walk_emphasis(s: str, style: Dict[str, bool]) -> None:
|
|
if not s:
|
|
return
|
|
# Try bold, then strike, then italic, recursing into the inner span.
|
|
for rx, key in ((_BOLD_RE, "bold"), (_STRIKE_RE, "strike"), (_ITALIC_RE, "italic")):
|
|
m = rx.search(s)
|
|
if m:
|
|
_walk_emphasis(s[:m.start()], style)
|
|
inner_style = dict(style)
|
|
inner_style[key] = True
|
|
_walk_emphasis(m.group(1), inner_style)
|
|
_walk_emphasis(s[m.end():], style)
|
|
return
|
|
emit_text(s, dict(style) if style else None)
|
|
|
|
walk(text, {})
|
|
return elements or [{"type": "text", "text": text}]
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Structural block builders
|
|
# ----------------------------------------------------------------------------
|
|
|
|
|
|
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}}
|
|
|
|
|
|
def _divider_block() -> Block:
|
|
return {"type": "divider"}
|
|
|
|
|
|
def _preformatted_block(text: str) -> Block:
|
|
# rich_text_preformatted renders monospace; used for code fences + tables.
|
|
return {
|
|
"type": "rich_text",
|
|
"elements": [
|
|
{
|
|
"type": "rich_text_preformatted",
|
|
"elements": _nonempty_elements([{"type": "text", "text": text.rstrip("\n")}]),
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
def _quote_block(lines: List[str]) -> Block:
|
|
section_children: List[Dict[str, Any]] = []
|
|
for i, ln in enumerate(lines):
|
|
if i:
|
|
section_children.append({"type": "text", "text": "\n"})
|
|
section_children.extend(_inline_elements(ln))
|
|
return {
|
|
"type": "rich_text",
|
|
"elements": [{"type": "rich_text_quote", "elements": _nonempty_elements(section_children)}],
|
|
}
|
|
|
|
|
|
def _list_block(items: List[Tuple[int, bool, str]]) -> Block:
|
|
"""Build ONE rich_text block from consecutive list items.
|
|
|
|
``items`` is a list of ``(indent, ordered, text)``. Each contiguous run
|
|
sharing the same (indent, ordered) becomes a ``rich_text_list`` element;
|
|
indentation changes start a new element, which is how Slack renders true
|
|
nesting.
|
|
"""
|
|
elements: List[Dict[str, Any]] = []
|
|
cur: Optional[Dict[str, Any]] = None
|
|
cur_key: Optional[Tuple[int, bool]] = None
|
|
for indent, ordered, text in items:
|
|
key = (indent, ordered)
|
|
if key != cur_key:
|
|
cur = {
|
|
"type": "rich_text_list",
|
|
"style": "ordered" if ordered else "bullet",
|
|
"indent": indent,
|
|
"elements": [],
|
|
}
|
|
elements.append(cur)
|
|
cur_key = key
|
|
if cur is None:
|
|
# Defensive: should never happen (first iteration always enters
|
|
# the ``if key != cur_key`` block above), but guard explicitly
|
|
# so ``python -O`` doesn't silently drop the check.
|
|
continue
|
|
cur["elements"].append(
|
|
{"type": "rich_text_section", "elements": _nonempty_elements(_inline_elements(text))}
|
|
)
|
|
return {"type": "rich_text", "elements": elements}
|
|
|
|
|
|
def _section_block(text: str) -> Block:
|
|
return {"type": "section", "text": {"type": "mrkdwn", "text": text}}
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# 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.
|
|
|
|
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": _nonempty_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)
|
|
# Slack requires every provided ``column_settings`` entry to be an object.
|
|
# Missing trailing entries inherit defaults, so only emit settings through
|
|
# the last non-default alignment. Earlier default-left placeholders still
|
|
# need explicit valid objects to preserve positional alignment.
|
|
last_non_default = -1
|
|
for c in range(min(ncols, MAX_TABLE_COLS)):
|
|
align = aligns[c] if c < len(aligns) else "left"
|
|
if align != "left":
|
|
last_non_default = c
|
|
column_settings: List[Dict[str, Any]] = []
|
|
for c in range(last_non_default + 1):
|
|
align = aligns[c] if c < len(aligns) else "left"
|
|
column_settings.append({"align": align})
|
|
|
|
block: Block = {
|
|
"type": "table",
|
|
"rows": [[_rich_text_cell(cell) for cell in row] for row in parsed],
|
|
}
|
|
if 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 (fallback)."""
|
|
parsed: List[List[str]] = []
|
|
for r in rows:
|
|
cells = _split_row(r)
|
|
parsed.append(cells)
|
|
if not parsed:
|
|
return "\n".join(rows)
|
|
ncols = max(len(r) for r in parsed)
|
|
for r in parsed:
|
|
r.extend([""] * (ncols - len(r)))
|
|
widths = [max(len(r[c]) for r in parsed) for c in range(ncols)]
|
|
out_lines = []
|
|
for ri, r in enumerate(parsed):
|
|
line = " | ".join(r[c].ljust(widths[c]) for c in range(ncols))
|
|
out_lines.append(line.rstrip())
|
|
if ri == 0: # header underline
|
|
out_lines.append("-+-".join("-" * widths[c] for c in range(ncols)))
|
|
return "\n".join(out_lines)
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Public entry point
|
|
# ----------------------------------------------------------------------------
|
|
|
|
|
|
def render_blocks(
|
|
markdown: str,
|
|
mrkdwn_fn=None,
|
|
) -> Optional[List[Block]]:
|
|
"""Convert agent markdown to a Slack Block Kit ``blocks`` list.
|
|
|
|
Args:
|
|
markdown: The agent's response text (standard markdown).
|
|
mrkdwn_fn: Optional callable converting a markdown paragraph to Slack
|
|
mrkdwn for ``section`` blocks (the adapter passes
|
|
``format_message``). When ``None``, the raw paragraph text is used.
|
|
|
|
Returns:
|
|
A list of Block Kit block dicts, or ``None`` when the content is empty,
|
|
exceeds Slack's structural limits, or hits an unexpected shape — the
|
|
caller then falls back to the flat ``text`` payload. Never raises.
|
|
"""
|
|
if not markdown or not markdown.strip():
|
|
return None
|
|
|
|
fmt = mrkdwn_fn or (lambda s: s)
|
|
|
|
try:
|
|
blocks: List[Block] = []
|
|
lines = markdown.replace("\r\n", "\n").split("\n")
|
|
i = 0
|
|
n = len(lines)
|
|
para: List[str] = []
|
|
|
|
def flush_para() -> None:
|
|
if not para:
|
|
return
|
|
text = "\n".join(para).strip()
|
|
para.clear()
|
|
if not text:
|
|
return
|
|
rendered = fmt(text)
|
|
# Split oversized sections on the 3000-char limit.
|
|
for chunk in _split_text(rendered, MAX_SECTION_TEXT):
|
|
blocks.append(_section_block(chunk))
|
|
|
|
while i < n:
|
|
line = lines[i]
|
|
|
|
# Blank line: paragraph boundary
|
|
if not line.strip():
|
|
flush_para()
|
|
i += 1
|
|
continue
|
|
|
|
# Fenced code block
|
|
fence = _FENCE_RE.match(line)
|
|
if fence:
|
|
flush_para()
|
|
marker = fence.group(1)
|
|
body: List[str] = []
|
|
i += 1
|
|
while i < n and not lines[i].lstrip().startswith(marker):
|
|
body.append(lines[i])
|
|
i += 1
|
|
i += 1 # consume closing fence
|
|
blocks.append(_preformatted_block("\n".join(body)))
|
|
continue
|
|
|
|
# Horizontal rule → divider
|
|
if _HR_RE.match(line):
|
|
flush_para()
|
|
blocks.append(_divider_block())
|
|
i += 1
|
|
continue
|
|
|
|
# ATX header
|
|
hm = _HEADER_RE.match(line)
|
|
if hm:
|
|
flush_para()
|
|
header = _header_block(hm.group(2))
|
|
if header is not None:
|
|
blocks.append(header)
|
|
i += 1
|
|
continue
|
|
|
|
# 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()
|
|
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
|
|
# 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
|
|
if _QUOTE_RE.match(line):
|
|
flush_para()
|
|
qlines: List[str] = []
|
|
while i < n:
|
|
qm = _QUOTE_RE.match(lines[i])
|
|
if not qm:
|
|
break
|
|
qlines.append(qm.group(1))
|
|
i += 1
|
|
blocks.append(_quote_block(qlines))
|
|
continue
|
|
|
|
# List group (bullets + ordered, with nesting)
|
|
if _is_list_line(line):
|
|
flush_para()
|
|
items: List[Tuple[int, bool, str]] = []
|
|
while i < n:
|
|
bm = _BULLET_RE.match(lines[i])
|
|
om = _ORDERED_RE.match(lines[i])
|
|
if bm:
|
|
items.append((_indent_level(bm.group(1)), False, bm.group(2)))
|
|
i += 1
|
|
elif om:
|
|
items.append((_indent_level(om.group(1)), True, om.group(3)))
|
|
i += 1
|
|
elif lines[i].strip() and lines[i].startswith((" ", "\t")) and items:
|
|
# continuation line of the previous item
|
|
indent, ordered, txt = items[-1]
|
|
items[-1] = (indent, ordered, txt + " " + lines[i].strip())
|
|
i += 1
|
|
elif not lines[i].strip() and items:
|
|
# Blank line inside a list run. LLM-authored ordered
|
|
# lists commonly separate items with a blank line; if
|
|
# the next non-blank line is another list item, treat
|
|
# the blank(s) as a soft separator and keep the run
|
|
# going so the items stay in one rich_text_list (Slack
|
|
# numbers each list independently, so splitting would
|
|
# restart every item at "1."). Otherwise the blank
|
|
# ends the list.
|
|
j = i + 1
|
|
while j < n and not lines[j].strip():
|
|
j += 1
|
|
if j < n and _is_list_line(lines[j]):
|
|
i = j
|
|
else:
|
|
break
|
|
else:
|
|
break
|
|
blocks.append(_list_block(items))
|
|
continue
|
|
|
|
# Default: accumulate into a paragraph
|
|
para.append(line)
|
|
i += 1
|
|
|
|
flush_para()
|
|
|
|
if not blocks:
|
|
return None
|
|
if len(blocks) > MAX_BLOCKS:
|
|
# Too structurally complex to express safely — let the caller fall
|
|
# back to plain text rather than truncating and losing content.
|
|
return None
|
|
return blocks
|
|
except Exception:
|
|
# Never let a rendering bug drop a message.
|
|
return None
|
|
|
|
|
|
def _split_text(text: str, limit: int) -> List[str]:
|
|
"""Split ``text`` into <= ``limit``-char chunks on line, then hard, boundaries."""
|
|
if len(text) <= limit:
|
|
return [text]
|
|
out: List[str] = []
|
|
remaining = text
|
|
while len(remaining) > limit:
|
|
cut = remaining.rfind("\n", 0, limit)
|
|
if cut <= 0:
|
|
cut = limit
|
|
out.append(remaining[:cut])
|
|
remaining = remaining[cut:].lstrip("\n")
|
|
if remaining:
|
|
out.append(remaining)
|
|
return out
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Outbound payload boundary — last-resort clamp before the Slack API
|
|
# ----------------------------------------------------------------------------
|
|
|
|
|
|
def _clamp_text_obj(text_obj: Dict[str, Any], limit: int) -> Dict[str, Any]:
|
|
"""Return ``text_obj`` with its ``text`` clamped to ``limit`` chars."""
|
|
txt = text_obj.get("text") or ""
|
|
if len(txt) <= limit:
|
|
return text_obj
|
|
clamped = dict(text_obj)
|
|
clamped["text"] = txt[: limit - 1].rstrip() + "…"
|
|
return clamped
|
|
|
|
|
|
def sanitize_blocks(blocks: Optional[List[Block]]) -> Optional[List[Block]]:
|
|
"""Clamp an outbound ``blocks`` payload to Slack's hard limits.
|
|
|
|
Defensive boundary applied wherever the adapter attaches ``blocks`` to
|
|
``chat.postMessage`` / ``chat.update``. One oversized or malformed block
|
|
fails the WHOLE call with ``invalid_blocks`` — approval cards then never
|
|
update and messages silently drop — so instead of trusting every builder,
|
|
the payload is normalized just before the API call:
|
|
|
|
* ``section`` / ``context`` text objects are truncated to the 3000-char
|
|
cap with an ellipsis (Slack HTML-escapes ``< > &`` on storage, so text
|
|
echoed back through interaction payloads can exceed the limit that the
|
|
send path originally budgeted for — see #53693 / #62054).
|
|
* ``header`` text is truncated to its 150-char cap.
|
|
* Empty blocks (no text / no elements / no rows) are dropped — Slack
|
|
rejects zero-length text objects and empty element lists.
|
|
* ``table.column_settings`` entries must all be objects; ``null`` entries
|
|
(emitted by older renderers, per the "use null to skip" misreading of
|
|
the schema) are replaced with ``{}`` and default trailing entries are
|
|
trimmed (#56615).
|
|
* The payload is capped at Slack's 50-block maximum.
|
|
|
|
Returns the sanitized list, or ``None`` when nothing valid remains — the
|
|
caller then sends the plain ``text`` fallback alone. Never raises.
|
|
"""
|
|
if not blocks:
|
|
return None
|
|
try:
|
|
out: List[Block] = []
|
|
for block in blocks:
|
|
if not isinstance(block, dict) or not block.get("type"):
|
|
continue
|
|
btype = block["type"]
|
|
|
|
if btype == "section":
|
|
text_obj = block.get("text")
|
|
has_body = bool(block.get("fields")) or bool(block.get("accessory"))
|
|
if isinstance(text_obj, dict):
|
|
if not (text_obj.get("text") or "").strip() and not has_body:
|
|
continue
|
|
clamped = _clamp_text_obj(text_obj, MAX_SECTION_TEXT)
|
|
if clamped is not text_obj:
|
|
block = dict(block)
|
|
block["text"] = clamped
|
|
elif not has_body:
|
|
continue
|
|
|
|
elif btype == "header":
|
|
text_obj = block.get("text")
|
|
if not isinstance(text_obj, dict) or not (text_obj.get("text") or "").strip():
|
|
continue
|
|
clamped = _clamp_text_obj(text_obj, MAX_HEADER_TEXT)
|
|
if clamped is not text_obj:
|
|
block = dict(block)
|
|
block["text"] = clamped
|
|
|
|
elif btype == "context":
|
|
elements = block.get("elements") or []
|
|
if not elements:
|
|
continue
|
|
clamped_els = [
|
|
_clamp_text_obj(el, MAX_SECTION_TEXT)
|
|
if isinstance(el, dict) and el.get("type") in ("mrkdwn", "plain_text")
|
|
else el
|
|
for el in elements
|
|
]
|
|
if any(c is not e for c, e in zip(clamped_els, elements)):
|
|
block = dict(block)
|
|
block["elements"] = clamped_els
|
|
|
|
elif btype in ("rich_text", "actions", "context_actions"):
|
|
if not block.get("elements"):
|
|
continue
|
|
|
|
elif btype == "table":
|
|
if not block.get("rows"):
|
|
continue
|
|
settings = block.get("column_settings")
|
|
if isinstance(settings, list) and any(
|
|
not isinstance(cs, dict) for cs in settings
|
|
):
|
|
fixed = [cs if isinstance(cs, dict) else {} for cs in settings]
|
|
while fixed and not fixed[-1]:
|
|
fixed.pop()
|
|
block = dict(block)
|
|
if fixed:
|
|
block["column_settings"] = fixed
|
|
else:
|
|
block.pop("column_settings", None)
|
|
|
|
out.append(block)
|
|
|
|
if not out:
|
|
return None
|
|
return out[:MAX_BLOCKS]
|
|
except Exception:
|
|
# A sanitizer bug must never take down the send path.
|
|
return None
|