Skip to content

feat(discord): render markdown tables as inline images (CJK-aware, markdown-aware) - #1

Open
sam7894604 wants to merge 11 commits into
mainfrom
claude/relaxed-sanderson-10d741
Open

feat(discord): render markdown tables as inline images (CJK-aware, markdown-aware)#1
sam7894604 wants to merge 11 commits into
mainfrom
claude/relaxed-sanderson-10d741

Conversation

@sam7894604

@sam7894604 sam7894604 commented Jul 1, 2026

Copy link
Copy Markdown
Owner

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_parts breaks 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).
  • Inline markdown in cells: **bold** renders bold, ~~strike~~ strikes; *italic* / `code` markers are stripped (no CJK italic/mono face).
  • Graceful fallback: no Pillow/font → aligned monospace code block (format_message), CJK-width-aware.
  • No-table messages are byte-for-byte unchanged.

Relation to upstream

Upstream nousresearch/hermes-agent already solves the same problem differently: a shared convert_table_to_bullets() (gateway/platforms/helpers.py) that rewrites tables into bold-heading + bullet groups for both Discord and Telegram. This fork's format_message takes 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

  • Bug Fixes
    • Discord messages now preserve table formatting more reliably, including tables mixed with prose or code blocks.
    • Large or complex messages are sent in a more stable order, with improved handling when replies can’t be threaded.
    • Tables may now appear as images when possible, with a readable monospace fallback when image rendering isn’t available.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

DiscordAdapter 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.

Changes

Discord table rendering and send flow

Layer / File(s) Summary
Table reflow and rendering
plugins/platforms/discord/adapter.py
Adds table detection, display-width-aware alignment, fenced-block preservation, PNG table rendering, font discovery, and routes format_message() through the table converter.
Outgoing item assembly and send flow
plugins/platforms/discord/adapter.py
Builds ordered text and image items, selects per-item reply references, retries rejected referenced sends without references, and adds pacing between multipart sends.
Table conversion and send tests
tests/gateway/test_discord_send.py
Adds coverage for aligned table output, fence preservation, split-message parsing, image sends, fallback code blocks, PNG output checks, and font discovery behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 45.45% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: Discord markdown tables are rendered as inline images with CJK and markdown-aware handling.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/relaxed-sanderson-10d741

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

🔎 Lint report: claude/relaxed-sanderson-10d741 vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 11379 on HEAD, 11373 on base (🆕 +6)

🆕 New issues (3):

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.py
  • tests/gateway/test_discord_send.py

Comment thread plugins/platforms/discord/adapter.py Outdated
sam7894604 pushed a commit that referenced this pull request Jul 1, 2026
… 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 win

Adjacent 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.py
  • tests/gateway/test_discord_send.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.py
  • tests/gateway/test_discord_send.py

Comment on lines +3762 to +3775
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})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 win

Bound table PNG dimensions before allocating.

width/height come 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 return None so 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 data

Also 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.py
  • tests/gateway/test_discord_send.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/gateway/test_discord_send.py

@sam7894604 sam7894604 changed the title feat(discord): auto-convert markdown tables to embed fields feat(discord): render markdown tables as inline images (CJK-aware, markdown-aware) Jul 1, 2026
@sam7894604
sam7894604 force-pushed the main branch 2 times, most recently from 8edcd97 to 7c29602 Compare July 9, 2026 16:25
@sam7894604
sam7894604 force-pushed the claude/relaxed-sanderson-10d741 branch from 075032f to cbc1799 Compare July 9, 2026 16:39
@sam7894604
sam7894604 force-pushed the claude/relaxed-sanderson-10d741 branch from cbc1799 to 08a387f Compare July 10, 2026 13:59
@sam7894604
sam7894604 force-pushed the claude/relaxed-sanderson-10d741 branch from 08a387f to cb24525 Compare July 10, 2026 20:32
@sam7894604
sam7894604 force-pushed the claude/relaxed-sanderson-10d741 branch from cb24525 to 8028907 Compare July 11, 2026 20:37
@sam7894604
sam7894604 force-pushed the claude/relaxed-sanderson-10d741 branch from 8028907 to 5c76350 Compare July 12, 2026 20:26
sam7894604 added a commit that referenced this pull request Jul 13, 2026
…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>
@sam7894604
sam7894604 force-pushed the claude/relaxed-sanderson-10d741 branch from 5c76350 to ba004e3 Compare July 13, 2026 20:31
@sam7894604
sam7894604 force-pushed the claude/relaxed-sanderson-10d741 branch from ba004e3 to ba83694 Compare July 14, 2026 20:25
@sam7894604
sam7894604 force-pushed the claude/relaxed-sanderson-10d741 branch from ba83694 to 0ff56ea Compare July 15, 2026 20:26
@sam7894604
sam7894604 force-pushed the claude/relaxed-sanderson-10d741 branch from 0ff56ea to 47a50bc Compare July 17, 2026 20:40
@sam7894604
sam7894604 force-pushed the claude/relaxed-sanderson-10d741 branch from 54799e5 to ce1aaa9 Compare August 1, 2026 20:25
@sam7894604
sam7894604 force-pushed the claude/relaxed-sanderson-10d741 branch from ce1aaa9 to b50c983 Compare August 2, 2026 21:04
@sam7894604
sam7894604 force-pushed the claude/relaxed-sanderson-10d741 branch from b50c983 to 8679f5e Compare August 3, 2026 20:41
@sam7894604
sam7894604 force-pushed the claude/relaxed-sanderson-10d741 branch from 8679f5e to 3755ede Compare August 4, 2026 20:25
@sam7894604
sam7894604 force-pushed the claude/relaxed-sanderson-10d741 branch from 3755ede to cabaf6d Compare August 5, 2026 20:23
@sam7894604
sam7894604 force-pushed the claude/relaxed-sanderson-10d741 branch from cabaf6d to 14e3290 Compare August 6, 2026 20:17
@sam7894604
sam7894604 force-pushed the claude/relaxed-sanderson-10d741 branch from 14e3290 to 509ce0a Compare August 7, 2026 20:40
@sam7894604
sam7894604 force-pushed the claude/relaxed-sanderson-10d741 branch from 509ce0a to da8bb4e Compare August 8, 2026 20:50
sam7894604 added a commit that referenced this pull request Aug 9, 2026
…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>
@sam7894604
sam7894604 force-pushed the claude/relaxed-sanderson-10d741 branch from da8bb4e to c037454 Compare August 9, 2026 20:16
sam7894604 and others added 11 commits August 14, 2026 05:04
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant