Discord markdown table rendering fix proposals

NousResearch/hermes-agent · cluster 21168 · gateway

Data as of

Nothing open on GitHub. Fix merged in #53284. Check it covers your case.

The short answer

0open issues
0open PRs
7items grouped
Known?
Yes. 7 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #53284. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #53284. That is separate from the fix event.
Still open
Nothing open on GitHub.

A bug report and multiple proposed fixes for how markdown tables render in Discord, with approaches ranging from wrapping tables in code fences to converting them into bullet groups. The members reflect ongoing discussion about the best solution, not a single agreed-upon fix.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #53284 fix(discord): auto-convert markdown tables to bullet groups

    merged to main

    files: gateway/platforms/helpers.py, plugins/platforms/discord/adapter.py, plugins/platforms/telegram/adapter.py, tests/gateway/test_discord_format.py, tests/gateway/test_table_helpers.py

    Show the diff
    • gateway/platforms/helpers.py
      @@ -276,3 +276,128 @@ def redact_phone(phone: str) -> str:
           if len(phone) <= 8:
               return phone[:2] + "****" + phone[-2:] if len(phone) > 4 else "****"
           return phone[:4] + "****" + phone[-4:]
      +
      +
      +# ─── GFM Markdown Table → Bullet Conversion ─────────────────────────────────
      +# Shared by Discord and Telegram adapters.  Discord calls
      +# convert_table_to_bullets() directly; Telegram imports the primitives
      +# but keeps its own MarkdownV2-aware renderer.
      +
      +
      +# Matches a GFM table delimiter row: optional outer pipes, cells of dashes
      +# (with optional alignment colons) separated by '|'.
      +# Requires at least one internal '|' so lone '---' rules are NOT matched.
      +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 split_markdown_table_row(line: str) -> list[str]:
      +    """Split a GFM table row into stripped cell values."""
      +    stripped = line.strip()
      +    if stripped.startswith("|"):
      +        stripped = stripped[1:]
      +    if stripped.endswith("|"):
      +        stripped = stripped[:-1]
      +    return [cell.strip() for cell in stripped.split("|")]
      +
      +
      +def _render_table_block(table_block: list[str]) -> str:
      +    """Render a detected GFM table as bold-heading + bullet groups.
      +
      +    Uses the same alignment logic as Telegram's renderer: for non-row-label
      +    tables, ``data_cells = cells`` (the full row) and the bullet whose value
      +    duplicates the heading is skipped.  This keeps header→value alignment
      +    correct.
      +    """
      +    if len(table_block) < 3:
      +        return "\n".join(table_block)
      +
      +    headers = split_markdown_table_row(table_block[0])
      +    if len(headers) < 2:
      +        return "\n".join(table_block)
      +
      +    first_data_row = (
      +        split_markdown_table_row(table_block[2])
      +        if len(table_block) > 2
      +        else []
      +    )
      +    has_row_label_col = len(first_data_row) == len(headers) + 1
      +
      +    rendered_groups: list[str] = []
      +    for index, row in enumerate(table_block[2:], start=1):
      +        cells = split_markdown_table_row(row)
      +        if has_row_label_col:
      +            heading = cells[0] if cells and cells[0] else f"Row {index}"
      +            data_cells = cells[1:]
      +        else:
      +            heading = next((cell for cell in cells if cell), f"Row {index}")
      +            data_cells = cells
      +
      +        if len(data_cells) < len(headers):
      +            data_cells.extend([""] * (len(headers) - len(data_cells)))
      +        elif len(data_cells) > len(headers):
      +            data_cells = data_cells[: len(headers)]
      +
      +        bullets: list[str] = []
      +        for header, value in zip(headers, data_cells):
      +            if not has_row_label_col and value == heading:
      +                continue
      +            bullets.append(f"• {header}: {value}")
      +
      +        group_lines = [f"**{heading}**", *bullets]
      +        rendered_groups.append("\n".join(group_lines))
      +
      +    return "\n\n".join(rendered_groups)
      +
      +
      +def convert_table_to_bullets(text: str) -> str:
      +    """Rewrite GFM pipe tables into bold-heading + bullet groups.
      +
      +    Tables inside fenced code blocks are left alone.
      +    """
      +    if '|' 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])
      +        ):
      +            table_block = [line, lines[i + 1]]
      +            j = i + 2
      +            while j < len(lines) and is_table_row(lines[j]):
      +                table_block.append(lines[j])
      +                j += 1
      +            out.append(_render_table_block(table_block))
      +            i = j
      +            continue
      +
      +        out.append(line)
      +        i += 1
      +
      +    return '\n'.join(out)
    • plugins/platforms/discord/adapter.py
      @@ -102,7 +102,7 @@ def __init__(self, id: int) -> None:  # noqa: A002 - matches discord API
       
       from gateway.config import Platform, PlatformConfig
       
      -from gateway.platforms.helpers import MessageDeduplicator, ThreadParticipationTracker
      +from gateway.platforms.helpers import MessageDeduplicator, ThreadParticipationTracker, convert_table_to_bullets
       from utils import atomic_json_write, env_float
       from gateway.platforms.base import (
           BasePlatformAdapter,
      @@ -3411,13 +3411,14 @@ async def _resolve_allowed_usernames(self) -> None:
                   print(f"[{self.name}] Updated DISCORD_ALLOWED_USERS with {resolved_count} resolved ID(s)")
       
           def format_message(self, content: str) -> str:
      -        """
      -        Format message for Discord.
      +        """Format message for Discord.
       
      -        Discord uses its own markdown variant.
      +        Converts GFM markdown tables to bullet-list groups since Discord
      +        does not render pipe tables natively.
               """
      -        # Discord markdown is fairly standard, no special escaping needed
      -        return content
      +        if not content:
      +            return content
      +        return convert_table_to_bullets(content)
       
           async def _run_simple_slash(
               self,
    • plugins/platforms/telegram/adapter.py
      @@ -217,137 +217,13 @@ def _separate_chunk_indicator_from_fence(text: str) -> str:
       # ---------------------------------------------------------------------------
       # Telegram's MarkdownV2 has no table syntax — '|' is just an escaped literal,
       # so pipe tables render as noisy backslash-pipe text with no alignment.
      -# Reformating each row into a bold heading plus bullet list keeps the content
      -# readable on mobile clients while preserving the source data.
      -
      -# Matches a GFM table delimiter row: optional outer pipes, cells containing
      -# only dashes (with optional leading/trailing colons for alignment) separated
      -# by '|'.  Requires at least one internal '|' so lone '---' horizontal rules
      -# are NOT matched.
      -_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 _split_markdown_table_row(line: str) -> list[str]:
      -    """Split a simple GFM table row into stripped cell values."""
      -    stripped = line.strip()
      -    if stripped.startswith("|"):
      -        stripped = stripped[1:]
      -    if stripped.endswith("|"):
      -        stripped = stripped[:-1]
      -    return [cell.strip() for cell in stripped.split("|")]
      -
      -
      -def _render_table_block_for_telegram(table_block: list[str]) -> str:
      -    """Render a detected GFM table as Telegram-friendly row groups."""
      -    if len(table_block) < 3:
      -        return "\n".join(table_block)
      -
      -    headers = _split_markdown_table_row(table_block[0])
      -    if len(headers) < 2:
      -        return "\n".join(table_block)
      -
      -    # Detect row-label column: present when data rows have one more cell
      -    # than the header row (the row-label column carries no header).
      -    first_data_row = _split_markdown_table_row(table_block[2]) if len(table_block) > 2 else []
      -    has_row_label_col = len(first_data_row) == len(headers) + 1
      -
      -    rendered_groups: list[str] = []
      -    for index, row in enumerate(table_block[2:], start=1):
      -        cells = _split_markdown_table_row(row)
      -        if has_row_label_col:
      -            # First cell is the row-label (heading); remaining cells align with headers.
      -            heading = cells[0] if cells and cells[0] else f"Row {index}"
      -            data_cells = cells[1:]
      -        else:
      -            # No row-label column: use first non-empty cell as heading.
      -            heading = next((cell for cell in cells if cell), f"Row {index}")
      -            data_cells = cells
      -
      -        # Pad or trim data_cells to match headers length.
      -        if len(data_cells) < len(headers):
      -            data_cells.extend([""] * (len(headers) - len(data_cells)))
      -        elif len(data_cells) > len(headers):
      -            data_cells = data_cells[: len(headers)]
      -
      -        # Build the bulleted lines for this row.  Skip any bullet whose value
      -        # duplicates the heading text -- when has_row_label_col is False the
      -        # heading IS the first data cell, and emitting it twice (once as the
      -        # bold heading, once as the first bullet) is visual noise.
      -        bullets: list[str] = []
      -        for header, value in zip(headers, data_cells):
      -            if not has_row_label_col and value == heading:
      -                continue
      -            bullets.append(f"• {header}: {value}")
      +# The shared convert_table_to_bullets() in gateway.platforms.helpers handles
      +# the full conversion (detection + rendering); Telegram just calls it.
       
      -        # Within a row-group: single newline between heading and its bullets,
      -        # and between successive bullets.  This keeps the row visually tight
      -        # on Telegram instead of stretching each bullet into its own paragraph.
      -        group_lines = [f"**{heading}**", *bullets]
      -        rendered_groups.append("\n".join(group_lines))
      -
      -    # Between row-groups: blank line so each group reads as a distinct block.
      -    return "\n\n".join(rendered_groups)
      -
      -
      -def _wrap_markdown_tables(text: str) -> str:
      -    """Rewrite GFM-style pipe tables into Telegram-friendly bullet groups.
      -
      -    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 and rewritten as
      -    per-row bullet groups. Tables inside existing fenced code blocks are left
      -    alone.
      -    """
      -    if '|' 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()
      -
      -        # Track existing fenced code blocks — never touch content inside.
      -        if stripped.startswith('```'):
      -            in_fence = not in_fence
      -            out.append(line)
      -            i += 1
      -            continue
      -        if in_fence:
      -            out.append(line)
      -            i += 1
      -            continue
      -
      -        # Look for a header row (contains '|') immediately followed by a
      -        # delimiter row.
      -        if (
      -            '|' in line
      -            and i + 1 < len(lines)
      -            and _TABLE_SEPARATOR_RE.match(lines[i + 1])
      -        ):
      -            table_block = [line, lines[i + 1]]
      -            j = i + 2
      -            while j < len(lines) and _is_table_row(lines[j]):
      -                table_block.append(lines[j])
      -                j += 1
      -            out.append(_render_table_block_for_telegram(table_block))
      -            i = j
      -            continue
      -
      -        out.append(line)
      -        i += 1
      -
      -    return '\n'.join(out)
      +from gateway.platforms.helpers import (
      +    TABLE_SEPARATOR_RE as _TABLE_SEPARATOR_RE,
      +    convert_table_to_bullets as _wrap_markdown_tables,
      +)
       
       
       # ---------------------------------------------------------------------------
    • tests/gateway/test_discord_format.py
      @@ -0,0 +1,58 @@
      +"""Discord format_message: tables converted to bullet groups."""
      +
      +import types
      +import sys
      +
      +
      +def _make_discord_adapter():
      +    """Construct a DiscordAdapter with discord.py stubbed out."""
      +    fake_discord = types.ModuleType("discord")
      +    fake_discord.Intents = type("Intents", (), {"default": classmethod(lambda cls: cls())})
      +    fake_discord.Message = object
      +    fake_ext = types.ModuleType("discord.ext")
      +    fake_commands = types.ModuleType("discord.ext.commands")
      +    fake_ext.commands = fake_commands
      +    fake_discord.ext = fake_ext
      +    sys.modules.setdefault("discord", fake_discord)
      +    sys.modules.setdefault("discord.ext", fake_ext)
      +    sys.modules.setdefault("discord.ext.commands", fake_commands)
      +
      +    from plugins.platforms.discord.adapter import DiscordAdapter
      +    adapter = object.__new__(DiscordAdapter)
      +    return adapter
      +
      +
      +class TestDiscordFormatMessage:
      +
      +    def test_table_converted_to_bullets(self):
      +        adapter = _make_discord_adapter()
      +        text = (
      +            "Results:\n\n"
      +            "| Name | Score |\n"
      +            "|------|-------|\n"
      +            "| Alice | 95   |\n"
      +            "| Bob   | 80   |\n"
      +            "\nDone."
      +        )
      +        out = adapter.format_message(text)
      +        assert "**Alice**" in out
      +        assert "• Score: 95" in out
      +        assert "**Bob**" in out
      +        assert "• Score: 80" in out
      +        assert out.startswith("Results:")
      +        assert out.rstrip().endswith("Done.")
      +        assert "|---" not in out
      +
      +    def test_plain_text_unchanged(self):
      +        adapter = _make_discord_adapter()
      +        text = "Hello world, no tables here."
      +        assert adapter.format_message(text) == text
      +
      +    def test_code_block_table_unchanged(self):
      +        adapter = _make_discord_adapter()
      +        text = "```\n| a | b |\n|---|---|\n| 1 | 2 |\n```"
      +        assert adapter.format_message(text) == text
      +
      +    def test_empty_string(self):
      +        adapter = _make_discord_adapter()
      +        assert adapter.format_message("") == ""
    • tests/gateway/test_table_helpers.py
      @@ -0,0 +1,137 @@
      +"""Shared GFM table → bullet conversion helpers."""
      +
      +from gateway.platforms.helpers import (
      +    TABLE_SEPARATOR_RE,
      +    is_table_row,
      +    split_markdown_table_row,
      +    convert_table_to_bullets,
      +)
      +
      +
      +class TestTablePrimitives:
      +
      +    def test_separator_re_matches_basic(self):
      +        assert TABLE_SEPARATOR_RE.match("|---|---|")
      +
      +    def test_separator_re_matches_alignment(self):
      +        assert TABLE_SEPARATOR_RE.match("|:-----|----:|:----:|")
      +
      +    def test_separator_re_rejects_lone_rule(self):
      +        assert not TABLE_SEPARATOR_RE.match("---")
      +
      +    def test_is_table_row_with_pipe(self):
      +        assert is_table_row("| Alice | 150 |")
      +
      +    def test_is_table_row_blank(self):
      +        assert not is_table_row("")
      +
      +    def test_split_row_strips_outer_pipes(self):
      +        assert split_markdown_table_row("| a | b | c |") == ["a", "b", "c"]
      +
      +    def test_split_row_no_outer_pipes(self):
      +        assert split_markdown_table_row("a | b | c") == ["a", "b", "c"]
      +
      +
      +class TestConvertTableToBullets:
      +
      +    def test_basic_table(self):
      +        text = (
      +            "| Player | Score |\n"
      +            "|--------|-------|\n"
      +            "| Alice  | 150   |\n"
      +            "| Bob    | 120   |"
      +        )
      +        out = convert_table_to_bullets(text)
      +        assert "**Alice**" in out
      +        assert "• Score: 150" in out
      +        assert "**Bob**" in out
      +        assert "• Score: 120" in out
      +        assert "• Player: Alice" not in out
      +
      +    def test_three_column_table(self):
      +        text = (
      +            "| Name | Age | City |\n"
      +            "|:-----|----:|:----:|\n"
      +            "| Ada  |  30 | NYC  |"
      +        )
      +        out = convert_table_to_bullets(text)
      +        assert "**Ada**" in out
      +        assert "• Name: Ada" not in out
      +        assert "• Age: 30" in out
      +        assert "• City: NYC" in out
      +        assert "**Ada**\n• Age: 30\n• City: NYC" in out
      +
      +    def test_row_label_column(self):
      +        text = (
      +            "|        | Score | Rank |\n"
      +            "|--------|-------|------|\n"
      +            "| Alice  | 150   | 1    |\n"
      +            "| Bob    | 120   | 2    |"
      +        )
      +        out = convert_table_to_bullets(text)
      +        assert "**Alice**" in out
      +        assert "• Score: 150" in out
      +        assert "• Rank: 1" in out
      +        assert "**Alice**\n• Score: 150\n• Rank: 1" in out
      +
      +    def test_bare_pipe_table(self):
      +        text = "head1 | head2\n--- | ---\na | b\nc | d"
      +        out = convert_table_to_bullets(text)
      +        assert "**a**" in out
      +        assert "• head1: a" not in out
      +        assert "• head2: b" in out
      +
      +    def test_two_consecutive_tables(self):
      +        text = (
      +            "| A | B |\n"
      +            "|---|---|\n"
      +            "| 1 | 2 |\n"
      +            "\n"
      +            "| X | Y |\n"
      +            "|---|---|\n"
      +            "| 9 | 8 |"
      +        )
      +        out = convert_table_to_bullets(text)
      +        assert out.count("**1**") == 1
      +        assert out.count("**9**") == 1
      +        assert "• B: 2" in out
      +        assert "• Y: 8" in out
      +
      +    def test_surrounding_prose_preserved(self):
      +        text = (
      +            "Scores:\n\n"
      +            "| Player | Score |\n"
      +            "|--------|-------|\n"
      +            "| Alice  | 150   |\n"
      +            "\nEnd."
      +        )
      +        out = convert_table_to_bullets(text)
      +        assert out.startswith("Scores:")
      +        assert out.endswith("End.")
      +
      +    def test_table_inside_code_fence_untouched(self):
      +        text = "```\n| a | b |\n|---|---|\n| 1 | 2 |\n```"
      +        assert convert_table_to_bullets(text) == text
      +
      +    def test_plain_text_with_pipes_untouched(self):
      +        text = "Use the | pipe operator to chain."
      +        assert convert_table_to_bullets(text) == text
      +
      +    def test_horizontal_rule_not_matched(self):
      +        text = "Section A\n\n---\n\nSection B"
      +        assert convert_table_to_bullets(text) == text
      +
      +    def test_no_pipe_short_circuits(self):
      +        text = "Plain **bold** text."
      +        assert convert_table_to_bullets(text) == text
      +
      +    def test_row_groups_separated_by_blank_line(self):
      +        text = (
      +            "| A | B |\n"
      +            "|---|---|\n"
      +            "| x | 1 |\n"
      +            "| y | 2 |"
      +        )
      +        out = convert_table_to_bullets(text)
      +        assert "• B: 1\n\n**y**" in out
      +        assert "\n\n• " not in out

5 PRs closed without merging

From the close comments: closed without comment 1 · done elsewhere 1 · duplicate 1 · merged via another change 1 · superseded by another PR 1

  • #16903 fix(discord): convert markdown tables to code blocks for proper rendering · done elsewhere → cf7bf5b
  • #21170 fix(discord): auto-wrap markdown tables in code fences · superseded by another PR → #16903
  • #35707 fix(discord): wrap markdown tables in code fences for readable rendering · duplicate → #4401, #16903, #21170, #29593, #21168
  • #45781 fix(discord): auto-convert markdown tables to bullet groups · merged via another change → #53284
  • #53067 fix(discord): convert markdown tables to readable bullet groups · closed without comment
Closed issues · 1
  • Closed#21168[Bug]: Discord markdown tables render as garbage — need auto code-fence wrapping
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.29: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
gateway
Merged PRs
#53284
Closed issues
1
Attempts
5 PRs closed without merging
Fix link checked
26 Jun 2026

For your agent

The same facts, typed.