feat(slack): opt-in Block Kit rendering for agent messages
Add platforms.slack.extra.rich_blocks (default off). When enabled, the final agent message is sent as Slack Block Kit blocks — section headers, dividers, and true nested lists via rich_text — instead of flat mrkdwn. - New plugins/platforms/slack/block_kit.py: pure markdown->blocks renderer (headers, dividers, nested ordered/bullet lists, blockquotes, fenced code; pipe-tables as aligned monospace since Block Kit has no robust table block). Enforces Slack's 50-block / 3000-char section limits and returns None to fall back to plain text on empty/oversized/unexpected input. Never raises. - adapter.send(): render blocks on the single-chunk primary message; a text= fallback is ALWAYS sent alongside (notifications/accessibility). - adapter.edit_message(): blocks only on finalize=True, so intermediate streaming edits stay plain mrkdwn (no per-flush block re-derivation). - Docs (EN + zh-Hans) + config example. Send-side only: no app reinstall. Tests: pure-renderer unit suite + adapter integration suite (blocks present when on, plain text when off, text fallback always set, finalize gating, multi-chunk fallback). Prove-failed against a stubbed renderer.
This commit is contained in:
parent
2e8748ed22
commit
b080b93ad8
6 changed files with 707 additions and 5 deletions
|
|
@ -54,6 +54,11 @@ from gateway.platforms.base import (
|
|||
cache_video_from_bytes,
|
||||
)
|
||||
|
||||
try: # sibling module; support both package and flat plugin-dir import
|
||||
from .block_kit import render_blocks
|
||||
except ImportError: # pragma: no cover - plugin loaded outside package context
|
||||
from block_kit import render_blocks # type: ignore
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -1372,12 +1377,21 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
# Controlled via platform config: gateway.slack.reply_broadcast
|
||||
broadcast = self.config.extra.get("reply_broadcast", False)
|
||||
|
||||
# Block Kit (opt-in): render the primary message as structured
|
||||
# blocks. Only applied to a single-chunk message — a >39k response
|
||||
# that had to be split is pathological for Block Kit's 50-block /
|
||||
# 3000-char limits, so those fall back to plain text. The ``text``
|
||||
# field is always kept as the notification/accessibility fallback.
|
||||
blocks = self._maybe_blocks(content) if len(chunks) == 1 else None
|
||||
|
||||
for i, chunk in enumerate(chunks):
|
||||
kwargs = {
|
||||
"channel": chat_id,
|
||||
"text": chunk,
|
||||
"mrkdwn": True,
|
||||
}
|
||||
if blocks and i == 0:
|
||||
kwargs["blocks"] = blocks
|
||||
if thread_ts:
|
||||
kwargs["thread_ts"] = thread_ts
|
||||
# Only broadcast the first chunk of the first reply
|
||||
|
|
@ -1462,11 +1476,20 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
return SendResult(success=False, error="Not connected")
|
||||
try:
|
||||
formatted = self.format_message(content)
|
||||
await self._get_client(chat_id).chat_update(
|
||||
channel=chat_id,
|
||||
ts=message_id,
|
||||
text=formatted,
|
||||
)
|
||||
update_kwargs: Dict[str, Any] = {
|
||||
"channel": chat_id,
|
||||
"ts": message_id,
|
||||
"text": formatted,
|
||||
}
|
||||
# Only render Block Kit on the FINAL edit. Intermediate streaming
|
||||
# edits stay plain mrkdwn — re-deriving a full block layout on every
|
||||
# progressive flush would be wasteful and jittery. ``text`` is kept
|
||||
# as the fallback either way.
|
||||
if finalize:
|
||||
blocks = self._maybe_blocks(content)
|
||||
if blocks:
|
||||
update_kwargs["blocks"] = blocks
|
||||
await self._get_client(chat_id).chat_update(**update_kwargs)
|
||||
if finalize:
|
||||
await self.stop_typing(chat_id)
|
||||
return SendResult(success=True, message_id=message_id)
|
||||
|
|
@ -1782,6 +1805,36 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
|
||||
# ----- Markdown → mrkdwn conversion -----
|
||||
|
||||
def _rich_blocks_enabled(self) -> bool:
|
||||
"""Whether to render outbound agent messages as Slack Block Kit blocks.
|
||||
|
||||
Opt-in via ``platforms.slack.extra.rich_blocks`` (config.yaml). Default
|
||||
off: messages continue to go out as flat mrkdwn ``text``. Enabling it
|
||||
renders the *final* agent message with real structural primitives
|
||||
(headers, dividers, true nested lists via ``rich_text``); tables are
|
||||
rendered as aligned monospace (Block Kit has no robust table block).
|
||||
"""
|
||||
raw = self.config.extra.get("rich_blocks")
|
||||
if raw is None:
|
||||
return False
|
||||
return str(raw).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
def _maybe_blocks(self, content: str) -> Optional[list]:
|
||||
"""Render ``content`` to Block Kit blocks when the feature is enabled.
|
||||
|
||||
Returns ``None`` when rich blocks are disabled, or when the renderer
|
||||
declines (empty / too complex / unexpected shape) — the caller then
|
||||
falls back to the plain ``text`` payload. A ``text`` fallback is ALWAYS
|
||||
sent alongside blocks, so this can safely return ``None`` at any time.
|
||||
"""
|
||||
if not self._rich_blocks_enabled():
|
||||
return None
|
||||
try:
|
||||
return render_blocks(content, mrkdwn_fn=self.format_message)
|
||||
except Exception: # pragma: no cover - renderer already guards itself
|
||||
logger.debug("[Slack] block render failed; using plain text", exc_info=True)
|
||||
return None
|
||||
|
||||
def format_message(self, content: str) -> str:
|
||||
"""Convert standard markdown to Slack mrkdwn format.
|
||||
|
||||
|
|
|
|||
399
plugins/platforms/slack/block_kit.py
Normal file
399
plugins/platforms/slack/block_kit.py
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
"""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):
|
||||
|
||||
* **Block Kit has no robust table primitive.** The newer ``table`` block is
|
||||
limited and fragile, so markdown pipe-tables are rendered as monospace
|
||||
``rich_text_preformatted`` — the same thing a human would paste today, just
|
||||
aligned. Proper ``table`` blocks are a future iteration (see the PR's Open
|
||||
Questions), not a v1 gate.
|
||||
* **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
|
||||
|
||||
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 _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 _header_block(text: str) -> Block:
|
||||
# header blocks are plain_text only, 150 char cap.
|
||||
clean = re.sub(r"[*_~`]", "", text).strip()
|
||||
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": [{"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": 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
|
||||
assert cur is not None
|
||||
cur["elements"].append(
|
||||
{"type": "rich_text_section", "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 (best-effort monospace fallback)
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _render_table(rows: List[str]) -> str:
|
||||
"""Render markdown pipe-table rows as aligned monospace text."""
|
||||
parsed: List[List[str]] = []
|
||||
for r in rows:
|
||||
cells = [c.strip() for c in r.strip().strip("|").split("|")]
|
||||
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()
|
||||
blocks.append(_header_block(hm.group(2)))
|
||||
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()
|
||||
trows = [line]
|
||||
i += 2 # skip header + separator
|
||||
while i < n and "|" in lines[i] and lines[i].strip():
|
||||
trows.append(lines[i])
|
||||
i += 1
|
||||
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 _BULLET_RE.match(line) or _ORDERED_RE.match(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
|
||||
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
|
||||
130
tests/gateway/test_slack_block_kit.py
Normal file
130
tests/gateway/test_slack_block_kit.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"""Unit tests for the Slack Block Kit renderer (pure function, no adapter)."""
|
||||
|
||||
from plugins.platforms.slack.block_kit import (
|
||||
MAX_BLOCKS,
|
||||
MAX_HEADER_TEXT,
|
||||
MAX_SECTION_TEXT,
|
||||
render_blocks,
|
||||
)
|
||||
|
||||
|
||||
def _types(blocks):
|
||||
return [b["type"] for b in blocks]
|
||||
|
||||
|
||||
class TestRenderBlocksBasics:
|
||||
def test_empty_returns_none(self):
|
||||
assert render_blocks("") is None
|
||||
assert render_blocks(" \n ") is None
|
||||
|
||||
def test_plain_paragraph_is_section(self):
|
||||
blocks = render_blocks("just a plain sentence")
|
||||
assert blocks is not None
|
||||
assert len(blocks) == 1
|
||||
assert blocks[0]["type"] == "section"
|
||||
assert blocks[0]["text"]["type"] == "mrkdwn"
|
||||
|
||||
def test_header_becomes_header_block(self):
|
||||
blocks = render_blocks("# Title")
|
||||
assert blocks[0]["type"] == "header"
|
||||
assert blocks[0]["text"]["type"] == "plain_text"
|
||||
assert blocks[0]["text"]["text"] == "Title"
|
||||
|
||||
def test_header_strips_markup_and_caps_length(self):
|
||||
long = "#" + " " + "x" * 300
|
||||
blocks = render_blocks(long)
|
||||
assert blocks[0]["type"] == "header"
|
||||
assert len(blocks[0]["text"]["text"]) <= MAX_HEADER_TEXT
|
||||
|
||||
def test_horizontal_rule_becomes_divider(self):
|
||||
blocks = render_blocks("above\n\n---\n\nbelow")
|
||||
assert "divider" in _types(blocks)
|
||||
|
||||
def test_fenced_code_becomes_preformatted(self):
|
||||
md = "```python\ndef f():\n return 1\n```"
|
||||
blocks = render_blocks(md)
|
||||
assert len(blocks) == 1
|
||||
assert blocks[0]["type"] == "rich_text"
|
||||
assert blocks[0]["elements"][0]["type"] == "rich_text_preformatted"
|
||||
|
||||
|
||||
class TestNestedLists:
|
||||
def test_nested_bullets_produce_increasing_indent(self):
|
||||
md = "- a\n - b\n - c"
|
||||
blocks = render_blocks(md)
|
||||
rich = [b for b in blocks if b["type"] == "rich_text"][0]
|
||||
indents = [e["indent"] for e in rich["elements"] if e["type"] == "rich_text_list"]
|
||||
# true nesting: indent levels must strictly increase across the run
|
||||
assert indents == sorted(indents)
|
||||
assert max(indents) >= 2
|
||||
assert min(indents) == 0
|
||||
|
||||
def test_ordered_and_bullet_styles_distinguished(self):
|
||||
md = "1. first\n2. second\n\n- bullet"
|
||||
blocks = render_blocks(md)
|
||||
styles = []
|
||||
for b in blocks:
|
||||
if b["type"] == "rich_text":
|
||||
for e in b["elements"]:
|
||||
if e["type"] == "rich_text_list":
|
||||
styles.append(e["style"])
|
||||
assert "ordered" in styles
|
||||
assert "bullet" in styles
|
||||
|
||||
|
||||
class TestInlineFormatting:
|
||||
def test_link_becomes_link_element(self):
|
||||
blocks = render_blocks("see [docs](https://example.com/x) now")
|
||||
# link lives in a section (paragraph) — but a bulleted link is a
|
||||
# rich_text link element; assert the URL survives somewhere.
|
||||
blob = str(blocks)
|
||||
assert "https://example.com/x" in blob
|
||||
|
||||
def test_bulleted_bold_is_styled(self):
|
||||
blocks = render_blocks("- this is **bold** text")
|
||||
rich = [b for b in blocks if b["type"] == "rich_text"][0]
|
||||
section = rich["elements"][0]["elements"][0]
|
||||
styled = [
|
||||
el for el in section["elements"]
|
||||
if el.get("style", {}).get("bold")
|
||||
]
|
||||
assert styled, "expected a bold-styled text element in the list item"
|
||||
|
||||
|
||||
class TestTables:
|
||||
def test_pipe_table_renders_preformatted(self):
|
||||
md = (
|
||||
"| Name | Status |\n"
|
||||
"|------|--------|\n"
|
||||
"| a | ok |\n"
|
||||
"| b | fail |"
|
||||
)
|
||||
blocks = render_blocks(md)
|
||||
assert len(blocks) == 1
|
||||
assert blocks[0]["type"] == "rich_text"
|
||||
pre = blocks[0]["elements"][0]
|
||||
assert pre["type"] == "rich_text_preformatted"
|
||||
text = pre["elements"][0]["text"]
|
||||
# header cell values preserved and column aligned
|
||||
assert "Name" in text and "Status" in text
|
||||
assert "fail" in text
|
||||
|
||||
|
||||
class TestLimits:
|
||||
def test_oversized_section_is_split_under_limit(self):
|
||||
big = "word " * 2000 # ~10000 chars, single paragraph
|
||||
blocks = render_blocks(big)
|
||||
assert blocks is not None
|
||||
for b in blocks:
|
||||
if b["type"] == "section":
|
||||
assert len(b["text"]["text"]) <= MAX_SECTION_TEXT
|
||||
|
||||
def test_too_many_blocks_returns_none(self):
|
||||
# 60 dividers => 60 blocks > MAX_BLOCKS => decline (caller uses text)
|
||||
md = "\n\n".join(["---"] * (MAX_BLOCKS + 10))
|
||||
assert render_blocks(md) is None
|
||||
|
||||
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)
|
||||
102
tests/gateway/test_slack_block_kit_adapter.py
Normal file
102
tests/gateway/test_slack_block_kit_adapter.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""Integration tests: SlackAdapter wiring of Block Kit into send paths.
|
||||
|
||||
Verifies the opt-in behaviour contract:
|
||||
* rich_blocks off (default) => no ``blocks`` kwarg, plain ``text`` only
|
||||
* rich_blocks on => ``blocks`` present AND ``text`` fallback set
|
||||
* edit_message: blocks only on finalize (streaming edits stay plain)
|
||||
* multi-chunk (>39k) messages fall back to plain text
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from plugins.platforms.slack.adapter import SlackAdapter
|
||||
|
||||
|
||||
def _make_adapter(extra=None):
|
||||
config = PlatformConfig(enabled=True, token="xoxb-fake", extra=extra or {})
|
||||
a = SlackAdapter(config)
|
||||
a._app = MagicMock()
|
||||
client = AsyncMock()
|
||||
client.chat_postMessage = AsyncMock(return_value={"ts": "111.222"})
|
||||
client.chat_update = AsyncMock(return_value={"ts": "111.222"})
|
||||
a._get_client = MagicMock(return_value=client)
|
||||
a.stop_typing = AsyncMock()
|
||||
a._running = True
|
||||
return a, client
|
||||
|
||||
|
||||
RICH_MD = "# Title\n\n- a\n - nested\n\n---\n\nbody text"
|
||||
|
||||
|
||||
class TestSendMessageBlocks:
|
||||
@pytest.mark.asyncio
|
||||
async def test_disabled_by_default_no_blocks(self):
|
||||
adapter, client = _make_adapter()
|
||||
await adapter.send("C1", RICH_MD)
|
||||
kwargs = client.chat_postMessage.await_args.kwargs
|
||||
assert "blocks" not in kwargs
|
||||
assert kwargs["text"] # plain text still sent
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enabled_sends_blocks_with_text_fallback(self):
|
||||
adapter, client = _make_adapter({"rich_blocks": True})
|
||||
await adapter.send("C1", RICH_MD)
|
||||
kwargs = client.chat_postMessage.await_args.kwargs
|
||||
assert "blocks" in kwargs and kwargs["blocks"]
|
||||
# text fallback is ALWAYS present alongside blocks (notifications/a11y)
|
||||
assert kwargs["text"]
|
||||
types = [b["type"] for b in kwargs["blocks"]]
|
||||
assert "header" in types
|
||||
assert "divider" in types
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enabled_but_unrenderable_falls_back_to_text(self):
|
||||
# 60 dividers -> renderer returns None -> no blocks kwarg, text stands
|
||||
adapter, client = _make_adapter({"rich_blocks": True})
|
||||
await adapter.send("C1", "\n\n".join(["---"] * 60))
|
||||
kwargs = client.chat_postMessage.await_args.kwargs
|
||||
assert "blocks" not in kwargs
|
||||
assert kwargs["text"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_string_true_coerced(self):
|
||||
adapter, client = _make_adapter({"rich_blocks": "true"})
|
||||
await adapter.send("C1", RICH_MD)
|
||||
assert "blocks" in client.chat_postMessage.await_args.kwargs
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multichunk_message_no_blocks(self):
|
||||
adapter, client = _make_adapter({"rich_blocks": True})
|
||||
huge = "word " * 20000 # well over MAX_MESSAGE_LENGTH -> chunked
|
||||
await adapter.send("C1", huge)
|
||||
# every posted chunk is plain text, none carry blocks
|
||||
for c in client.chat_postMessage.await_args_list:
|
||||
assert "blocks" not in c.kwargs
|
||||
assert c.kwargs["text"]
|
||||
|
||||
|
||||
class TestEditMessageBlocks:
|
||||
@pytest.mark.asyncio
|
||||
async def test_intermediate_edit_no_blocks(self):
|
||||
adapter, client = _make_adapter({"rich_blocks": True})
|
||||
await adapter.edit_message("C1", "111.222", RICH_MD, finalize=False)
|
||||
kwargs = client.chat_update.await_args.kwargs
|
||||
assert "blocks" not in kwargs
|
||||
assert kwargs["text"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finalize_edit_gets_blocks(self):
|
||||
adapter, client = _make_adapter({"rich_blocks": True})
|
||||
await adapter.edit_message("C1", "111.222", RICH_MD, finalize=True)
|
||||
kwargs = client.chat_update.await_args.kwargs
|
||||
assert "blocks" in kwargs and kwargs["blocks"]
|
||||
assert kwargs["text"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finalize_edit_disabled_no_blocks(self):
|
||||
adapter, client = _make_adapter() # rich_blocks off
|
||||
await adapter.edit_message("C1", "111.222", RICH_MD, finalize=True)
|
||||
assert "blocks" not in client.chat_update.await_args.kwargs
|
||||
|
|
@ -343,6 +343,14 @@ platforms:
|
|||
# (Slack's "Also send to channel" feature).
|
||||
# Only the first chunk of the first reply is broadcast.
|
||||
reply_broadcast: false
|
||||
|
||||
# Render agent messages as Slack Block Kit blocks (default: false).
|
||||
# When true, the final agent message is sent with structured blocks —
|
||||
# section headers, dividers, and true nested lists (via rich_text) —
|
||||
# instead of flat mrkdwn text. A plain-text fallback is always sent
|
||||
# alongside for notifications/accessibility. Markdown tables are
|
||||
# rendered as aligned monospace (Block Kit has no native table block).
|
||||
rich_blocks: false
|
||||
```
|
||||
|
||||
| Key | Default | Description |
|
||||
|
|
@ -350,6 +358,7 @@ platforms:
|
|||
| `platforms.slack.reply_to_mode` | `"first"` | Threading mode for multi-part messages: `"off"`, `"first"`, or `"all"` |
|
||||
| `platforms.slack.extra.reply_in_thread` | `true` | When `false`, channel messages get direct replies instead of threads. Messages inside existing threads still reply in-thread. |
|
||||
| `platforms.slack.extra.reply_broadcast` | `false` | When `true`, thread replies are also posted to the main channel. Only the first chunk is broadcast. |
|
||||
| `platforms.slack.extra.rich_blocks` | `false` | When `true`, agent messages are rendered as [Block Kit](https://docs.slack.dev/block-kit/) blocks (headers, dividers, true nested lists). A plain-text fallback is always sent. Tables render as aligned monospace. No app reinstall required — it's a send-side change only. |
|
||||
|
||||
### Session Isolation
|
||||
|
||||
|
|
|
|||
|
|
@ -298,6 +298,14 @@ platforms:
|
|||
# (Slack 的"同时发送到频道"功能)。
|
||||
# 仅广播第一条回复的第一个分块。
|
||||
reply_broadcast: false
|
||||
|
||||
# 将 Agent 消息渲染为 Slack Block Kit 区块(默认:false)。
|
||||
# 为 true 时,最终的 Agent 消息会以结构化区块发送——包括
|
||||
# 章节标题、分隔线以及真正的嵌套列表(通过 rich_text)——
|
||||
# 而非扁平的 mrkdwn 文本。同时始终附带纯文本回退内容,
|
||||
# 用于通知和无障碍访问。Markdown 表格会渲染为对齐的等宽文本
|
||||
# (Block Kit 没有原生表格区块)。
|
||||
rich_blocks: false
|
||||
```
|
||||
|
||||
| 键 | 默认值 | 描述 |
|
||||
|
|
@ -305,6 +313,7 @@ platforms:
|
|||
| `platforms.slack.reply_to_mode` | `"first"` | 多部分消息的话题模式:`"off"`、`"first"` 或 `"all"` |
|
||||
| `platforms.slack.extra.reply_in_thread` | `true` | 为 `false` 时,频道消息直接回复而非话题。已在话题中的消息仍在话题中回复。 |
|
||||
| `platforms.slack.extra.reply_broadcast` | `false` | 为 `true` 时,话题回复也会发布到主频道。仅广播第一个分块。 |
|
||||
| `platforms.slack.extra.rich_blocks` | `false` | 为 `true` 时,Agent 消息会渲染为 [Block Kit](https://docs.slack.dev/block-kit/) 区块(标题、分隔线、真正的嵌套列表)。始终附带纯文本回退。表格渲染为对齐的等宽文本。无需重新安装应用——这仅是发送端的改动。 |
|
||||
|
||||
### 会话隔离
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue