mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-06 18:12:47 +08:00
Compare commits
2 Commits
fix/tui-qu
...
feat/slack
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
703d716228 | ||
|
|
cbc27c8ef8 |
@@ -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.
|
||||
|
||||
|
||||
491
plugins/platforms/slack/block_kit.py
Normal file
491
plugins/platforms/slack/block_kit.py
Normal file
@@ -0,0 +1,491 @@
|
||||
"""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
|
||||
# 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 _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 — 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."""
|
||||
return {
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{"type": "rich_text_section", "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)
|
||||
column_settings: List[Optional[Dict[str, Any]]] = []
|
||||
for c in range(min(ncols, MAX_TABLE_COLS)):
|
||||
align = aligns[c] if c < len(aligns) else "left"
|
||||
# Only emit a setting when it differs from the default (left, no wrap);
|
||||
# use null to skip a column, per the Slack schema.
|
||||
column_settings.append({"align": align} if align != "left" else None)
|
||||
|
||||
block: Block = {
|
||||
"type": "table",
|
||||
"rows": [[_rich_text_cell(cell) for cell in row] for row in parsed],
|
||||
}
|
||||
if any(cs is not None for cs in 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()
|
||||
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()
|
||||
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 _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
|
||||
189
tests/gateway/test_slack_block_kit.py
Normal file
189
tests/gateway/test_slack_block_kit.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""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_native_table_block(self):
|
||||
md = (
|
||||
"| Name | Status |\n"
|
||||
"|------|--------|\n"
|
||||
"| a | ok |\n"
|
||||
"| b | fail |"
|
||||
)
|
||||
blocks = render_blocks(md)
|
||||
assert len(blocks) == 1
|
||||
assert blocks[0]["type"] == "table"
|
||||
rows = blocks[0]["rows"]
|
||||
# header + 2 body rows, 2 columns each
|
||||
assert len(rows) == 3
|
||||
assert all(len(r) == 2 for r in rows)
|
||||
# cells are rich_text carrying the values
|
||||
assert str(rows[0]).count("Name") == 1
|
||||
assert "fail" in str(rows[2])
|
||||
|
||||
def test_alignment_parsed_into_column_settings(self):
|
||||
md = (
|
||||
"| L | C | R |\n"
|
||||
"|:---|:--:|---:|\n"
|
||||
"| 1 | 2 | 3 |"
|
||||
)
|
||||
blocks = render_blocks(md)
|
||||
cs = blocks[0]["column_settings"]
|
||||
# left is default -> null; center/right emitted
|
||||
assert cs[0] is None
|
||||
assert cs[1] == {"align": "center"}
|
||||
assert cs[2] == {"align": "right"}
|
||||
|
||||
def test_inline_formatting_inside_cells(self):
|
||||
md = (
|
||||
"| Item | Link |\n"
|
||||
"|------|------|\n"
|
||||
"| **bold** | [x](https://e.io) |"
|
||||
)
|
||||
blocks = render_blocks(md)
|
||||
body = blocks[0]["rows"][1]
|
||||
# bold styled text element in first cell
|
||||
bold = [
|
||||
el for el in body[0]["elements"][0]["elements"]
|
||||
if el.get("style", {}).get("bold")
|
||||
]
|
||||
assert bold
|
||||
# link element in second cell
|
||||
links = [el for el in body[1]["elements"][0]["elements"] if el["type"] == "link"]
|
||||
assert links and links[0]["url"] == "https://e.io"
|
||||
|
||||
def test_oversized_table_falls_back_to_monospace(self):
|
||||
# 120 rows > MAX_TABLE_ROWS -> monospace rich_text fallback, not a table
|
||||
big = "| a | b |\n|---|---|\n" + "\n".join(f"| x{i} | y |" for i in range(120))
|
||||
blocks = render_blocks(big)
|
||||
assert blocks[0]["type"] == "rich_text" # preformatted fallback
|
||||
assert blocks[0]["elements"][0]["type"] == "rich_text_preformatted"
|
||||
|
||||
def test_too_many_columns_falls_back_to_monospace(self):
|
||||
header = "|" + "|".join(f"c{i}" for i in range(25)) + "|"
|
||||
sep = "|" + "|".join("-" for _ in range(25)) + "|"
|
||||
row = "|" + "|".join("v" for _ in range(25)) + "|"
|
||||
blocks = render_blocks(f"{header}\n{sep}\n{row}")
|
||||
assert blocks[0]["type"] == "rich_text"
|
||||
|
||||
def test_escaped_pipe_not_a_column_separator(self):
|
||||
md = (
|
||||
"| Expr | Meaning |\n"
|
||||
"|------|--------|\n"
|
||||
"| a \\| b | or |"
|
||||
)
|
||||
blocks = render_blocks(md)
|
||||
assert blocks[0]["type"] == "table"
|
||||
# the escaped-pipe cell stays a single cell containing a literal pipe
|
||||
body = blocks[0]["rows"][1]
|
||||
assert len(body) == 2
|
||||
assert "|" in str(body[0])
|
||||
|
||||
|
||||
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,15 @@ 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, true nested lists (via rich_text), and
|
||||
# native Block Kit tables — instead of flat mrkdwn text. A plain-text
|
||||
# fallback is always sent alongside for notifications/accessibility.
|
||||
# Tables exceeding Slack's limits (100 rows / 20 cols / 10k chars)
|
||||
# gracefully fall back to aligned monospace.
|
||||
rich_blocks: false
|
||||
```
|
||||
|
||||
| Key | Default | Description |
|
||||
@@ -350,6 +359,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, and native tables). A plain-text fallback is always sent. Tables over Slack's limits fall back to 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)以及
|
||||
# 原生 Block Kit 表格——而非扁平的 mrkdwn 文本。同时始终附带
|
||||
# 纯文本回退内容,用于通知和无障碍访问。超出 Slack 限制
|
||||
# (100 行 / 20 列 / 1 万字符)的表格会优雅地回退为对齐的等宽文本。
|
||||
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/) 区块(标题、分隔线、真正的嵌套列表以及原生表格)。始终附带纯文本回退。超出 Slack 限制的表格会回退为对齐的等宽文本。无需重新安装应用——这仅是发送端的改动。 |
|
||||
|
||||
### 会话隔离
|
||||
|
||||
|
||||
Reference in New Issue
Block a user