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:
@@ -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:
|
||||
|
||||
145
tests/gateway/test_slack_pasted_tables.py
Normal file
145
tests/gateway/test_slack_pasted_tables.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Slack pasted-table recovery (ported from qwibitai/nanoclaw#3666).
|
||||
|
||||
Slack represents a pasted table as ``table`` blocks — usually nested inside
|
||||
``attachments[].blocks[]``, sometimes top-level. The table appears in neither
|
||||
the message ``text`` nor the file list, so before this port the agent
|
||||
received the sentence before the table and nothing else.
|
||||
"""
|
||||
|
||||
from plugins.platforms.slack.adapter import (
|
||||
_SLACK_TABLE_MAX_CHARS,
|
||||
_collect_slack_table_cell_text,
|
||||
_extract_additional_text_from_slack_blocks,
|
||||
_extract_text_from_slack_attachments,
|
||||
_extract_text_from_slack_blocks,
|
||||
_render_slack_table_block,
|
||||
_serialize_slack_blocks_for_agent,
|
||||
)
|
||||
|
||||
|
||||
def _raw_cell(text: str) -> dict:
|
||||
return {"type": "raw_text", "text": text}
|
||||
|
||||
|
||||
def _rich_cell(text: str, bold: bool = False) -> dict:
|
||||
style = {"bold": True} if bold else {}
|
||||
return {
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_section",
|
||||
"elements": [{"type": "text", "text": text, "style": style}],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _table_block(rows) -> dict:
|
||||
return {"type": "table", "rows": rows}
|
||||
|
||||
|
||||
class TestCellText:
|
||||
def test_raw_text_cell(self):
|
||||
assert _collect_slack_table_cell_text(_raw_cell("Name")) == "Name"
|
||||
|
||||
def test_rich_text_cell_collects_leaves(self):
|
||||
assert _collect_slack_table_cell_text(_rich_cell("Bold header", bold=True)) == (
|
||||
"Bold header"
|
||||
)
|
||||
|
||||
def test_non_dict_cell_is_empty(self):
|
||||
assert _collect_slack_table_cell_text("stray") == ""
|
||||
assert _collect_slack_table_cell_text(None) == ""
|
||||
|
||||
def test_list_of_nodes(self):
|
||||
cells = [_raw_cell("a"), _raw_cell("b")]
|
||||
assert _collect_slack_table_cell_text(cells) == "a b"
|
||||
|
||||
|
||||
class TestRenderTableBlock:
|
||||
def test_projects_rows_pipe_separated(self):
|
||||
block = _table_block(
|
||||
[
|
||||
[_raw_cell("Name"), _raw_cell("Status")],
|
||||
[_raw_cell("Hermes"), _rich_cell("ok")],
|
||||
]
|
||||
)
|
||||
assert _render_slack_table_block(block) == "Name | Status\nHermes | ok"
|
||||
|
||||
def test_no_rows_returns_empty(self):
|
||||
assert _render_slack_table_block({"type": "table"}) == ""
|
||||
assert _render_slack_table_block({"type": "table", "rows": "bad"}) == ""
|
||||
assert _render_slack_table_block(_table_block([])) == ""
|
||||
|
||||
def test_malformed_row_skipped(self):
|
||||
block = _table_block(["not-a-row", [_raw_cell("x"), _raw_cell("y")]])
|
||||
assert _render_slack_table_block(block) == "x | y"
|
||||
|
||||
def test_empty_rows_dropped(self):
|
||||
block = _table_block([[_raw_cell(""), _raw_cell("")], [_raw_cell("k")]])
|
||||
assert _render_slack_table_block(block) == "k"
|
||||
|
||||
def test_truncation_cap(self):
|
||||
big = _table_block([[_raw_cell("x" * 5000)] for _ in range(10)])
|
||||
out = _render_slack_table_block(big)
|
||||
assert out.endswith("[table truncated]")
|
||||
assert len(out) <= _SLACK_TABLE_MAX_CHARS
|
||||
|
||||
|
||||
class TestBlockExtraction:
|
||||
def test_top_level_table_block_rendered(self):
|
||||
blocks = [_table_block([[_raw_cell("a"), _raw_cell("b")]])]
|
||||
assert _extract_text_from_slack_blocks(blocks) == "a | b"
|
||||
|
||||
def test_table_alongside_rich_text(self):
|
||||
blocks = [
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_section",
|
||||
"elements": [{"type": "text", "text": "See table:"}],
|
||||
}
|
||||
],
|
||||
},
|
||||
_table_block([[_raw_cell("k"), _raw_cell("v")]]),
|
||||
]
|
||||
out = _extract_text_from_slack_blocks(blocks)
|
||||
assert "See table:" in out
|
||||
assert "k | v" in out
|
||||
|
||||
def test_additional_text_path_surfaces_table(self):
|
||||
# The live inbound path routes top-level blocks through
|
||||
# _extract_additional_text_from_slack_blocks with the flat text as
|
||||
# the dedupe reference — the table must survive that dedupe.
|
||||
blocks = [_table_block([[_raw_cell("col1"), _raw_cell("col2")]])]
|
||||
out = _extract_additional_text_from_slack_blocks(blocks, "intro sentence")
|
||||
assert "col1 | col2" in out
|
||||
|
||||
|
||||
class TestAttachmentNestedTable:
|
||||
def test_attachment_blocks_table_recovered(self):
|
||||
# The real-world shape: pasted table arrives as
|
||||
# attachments[].blocks[] with type "table" and nothing in text.
|
||||
attachments = [
|
||||
{"blocks": [_table_block([[_raw_cell("Item"), _raw_cell("Qty")]])]}
|
||||
]
|
||||
out = _extract_text_from_slack_attachments(attachments)
|
||||
assert "Item | Qty" in out
|
||||
|
||||
|
||||
class TestSerializerSkipsTables:
|
||||
def test_table_block_not_json_dumped(self):
|
||||
# Table blocks are rendered as text; the JSON serializer must not
|
||||
# emit an empty {"type": "table"} husk for them.
|
||||
blocks = [_table_block([[_raw_cell("a")]])]
|
||||
assert _serialize_slack_blocks_for_agent(blocks) == ""
|
||||
|
||||
def test_other_blocks_still_serialized(self):
|
||||
blocks = [
|
||||
_table_block([[_raw_cell("a")]]),
|
||||
{"type": "section", "text": {"type": "mrkdwn", "text": "hello"}},
|
||||
]
|
||||
out = _serialize_slack_blocks_for_agent(blocks)
|
||||
assert "section" in out
|
||||
assert '"table"' not in out
|
||||
Reference in New Issue
Block a user