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:
Kyle Zhang 2026-07-23 08:12:58 -07:00 committed by Teknium
parent 72f714ca36
commit a7738796e1
2 changed files with 339 additions and 6 deletions

View file

@ -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]