mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
feat(slack): wrap and align GFM tables for proper mrkdwn rendering
Slack mrkdwn has no table syntax — GFM pipe tables render as literal-pipe noise with a raw |---|---| separator row. Wrap detected tables in ``` fences so they render as monospace preformatted text, and pad cells to per-column max display width (East-Asian Wide / Full-width chars counted as 2 columns) so columns stay aligned even with CJK content. Tables already inside fenced code blocks are left untouched, and the emitted fences carry no language tag so they compose with the lang-tag-strip pass. This covers the plain-mrkdwn text path; the opt-in rich_blocks path already renders native Block Kit table blocks. Reapplied from #16648 by @kylezh — the original patched gateway/platforms/slack.py, which was migrated to plugins/platforms/slack/adapter.py in the plugin migration. Fixes the non-rich_blocks table path described in #8552.
This commit is contained in:
parent
72f714ca36
commit
a7738796e1
2 changed files with 339 additions and 6 deletions
|
|
@ -16,6 +16,7 @@ import logging
|
|||
import os
|
||||
import re
|
||||
import time
|
||||
import unicodedata
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, ClassVar, Dict, Optional, Any, Tuple, List
|
||||
|
||||
|
|
@ -128,6 +129,119 @@ def _slack_file_marker(file_obj: Dict[str, Any]) -> str:
|
|||
return f"[audio: {name}]"
|
||||
return f"[file: {name} ({mimetype})]" if mimetype else f"[file: {name}]"
|
||||
|
||||
|
||||
# ── GFM markdown table preprocessing ──────────────────────────────────────
|
||||
# Slack mrkdwn does not render GFM-style pipe tables — they appear as literal
|
||||
# pipes. Wrapping in ``` fences makes them render as monospace preformatted
|
||||
# text, and padding cells to per-column max display width (with East-Asian
|
||||
# Wide / CJK awareness) keeps the columns aligned for the reader.
|
||||
|
||||
_TABLE_SEPARATOR_RE = re.compile(
|
||||
r"^\s*\|?\s*:?-+:?\s*(?:\|\s*:?-+:?\s*){1,}\|?\s*$"
|
||||
)
|
||||
|
||||
|
||||
def _is_table_row(line: str) -> bool:
|
||||
"""Return True if *line* could plausibly be a table data row."""
|
||||
stripped = line.strip()
|
||||
return bool(stripped) and "|" in stripped
|
||||
|
||||
|
||||
def _disp_width(s: str) -> int:
|
||||
"""Monospace display width: East-Asian Wide / Full-width chars count as 2."""
|
||||
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
|
||||
|
||||
|
||||
def _pad(cell: str, width: int) -> str:
|
||||
"""Right-pad *cell* with spaces until its display width equals *width*."""
|
||||
delta = width - _disp_width(cell)
|
||||
return cell + (" " * delta if delta > 0 else "")
|
||||
|
||||
|
||||
def _split_table_row(line: str) -> List[str]:
|
||||
"""Split a ``| a | b | c |`` row into trimmed cells (outer pipes optional)."""
|
||||
s = line.strip()
|
||||
if s.startswith("|"):
|
||||
s = s[1:]
|
||||
if s.endswith("|"):
|
||||
s = s[:-1]
|
||||
return [c.strip() for c in s.split("|")]
|
||||
|
||||
|
||||
def _align_table(rows: List[str]) -> List[str]:
|
||||
"""Re-emit a markdown table with cells padded to per-column max display width.
|
||||
|
||||
*rows[0]* is the header, *rows[1]* is the GFM separator (regenerated to
|
||||
match new column widths), and *rows[2:]* are data rows. Cells are
|
||||
normalized to a uniform column count (missing cells filled with empty
|
||||
strings) before width calculation.
|
||||
"""
|
||||
if len(rows) < 2:
|
||||
return rows
|
||||
parsed = [_split_table_row(r) for r in rows]
|
||||
n_cols = max(len(r) for r in parsed)
|
||||
for r in parsed:
|
||||
while len(r) < n_cols:
|
||||
r.append("")
|
||||
sep_idx = 1
|
||||
parsed[sep_idx] = ["---"] * n_cols # placeholder; regenerated below
|
||||
widths = [max(_disp_width(r[c]) for r in parsed) for c in range(n_cols)]
|
||||
out: List[str] = []
|
||||
for idx, row in enumerate(parsed):
|
||||
if idx == sep_idx:
|
||||
cells = ["-" * widths[c] for c in range(n_cols)]
|
||||
else:
|
||||
cells = [_pad(row[c], widths[c]) for c in range(n_cols)]
|
||||
out.append("| " + " | ".join(cells) + " |")
|
||||
return out
|
||||
|
||||
|
||||
def _wrap_markdown_tables(text: str) -> str:
|
||||
"""Wrap GFM pipe tables in ``` fences and align column widths.
|
||||
|
||||
Detected by a row containing ``|`` immediately followed by a delimiter row
|
||||
matching :data:`_TABLE_SEPARATOR_RE`. Subsequent pipe-containing non-blank
|
||||
lines are consumed as the table body. Tables already inside fenced code
|
||||
blocks are left alone.
|
||||
"""
|
||||
if not text or "|" not in text or "-" not in text:
|
||||
return text
|
||||
|
||||
lines = text.split("\n")
|
||||
out: List[str] = []
|
||||
in_fence = False
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
stripped = line.lstrip()
|
||||
if stripped.startswith("```"):
|
||||
in_fence = not in_fence
|
||||
out.append(line)
|
||||
i += 1
|
||||
continue
|
||||
if in_fence:
|
||||
out.append(line)
|
||||
i += 1
|
||||
continue
|
||||
if (
|
||||
"|" in line
|
||||
and i + 1 < len(lines)
|
||||
and _TABLE_SEPARATOR_RE.match(lines[i + 1])
|
||||
):
|
||||
block = [line, lines[i + 1]]
|
||||
j = i + 2
|
||||
while j < len(lines) and _is_table_row(lines[j]):
|
||||
block.append(lines[j])
|
||||
j += 1
|
||||
out.append("```")
|
||||
out.extend(_align_table(block))
|
||||
out.append("```")
|
||||
i = j
|
||||
continue
|
||||
out.append(line)
|
||||
i += 1
|
||||
return "\n".join(out)
|
||||
|
||||
# ContextVar carrying the user_id of the slash-command invoker.
|
||||
# Set in _handle_slash_command, read in send() to match the correct
|
||||
# stashed response_url when multiple users issue commands on the same
|
||||
|
|
@ -3212,15 +3326,21 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
def format_message(self, content: str) -> str:
|
||||
"""Convert standard markdown to Slack mrkdwn format.
|
||||
|
||||
Protected regions (code blocks, inline code) are extracted first so
|
||||
their contents are never modified. Standard markdown constructs
|
||||
(headers, bold, italic, links) are translated to mrkdwn syntax.
|
||||
GFM-style pipe tables are first wrapped in ``` fences and column-
|
||||
aligned (with CJK display-width awareness) so they render as monospace
|
||||
preformatted text instead of literal-pipe noise. Then protected
|
||||
regions (code blocks — including the table fences just emitted —
|
||||
and inline code) are extracted so their contents are never modified.
|
||||
Standard markdown constructs (headers, bold, italic, links) are
|
||||
translated to mrkdwn syntax.
|
||||
Broadcast mentions are escaped before entity protection so model output
|
||||
cannot trigger workspace- or channel-wide notifications by default.
|
||||
"""
|
||||
if not content:
|
||||
return content
|
||||
|
||||
content = _wrap_markdown_tables(content)
|
||||
|
||||
placeholders: dict = {}
|
||||
counter = [0]
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue