feat(discord): render markdown tables as inline images (CJK-aware, markdown-aware) - #1
feat(discord): render markdown tables as inline images (CJK-aware, markdown-aware)#1sam7894604 wants to merge 11 commits into
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughDiscordAdapter now converts Markdown tables into aligned fenced code blocks or PNG images, preserves fenced code blocks, and sends mixed text/table content as ordered Discord messages with per-item reply handling. Tests cover parsing, rendering, fallback behavior, and send ordering. ChangesDiscord table rendering and send flow
Estimated code review effort: 4 (Complex) | ~45 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
🔎 Lint report:
|
| Rule | Count |
|---|---|
unresolved-attribute |
2 |
unresolved-import |
1 |
First entries
run_agent.py:2997: [unresolved-attribute] unresolved-attribute: Object of type `Self@get_credits_spent_micros` has no attribute `_credits_session_start_micros`
plugins/platforms/discord/adapter.py:4073: [unresolved-import] unresolved-import: Cannot resolve imported module `PIL`
tests/run_agent/test_credits_notices_toggle.py:76: [unresolved-attribute] unresolved-attribute: Unresolved attribute `_credits_session_start_micros` on type `AIAgent`
✅ Fixed issues (1):
| Rule | Count |
|---|---|
invalid-assignment |
1 |
First entries
tests/run_agent/test_credits_notices_toggle.py:76: [invalid-assignment] invalid-assignment: Object of type `None` is not assignable to attribute `_credits_session_start_micros` of type `int`
Unchanged: 5971 pre-existing issues carried over.
Diagnostics are surfaced as warnings — this check never fails the build.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@plugins/platforms/discord/adapter.py`:
- Around line 1735-1747: The table-to-embed flow in the Discord adapter is
dropping content when embed delivery fails. In `adapter.py`, adjust the
`table_embeds` send loop so `formatted = cleaned` only happens when the embeds
are actually sent successfully, and keep the original `formatted` unchanged on
any exception from `channel.send` in `send`/embed handling. Use the existing
`cleaned`, `table_embeds`, and `embed_message_ids` logic to preserve fallback
table text whenever embed delivery is best-effort or rate-limited.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: b3f88191-475e-4328-b591-f185cf76d732
📥 Commits
Reviewing files that changed from the base of the PR and between dd5e290638b56a49344e96ee4c99fe4b7e90c543 and b5cd41d13630184b2c728ef1d3158108bc35c80c.
📒 Files selected for processing (2)
plugins/platforms/discord/adapter.pytests/gateway/test_discord_send.py
… fail on '(empty)' sentinel Two related bugs caused subagent delegation to silently return empty summaries with 0 tokens when the user configured delegation.provider=bedrock alongside delegation.base_url=https://bedrock-runtime.<region>.amazonaws.com. Root cause #1 — misrouting in _resolve_delegation_credentials(): The configured_base_url branch unconditionally forced provider='custom' and api_mode='chat_completions', only specializing for chatgpt.com, anthropic, and kimi hosts. Bedrock (and other native-SDK providers) fell through as 'custom' + chat_completions, which then POSTed OpenAI-shaped JSON at Bedrock's native API. Bedrock rejected the payload and returned nothing, which looked like an empty LLM response to the child agent. Fix: when provider is one of {bedrock, vertex, google, google-genai}, skip the base_url short-circuit and fall through to resolve_runtime_provider(), which knows how to construct the proper SDK client. base_url can still be forwarded through that path for regional overrides. Root cause #2 — '(empty)' sentinel accepted as success: After N retries of empty LLM responses, run_agent.py emits the literal string '(empty)' as final_response. _run_single_child then hit `elif summary:` — '(empty)' is truthy, so status became 'completed' and the parent surfaced a blank result with no error. Users saw api_calls=4, tokens=0, duration~0.4s, status=completed. Fix: treat final_response.strip() == '(empty)' as a failure so the parent surfaces it instead of silently accepting zero-content 'success'. Both paths were reproduced in a live Hermes TUI session on us-west-2 Bedrock (provider=bedrock, model=us.anthropic.claude-sonnet-4-6) and are covered by new tests in tests/tools/test_delegate.py.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
plugins/platforms/discord/adapter.py (1)
3621-3648: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winAdjacent tables (no blank line between them) get merged into one.
The data-row collection loop only checks
_looks_like_table_row(lines[j]), so if a second table's header immediately follows the first table's last data row with no blank line, that header line — and even the second table's separator row — get swallowed as literal data rows of the first table. The second separator (|---|---|) then renders as a garbage data row, and the merged table uses the first table's column count for all of it.🐛 Proposed fix: stop data collection when the next pipe-line starts a new table
header = lines[i] j = i + 2 data: List[str] = [] while j < n and cls._looks_like_table_row(lines[j]): + if j + 1 < n and cls._looks_like_separator_row(lines[j + 1]): + break # lines[j] is actually the next table's header data.append(lines[j]) j += 1🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@plugins/platforms/discord/adapter.py` around lines 3621 - 3648, The table reflow logic in _reflow_tables_in_segment is overconsuming adjacent tables because the data scan only stops on non-table rows. Update the loop that collects data rows so it also stops when a pipe line begins a new table header followed by a separator row, preventing the next table’s header and separator from being swallowed as data. Keep the fix localized to _reflow_tables_in_segment and reuse the existing _looks_like_table_row and _looks_like_separator_row helpers to detect the start of a new table.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@plugins/platforms/discord/adapter.py`:
- Around line 3621-3648: The table reflow logic in _reflow_tables_in_segment is
overconsuming adjacent tables because the data scan only stops on non-table
rows. Update the loop that collects data rows so it also stops when a pipe line
begins a new table header followed by a separator row, preventing the next
table’s header and separator from being swallowed as data. Keep the fix
localized to _reflow_tables_in_segment and reuse the existing
_looks_like_table_row and _looks_like_separator_row helpers to detect the start
of a new table.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: efb52758-5cb7-4fd0-87d0-53b07608f50c
📥 Commits
Reviewing files that changed from the base of the PR and between b5cd41d13630184b2c728ef1d3158108bc35c80c and c3ca010c393bf4b881be73b5eebadeb8ab7be24a.
📒 Files selected for processing (2)
plugins/platforms/discord/adapter.pytests/gateway/test_discord_send.py
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@plugins/platforms/discord/adapter.py`:
- Around line 3762-3775: The table detection and rendering logic in the Discord
adapter is too permissive and splits cells incorrectly. Update the table-start
predicates in the table parsing flow to use a shared GFM-aware cell parser that
respects escaped pipes, and require the header/separator cell counts to match
before treating content as a table. Then replace the direct header/row
.split("|") usage in both the text and image rendering paths with the shared
helper so escaped cell content is preserved consistently.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: f9843ef1-f61f-4583-8e10-fc17bcbd1562
📥 Commits
Reviewing files that changed from the base of the PR and between c3ca010c393bf4b881be73b5eebadeb8ab7be24a and acce1ff22d1a9204dca03c66b21d33370a2c48b0.
📒 Files selected for processing (2)
plugins/platforms/discord/adapter.pytests/gateway/test_discord_send.py
| if ( | ||
| i + 1 < n | ||
| and cls._looks_like_table_row(lines[i]) | ||
| and cls._looks_like_separator_row(lines[i + 1]) | ||
| ): | ||
| header = lines[i] | ||
| j = i + 2 | ||
| data: List[str] = [] | ||
| while j < n and cls._looks_like_table_row(lines[j]): | ||
| data.append(lines[j]) | ||
| j += 1 | ||
| add_text("\n".join(buf)) | ||
| buf = [] | ||
| parts.append({"type": "table", "header": header, "rows": data}) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Use GFM-aware cell parsing before classifying/rendering tables.
The current row-local separator check accepts cases like A | B\n--- as a table, and raw .split("|") treats escaped cell content such as \| as a delimiter before later truncation can drop data. Centralize cell parsing, require separator/header widths to match, and reuse it in both text and image paths.
Proposed direction
+ _TABLE_CELL_SPLIT_RE = re.compile(r"(?<!\\)\|")
+
+ `@classmethod`
+ def _table_cells(cls, line: str) -> List[str]:
+ return [
+ cell.replace(r"\|", "|").strip()
+ for cell in cls._TABLE_CELL_SPLIT_RE.split(line.strip().strip("|"))
+ ]
+
+ `@classmethod`
+ def _is_table_start(cls, header_line: str, separator_line: str) -> bool:
+ if not cls._looks_like_table_row(header_line):
+ return False
+ if not cls._looks_like_separator_row(separator_line):
+ return False
+ headers = cls._table_cells(header_line)
+ separators = cls._table_cells(separator_line)
+ return len(headers) > 1 and len(headers) == len(separators)Then replace the table-start predicates and header/row .split("|") calls with these helpers.
Also applies to: 3824-3831
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@plugins/platforms/discord/adapter.py` around lines 3762 - 3775, The table
detection and rendering logic in the Discord adapter is too permissive and
splits cells incorrectly. Update the table-start predicates in the table parsing
flow to use a shared GFM-aware cell parser that respects escaped pipes, and
require the header/separator cell counts to match before treating content as a
table. Then replace the direct header/row .split("|") usage in both the text and
image rendering paths with the shared helper so escaped cell content is
preserved consistently.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
plugins/platforms/discord/adapter.py (1)
3909-3912: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winBound table PNG dimensions before allocating.
width/heightcome directly from table content, so a large generated table can allocate a huge Pillow image on the async send path. Add pixel/byte caps and returnNoneso the existing code-block fallback is used.🛡️ Proposed guard
+ _TABLE_IMG_MAX_PIXELS = 16_000_000 + _TABLE_IMG_MAX_BYTES = 8 * 1024 * 1024 + width = sum(col_w) + 1 height = row_h * (len(rows) + 1) + 1 + if width <= 0 or height <= 0 or width * height > cls._TABLE_IMG_MAX_PIXELS: + return None img = Image.new("RGB", (width, height), cls._TABLE_IMG_BG) @@ buf = io.BytesIO() img.save(buf, format="PNG") - return buf.getvalue() + data = buf.getvalue() + if len(data) > cls._TABLE_IMG_MAX_BYTES: + return None + return dataAlso applies to: 3947-3949
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@plugins/platforms/discord/adapter.py` around lines 3909 - 3912, The table PNG generation in the Discord adapter can allocate an oversized Pillow image from unbounded content-derived dimensions. Update the table rendering path in the image creation logic (the code that computes width/height before Image.new, including the related block near the other table render site) to enforce pixel/byte caps and bail out by returning None when the bounds are exceeded. Keep the existing code-block fallback intact so oversized tables are sent as text instead of images.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@plugins/platforms/discord/adapter.py`:
- Around line 3909-3912: The table PNG generation in the Discord adapter can
allocate an oversized Pillow image from unbounded content-derived dimensions.
Update the table rendering path in the image creation logic (the code that
computes width/height before Image.new, including the related block near the
other table render site) to enforce pixel/byte caps and bail out by returning
None when the bounds are exceeded. Keep the existing code-block fallback intact
so oversized tables are sent as text instead of images.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 462975c9-c933-47f8-93d3-88b910bb5a96
📥 Commits
Reviewing files that changed from the base of the PR and between acce1ff22d1a9204dca03c66b21d33370a2c48b0 and cf59609b79b8397a16ec320ec8228a780910c315.
📒 Files selected for processing (2)
plugins/platforms/discord/adapter.pytests/gateway/test_discord_send.py
🚧 Files skipped from review as they are similar to previous changes (1)
- tests/gateway/test_discord_send.py
8edcd97 to
7c29602
Compare
075032f to
cbc1799
Compare
cbc1799 to
08a387f
Compare
08a387f to
cb24525
Compare
cb24525 to
8028907
Compare
8028907 to
5c76350
Compare
…sion fallback) Two-part fix so attached PDFs are read reliably, platform-independently: 1. LINE adapter (#1 filename loss): the trigger path dropped the file's real fileName — every "file" cached as an anonymous .bin with media_type "file" (not application/pdf), so the agent couldn't tell it was a PDF. Now capture msg.fileName + guess MIME and pass them to cache_media_bytes(), caching as receipt.pdf / application/pdf like Telegram. _download_media returns (path, mime). 2. Gateway auto-extraction (#2/#3): GatewayRunner._auto_extract_pdf() runs at inbound time (not model-decided) — text-layer PDFs are inlined via pymupdf (free, instant); scanned PDFs with no text layer fall back to rendering each page and reading it through the vision auxiliary (_vision_read_scanned_pdf, whatever auxiliary.vision resolves to). Best-effort, never breaks the flow; pymupdf-unavailable degrades to None. Tests: TestDownloadMediaRouting (filename/mime preserved) + test_auto_pdf_ extraction (text inline / scanned->vision / non-pdf / no-pymupdf). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
5c76350 to
ba004e3
Compare
ba004e3 to
ba83694
Compare
ba83694 to
0ff56ea
Compare
0ff56ea to
47a50bc
Compare
54799e5 to
ce1aaa9
Compare
ce1aaa9 to
b50c983
Compare
b50c983 to
8679f5e
Compare
8679f5e to
3755ede
Compare
3755ede to
cabaf6d
Compare
cabaf6d to
14e3290
Compare
14e3290 to
509ce0a
Compare
509ce0a to
da8bb4e
Compare
…sion fallback) Two-part fix so attached PDFs are read reliably, platform-independently: 1. LINE adapter (#1 filename loss): the trigger path dropped the file's real fileName — every "file" cached as an anonymous .bin with media_type "file" (not application/pdf), so the agent couldn't tell it was a PDF. Now capture msg.fileName + guess MIME and pass them to cache_media_bytes(), caching as receipt.pdf / application/pdf like Telegram. _download_media returns (path, mime). 2. Gateway auto-extraction (#2/#3): GatewayRunner._auto_extract_pdf() runs at inbound time (not model-decided) — text-layer PDFs are inlined via pymupdf (free, instant); scanned PDFs with no text layer fall back to rendering each page and reading it through the vision auxiliary (_vision_read_scanned_pdf, whatever auxiliary.vision resolves to). Best-effort, never breaks the flow; pymupdf-unavailable degrades to None. Tests: TestDownloadMediaRouting (filename/mime preserved) + test_auto_pdf_ extraction (text inline / scanned->vision / non-pdf / no-pymupdf). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
da8bb4e to
c037454
Compare
When GROQ_BASE_URL is routed through a Cloudflare AI Gateway (gateway.ai.cloudflare.com), the gateway rejects the transcription request with 401 AiGatewayError (code 2009) unless a cf-aig-authorization header is present. _transcribe_groq built its OpenAI client with only the Groq api_key and no gateway header, so voice transcription broke the moment GROQ_BASE_URL was pointed at the gateway. Inject the cf-aig-authorization header from CF_AIG_TOKEN (read from env, never logged) and clear api_key so CF supplies the stored provider key — the same BYOK pattern the primary OpenAI clients use in agent_runtime_helpers. Direct api.groq.com is left untouched (no header, GROQ_API_KEY required as before); through the gateway a missing local GROQ_API_KEY no longer short-circuits since CF supplies the key. Verified live against the real gateway: header present → transcript returned; no header → 401. Also formalise the same CF BYOK injection in agent_runtime_helpers._create_openai_client into version control (it had been hand-patched onto the live box only, so the next deploy would have silently dropped it, breaking any CF-routed primary client e.g. GEMINI_BASE_URL / XAI_BASE_URL). +3 tests (CF path injects header + clears key; direct path sends no header + keeps key; CF path works without a local Groq key). No secrets in code or tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Discord does not render GitHub-flavored markdown tables — they arrive as walls of raw `|` pipes. Detect tables in outgoing messages and lift each into a discord.Embed (one inline field per column, values stacked by row), posted ahead of the surrounding prose and stripped from the message body. - Line-based scanner with strict separator-row validation (`:?-+:?` per cell) so prose containing pipes is never misdetected as a table. - Tables inside ``` fences are preserved verbatim. - Empty/whitespace text chunks are skipped so a table-only message never attempts an empty channel.send() (which Discord rejects); the embed's message id is still reported as the primary message_id. - Safety caps: 25 fields/embed, 256-char field names, 1024-char values. Adds unit + integration coverage in tests/gateway/test_discord_send.py. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Embeds detach the table from its surrounding message, so the reader loses the context that explains it. Instead, re-render each markdown table as an aligned monospace ``` code block (columns padded so the pipes line up under Discord's fixed-width font) and keep it inline. - Conversion moves into format_message(), so it also covers the forum and edit-message paths, not just send(). - send() reverts to its original form (no embed sends, no empty-chunk handling needed since tables are wrapped in place, never removed). - Detection logic (strict separator validation, code-fence safety, ragged-row padding/truncation) is unchanged and still covered. Tests updated to assert inline wrapping and exact column alignment. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
len() counts a full-width CJK glyph as 1, but Discord's monospace font renders it as 2 cells, so any column containing Chinese/Japanese/Korean text drifted out of alignment. Pad by display width instead: East-Asian Wide/Fullwidth chars count as 2, everything else as 1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lace A monospace code block can't align CJK: Discord reaches CJK glyphs via a font fallback whose width isn't a clean multiple of the space, so columns drift no matter how we pad. Render each table to a PNG with Pillow instead (pixel-accurate for any script) and split the message at the table's position so the image lands inline: text-before → image → text-after. - Deterministic render only (Pillow + a bundled CJK font), never AI. - _split_message_parts() breaks content into ordered text/table parts (tables inside ``` fences stay text); send() emits each in order. - Graceful fallback: if Pillow or a CJK font is unavailable, tables fall back to the aligned monospace code block (format_message), unchanged. - Fresh discord.File per send attempt (its stream is consumed on send). - No-table messages are byte-for-byte unchanged from before. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Table cells showed literal **markers** because the renderer drew raw cell text. Parse inline markdown into styled runs so **bold** renders as bold (header cells are already bold); *italic*, `code`, and ~~strike~~ markers are handled too (strike draws a line; italic/code just strip the markers since no CJK italic/mono face exists). The code-block fallback strips markers as well, so alignment is measured on the visible text. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make the CJK font for table rendering robust and open-source-friendly without bundling a large font or a fragile runtime download: - HERMES_TABLE_FONT / HERMES_TABLE_FONT_BOLD env override — point at any open-source font directly. - $HERMES_HOME/fonts drop-in dir — "install" a font by placing a .ttf/.otf/.ttc there; a *bold* file is paired as the bold face. - Curated system paths (Noto CJK / JhengHei / PingFang) remain the default, so existing behaviour is unchanged where a font is installed. Avoided fontconfig fc-match (its :lang=zh default resolves to non-CJK DejaVu here) and PIL glyph-coverage probing (can't distinguish .notdef), both of which would silently pick a tofu font. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Emoji live in a dedicated emoji font, not Noto Sans CJK, so a cell like "Done ✅" rendered the emoji as tofu. Add per-run emoji fallback: - _split_emoji_runs splits each cell run into text/emoji segments (ZWJ sequences, variation selectors and skin-tone modifiers stay attached). - Emoji segments render with an emoji font (Noto Color Emoji / Segoe / Apple), resolved via HERMES_EMOJI_FONT env → system paths → an *emoji* file in $HERMES_HOME/fonts. Color emoji (CBDT) only load at a bitmap strike, so we render at the strike and resize to the text line height, then alpha-paste — full-color, correctly sized, columns still aligned. - Degrades cleanly: no emoji font → emoji render as before (font tofu), everything else unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Agents commonly wrap a table in a ``` fence for display, which the splitter skipped — so it showed as raw pipes instead of an image. Now a fenced block whose entire body is a single markdown table (optionally with a language tag) is treated as a table and rendered. Real code is never matched: the check requires a header row immediately followed by a |---|---| separator and only table rows after — a shape code never has. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lets Rebase-integration fix: upstream added test_table_converted_to_bullets asserting convert_table_to_bullets output, but this branch's table→image feature deliberately supersedes it — format_message renders tables as an aligned code block (text fallback) / inline PNG (send path). Update the upstream test to the actual behavior instead of the obsolete bullet form. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Discord does not render GFM pipe tables — they arrive as walls of raw
|. This renders each table to a PNG image (deterministic Pillow render, no AI) and splits the message at the table's position so the image sits inline between the surrounding prose (text-before → image → text-after).Final approach after iterating on live feedback (see commit history): embed → code block → CJK width fix → image → inline-markdown. Embeds detached the table from its context; monospace code blocks can't align CJK (Discord's font-fallback glyphs aren't a clean multiple of the space width). A rendered image aligns any script perfectly.
What it does
_split_message_partsbreaks content into ordered text/table parts (tables inside ``` fences stay text);send()emits each in order._render_table_image— Pillow, dark theme matching Discord, per-column pixel widths, CJK via Noto Sans CJK (Linux) / JhengHei|YaHei (Win) / PingFang (mac).**bold**renders bold,~~strike~~strikes;*italic*/`code`markers are stripped (no CJK italic/mono face).format_message), CJK-width-aware.Relation to upstream
Upstream
nousresearch/hermes-agentalready solves the same problem differently: a sharedconvert_table_to_bullets()(gateway/platforms/helpers.py) that rewrites tables into bold-heading + bullet groups for both Discord and Telegram. This fork'sformat_messagetakes the image route instead. An upstream contribution would need to reconcile the two (see discussion).Tests
tests/gateway/test_discord_send.py: message splitting, inline image ordering, code-block fallback, real PNG render (when a font is present), inline-markdown parser, CJK display-width alignment. Full Discord gateway suite green (115 passed).Deployed
Verified live on the toothless gateway (Noto Sans CJK); Traditional-Chinese tables render aligned with bold cells.
🤖 Generated with Claude Code
Summary by CodeRabbit