Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 74 additions & 3 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,75 @@ def _b(name: str, default: bool) -> bool:
)


# --- Markdown table detection for Discord code-fence wrapping ---

_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 _wrap_tables_in_code_fence(text: str) -> str:
"""Wrap GFM-style pipe tables in fenced code blocks for Discord.

Discord does not render markdown tables natively β€” raw pipe characters
display as garbage. Wrapping the table in triple-backtick fences
produces a readable monospaced rendering.

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 (contains '|') immediately followed by a
# delimiter row matching the separator regex.
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
# Wrap the detected table in code fences
out.append('```')
out.extend(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.

Expand Down Expand Up @@ -2908,10 +2977,12 @@ def format_message(self, content: str) -> str:
"""
Format message for Discord.

Discord uses its own markdown variant.
Discord uses its own markdown variant. In particular, Discord does
not render GFM pipe tables β€” the raw pipe characters display as
garbage. Detected tables are wrapped in triple-backtick code fences
so they render as readable monospaced text.
"""
# Discord markdown is fairly standard, no special escaping needed
return content
return _wrap_tables_in_code_fence(content)

async def _run_simple_slash(
self,
Expand Down
128 changes: 128 additions & 0 deletions tests/gateway/test_discord_table_wrap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""Tests for Discord markdown table code-fence wrapping (issue #21168).

Discord does not render GFM pipe tables β€” raw pipe characters display as
garbage. ``_wrap_tables_in_code_fence`` detects tables and wraps them in
triple-backtick fences so they render as readable monospaced text.
"""

import pytest

from plugins.platforms.discord.adapter import _wrap_tables_in_code_fence


class TestWrapTablesInCodeFence:
"""Unit tests for the table-wrapping helper."""

def test_basic_table_is_wrapped(self):
text = (
"| Name | Value |\n"
"|------|-------|\n"
"| foo | 1 |\n"
"| bar | 2 |"
)
result = _wrap_tables_in_code_fence(text)
assert result.startswith("```\n")
assert result.endswith("\n```")
# Original table lines are preserved inside the fences
assert "| Name | Value |" in result
assert "| foo | 1 |" in result

def test_table_with_surrounding_text(self):
text = (
"Here is a comparison:\n"
"\n"
"| Model | Speed |\n"
"|-------|-------|\n"
"| A | fast |\n"
"| B | slow |\n"
"\n"
"Hope that helps!"
)
result = _wrap_tables_in_code_fence(text)
# Surrounding text is untouched
assert result.startswith("Here is a comparison:\n")
assert result.endswith("\nHope that helps!")
# Table is wrapped
assert "```\n| Model | Speed |" in result
assert "| B | slow |\n```" in result

def test_table_inside_code_fence_is_untouched(self):
text = (
"```\n"
"| A | B |\n"
"|---|---|\n"
"| 1 | 2 |\n"
"```"
)
result = _wrap_tables_in_code_fence(text)
assert result == text # unchanged

def test_no_table_passthrough(self):
text = "Just a regular message with no pipes or tables."
assert _wrap_tables_in_code_fence(text) == text

def test_pipe_without_separator_is_untouched(self):
# A line with '|' but no separator row below is NOT a table
text = "Use the | operator in bash for piping."
assert _wrap_tables_in_code_fence(text) == text

def test_empty_input(self):
assert _wrap_tables_in_code_fence("") == ""

def test_table_without_leading_pipe(self):
# GFM tables can omit leading/trailing pipes
text = (
"Name | Value\n"
"------|-------\n"
"foo | 1\n"
"bar | 2"
)
result = _wrap_tables_in_code_fence(text)
assert result.startswith("```\n")
assert result.endswith("\n```")

def test_multiple_tables(self):
text = (
"| A | B |\n"
"|---|---|\n"
"| 1 | 2 |\n"
"\n"
"Some text\n"
"\n"
"| C | D |\n"
"|---|---|\n"
"| 3 | 4 |"
)
result = _wrap_tables_in_code_fence(text)
# Both tables should be wrapped
assert result.count("```") == 4 # 2 opening + 2 closing fences

def test_table_with_alignment_row(self):
text = (
"| Left | Center | Right |\n"
"|:-----|:------:|------:|\n"
"| a | b | c |"
)
result = _wrap_tables_in_code_fence(text)
assert result.startswith("```\n")
assert result.endswith("\n```")

def test_single_column_separator_not_matched(self):
# A separator with only one column (no '|') should not match
text = "-----\nnot a table"
assert _wrap_tables_in_code_fence(text) == text

def test_format_message_integration(self):
"""Verify format_message calls the wrapping function."""
from plugins.platforms.discord.adapter import DiscordAdapter

# Create adapter instance bypassing __init__
adapter = DiscordAdapter.__new__(DiscordAdapter)
text = (
"| X | Y |\n"
"|---|---|\n"
"| 1 | 2 |"
)
result = adapter.format_message(text)
assert result.startswith("```\n")
assert result.endswith("\n```")
Loading