feat(slack): pasted tables now reach the agent instead of silently vanishing

Port from qwibitai/nanoclaw#3666: Slack represents a pasted table as
'table' blocks — usually nested in attachments[].blocks[], sometimes
top-level. They appear in neither the message text nor the file list,
so the agent received the sentence before the table and nothing else.

- _render_slack_table_block(): projects rows as 'cell | cell' lines,
  collecting text leaves from raw_text/rich_text cell subtrees; capped
  at 20k chars with a visible '[table truncated]' marker.
- Wired into all three ingestion paths: _extract_text_from_slack_blocks
  (thread history + attachment-nested blocks), the live inbound
  attachment loop, and _extract_additional_text_from_slack_blocks
  (top-level blocks on live messages).
- _serialize_slack_blocks_for_agent skips 'table' blocks — the
  allowlist drops 'rows', so it only emitted an empty husk.
This commit is contained in:
Teknium
2026-08-30 17:17:02 -07:00
parent a2b1c4cf44
commit a74e0155b6
2 changed files with 235 additions and 4 deletions

View File

@@ -511,11 +511,81 @@ def _extract_text_from_slack_blocks(blocks: list) -> str:
_append_line(_render_inline_elements([elem]), quote_depth, bullet)
for block in blocks:
if (block or {}).get("type") == "rich_text":
block_type = (block or {}).get("type")
if block_type == "rich_text":
_walk_elements(block.get("elements", []))
elif block_type == "table":
table_text = _render_slack_table_block(block)
if table_text:
parts.append(table_text)
return "\n".join(parts)
#: Cap on a single rendered pasted-table projection. Slack lets a user paste
#: arbitrarily large spreadsheets; the projection must not grow unboundedly
#: with whatever was pasted. 20k chars comfortably covers real tables while
#: staying well under Slack's own 40k message ceiling.
_SLACK_TABLE_MAX_CHARS = 20_000
def _collect_slack_table_cell_text(value: Any) -> str:
"""Collect the text leaves in a Slack table cell's raw/rich-text subtree.
Cells arrive as ``raw_text`` objects or nested rich-text trees depending
on formatting; walking every ``text`` leaf keeps formatted cells intact
without enumerating Slack's cell schema.
"""
parts: list[str] = []
def _visit(node: Any) -> None:
if isinstance(node, list):
for item in node:
_visit(item)
return
if not isinstance(node, dict):
return
text = node.get("text")
if isinstance(text, str):
parts.append(text)
for child in node.values():
_visit(child)
_visit(value)
return " ".join(p for p in parts if p).strip()
def _render_slack_table_block(
block: dict, max_chars: int = _SLACK_TABLE_MAX_CHARS
) -> str:
"""Render a Slack ``table`` block as ``cell | cell | cell`` lines.
Slack represents a **pasted table** as ``blocks[]`` entries of type
``table`` (usually nested inside ``attachments[].blocks[]``). The table
appears in neither the message ``text`` nor the file list, so without
this projection the agent receives the sentence before the table and
nothing else — the table silently does not exist.
Ported from qwibitai/nanoclaw#3666 (``slack-raw-text.ts``).
"""
rows = block.get("rows") if isinstance(block, dict) else None
if not isinstance(rows, list):
return ""
lines: list[str] = []
for row in rows:
if not isinstance(row, list):
continue
rendered = " | ".join(_collect_slack_table_cell_text(cell) for cell in row)
if rendered.strip(" |"):
lines.append(rendered)
text = "\n".join(lines)
if not text:
return ""
if len(text) > max_chars:
text = text[: max_chars - 20].rstrip() + "\n[table truncated]"
return text
def _extract_text_from_slack_attachments(attachments: list) -> str:
"""Extract readable text from legacy ``attachments`` (alert/CI bots post empty ``text``).
Prefers structured fields; uses ``fallback`` only when nothing else exists."""
@@ -608,7 +678,16 @@ def _extract_additional_text_from_slack_blocks(
for match in _SLACK_FENCED_CODE_RE.finditer(primary_text or "")}
parts: list[str] = []
for block in blocks or []:
if (block or {}).get("type") != "rich_text":
block_type = (block or {}).get("type")
if block_type == "table":
# Pasted tables (qwibitai/nanoclaw#3666): a top-level ``table``
# block never appears in the plain text, and the JSON serializer
# drops ``rows``, so this is the only path that surfaces it.
table_text = _render_slack_table_block(block)
if table_text:
parts.append(table_text)
continue
if block_type != "rich_text":
continue
for element in block.get("elements", []):
element_type = element.get("type", "")
@@ -638,8 +717,10 @@ _BLOCK_RECURSIVE_KEYS = frozenset(
def _serialize_slack_blocks_for_agent(blocks: list, max_chars: int = 6000) -> str:
"""Compact, redacted JSON view of non-``rich_text`` Block Kit blocks.
``rich_text`` is already rendered into the message text; dumping it here would repeat the
author's words with every ``url`` stripped by the allowlist."""
inspectable = [block for block in (blocks or []) if (block or {}).get("type") != "rich_text"]
author's words with every ``url`` stripped by the allowlist. ``table`` is rendered by
:func:`_render_slack_table_block`; the allowlist drops ``rows`` so it would dump as a husk."""
inspectable = [
block for block in (blocks or []) if (block or {}).get("type") not in ("rich_text", "table")]
if not inspectable:
return ""
def _sanitize(value):
@@ -3983,6 +4064,11 @@ class SlackAdapter(BasePlatformAdapter):
body = (att_text or att_fallback or "").strip()
if len(body) > 500:
body = body[:497] + "..."
# Pasted tables arrive as ``table`` blocks in ``attachments[].blocks[]``, absent from
# ``text``/``fallback``/files; without this the agent sees only the sentence before them.
nested_text = _extract_text_from_slack_blocks(att.get("blocks") or [])
if nested_text and nested_text not in body:
body = f"{body}\n{nested_text}".strip() if body else nested_text
if header:
section = f"{header}\n {body}" if body else header
elif body: