From 5922b7277f612112535b3927139bd4e06547ed14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl?= Date: Wed, 3 Jun 2026 00:03:14 +0200 Subject: [PATCH] fix(discord): render markdown tables readably --- plugins/platforms/discord/adapter.py | 112 ++++++++++++++++++++++- tests/gateway/test_discord_table_wrap.py | 76 +++++++++++++++ 2 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 tests/gateway/test_discord_table_wrap.py diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 12cf05c38c9e..09e8572c6fcf 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -170,6 +170,112 @@ def _b(name: str, default: bool) -> bool: ) +# --- Markdown table detection for Discord code-fence formatting --- + +_TABLE_SEPARATOR_RE = re.compile( + r'^\s*\|?\s*:?-+:?\s*(?:\|\s*:?-+:?\s*){1,}\|?\s*$' +) + + +def _split_table_row(line: str) -> list[str]: + """Split a GFM pipe-table row into stripped cell strings.""" + stripped = line.strip() + if stripped.startswith('|'): + stripped = stripped[1:] + if stripped.endswith('|'): + stripped = stripped[:-1] + return [cell.strip() for cell in stripped.split('|')] + + +def _is_table_row(line: str) -> bool: + """Return True if *line* could plausibly be a table data row.""" + if '|' not in line: + return False + return len(_split_table_row(line)) >= 2 + + +def _format_plaintext_table(table_lines: list[str]) -> list[str]: + """Render markdown table lines as aligned plaintext rows.""" + if len(table_lines) < 2: + return table_lines + + rows = [_split_table_row(table_lines[0])] + rows.extend(_split_table_row(line) for line in table_lines[2:]) + column_count = max((len(row) for row in rows), default=0) + if column_count < 2: + return table_lines + + normalized = [row + [''] * (column_count - len(row)) for row in rows] + widths = [ + max(len(row[index]) for row in normalized) + for index in range(column_count) + ] + + def render_row(row: list[str]) -> str: + return ' | '.join( + cell.ljust(widths[index]) for index, cell in enumerate(row) + ).rstrip() + + separator = ' | '.join('-' * max(3, width) for width in widths) + rendered = [render_row(normalized[0]), separator] + rendered.extend(render_row(row) for row in normalized[1:]) + return rendered + + +def _wrap_tables_in_code_fence(text: str) -> str: + """Convert GFM-style pipe tables to aligned code blocks for Discord. + + Discord does not render markdown tables natively. Raw markdown tables + are hard to read in proportional chat text, so detected tables are + converted to aligned plaintext and wrapped in triple-backtick fences. + + Tables that are already 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() + + # 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 immediately followed by a GFM separator row. + if ( + _is_table_row(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('```') + out.extend(_format_plaintext_table(table_block)) + out.append('```') + i = j + continue + + out.append(line) + i += 1 + + return '\n'.join(out) + + class VoiceReceiver: """Captures and decodes voice audio from a Discord voice channel. @@ -2908,10 +3014,10 @@ def format_message(self, content: str) -> str: """ Format message for Discord. - Discord uses its own markdown variant. + Discord uses its own markdown variant. It does not render GFM pipe + tables, so convert detected tables to aligned fenced code blocks. """ - # Discord markdown is fairly standard, no special escaping needed - return content + return _wrap_tables_in_code_fence(content) async def _run_simple_slash( self, diff --git a/tests/gateway/test_discord_table_wrap.py b/tests/gateway/test_discord_table_wrap.py new file mode 100644 index 000000000000..1128918b8a2a --- /dev/null +++ b/tests/gateway/test_discord_table_wrap.py @@ -0,0 +1,76 @@ +"""Tests for Discord markdown table formatting. + +Discord does not render GFM pipe tables. ``_wrap_tables_in_code_fence`` +detects tables, converts them to aligned plaintext, and wraps them in +triple-backtick fences so they render readably in Discord. +""" + +from plugins.platforms.discord.adapter import _wrap_tables_in_code_fence + + +class TestWrapTablesInCodeFence: + def test_unaligned_markdown_table_becomes_readable_plaintext(self): + text = ( + "Model | Latency | Notes\n" + "---|---|---\n" + "GPT-5.5 | 120ms | fast\n" + "Mini | 9ms | cheap" + ) + result = _wrap_tables_in_code_fence(text) + assert result == ( + "```\n" + "Model | Latency | Notes\n" + "------- | ------- | -----\n" + "GPT-5.5 | 120ms | fast\n" + "Mini | 9ms | cheap\n" + "```" + ) + + def test_table_inside_code_fence_is_untouched(self): + text = ( + "```\n" + "| A | B |\n" + "|---|---|\n" + "| 1 | 2 |\n" + "```" + ) + assert _wrap_tables_in_code_fence(text) == text + + def test_pipe_without_separator_is_untouched(self): + text = "Use the | operator in bash for piping." + assert _wrap_tables_in_code_fence(text) == text + + def test_ragged_rows_are_padded(self): + text = ( + "| A | B | C |\n" + "|---|---|---|\n" + "| 1 | 2 |\n" + "| 3 | 4 | 5 |" + ) + result = _wrap_tables_in_code_fence(text) + assert result == ( + "```\n" + "A | B | C\n" + "--- | --- | ---\n" + "1 | 2 |\n" + "3 | 4 | 5\n" + "```" + ) + + def test_format_message_integration(self): + from plugins.platforms.discord.adapter import DiscordAdapter + + adapter = DiscordAdapter.__new__(DiscordAdapter) + text = ( + "| X | Y |\n" + "|---|---|\n" + "| 1 | 2 |" + ) + result = adapter.format_message(text) + assert result == ( + "```\n" + "X | Y\n" + "--- | ---\n" + "1 | 2\n" + "```" + )