From e5d82eb044d51e9d9a6015ce3bf004a6a7ce0807 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:58:44 +0800 Subject: [PATCH] feat(discord): post a Markdown table as an aligned code block --- docs/discord-bridge.md | 2 +- src/discord-bridge.ts | 49 ++++++++++++++++++++++++++++- test/discord-bridge.test.ts | 63 +++++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 2 deletions(-) diff --git a/docs/discord-bridge.md b/docs/discord-bridge.md index 622f2b2e..680d4fb2 100644 --- a/docs/discord-bridge.md +++ b/docs/discord-bridge.md @@ -8,7 +8,7 @@ Codeman can post to a private Discord server when a session needs you, give ever ## What it does - Posts when a session asks for permission, asks a question, goes idle or errors. A finished turn ("stops") is an `info` event, so it posts only when **Which events** is set to Everything. -- Posts a finished turn as the CLI's whole last message, with its bold, lists and numbered questions kept, split over several posts if it is long. This reads the CLI's transcript where it keeps one (Claude Code, Codex, DeepSeek Harness, OMP) and otherwise splits the screen into turns (OpenCode, Gemini, Antigravity, Pi, Grok, Copilot), which can start mid-message when a long answer has scrolled off. A shell, and a permission or question dialog, show the last lines of the screen instead, without the prompt row and status lines under them. +- Posts a finished turn as the CLI's whole last message, with its bold, lists and numbered questions kept (a Markdown table, which Discord cannot draw, becomes an aligned code block), split over several posts if it is long. This reads the CLI's transcript where it keeps one (Claude Code, Codex, DeepSeek Harness, OMP) and otherwise splits the screen into turns (OpenCode, Gemini, Antigravity, Pi, Grok, Copilot), which can start mid-message when a long answer has scrolled off. A shell, and a permission or question dialog, show the last lines of the screen instead, without the prompt row and status lines under them. - Creates one thread per tab in a single parent channel, so a reply always reaches exactly one session. - Types your reply into that session as a single line followed by Enter. A single digit sent while a permission or question is waiting goes as the key alone, without Enter, because the dialog acts on the keypress. - Lets a CLI post a deliberate message (for example "press the reset button on the case") with the `codeman notify` command, which calls `POST /api/sessions/:id/discord-notify`. Each tab may post 6 a minute; past that the command says "too many messages", so a looping agent cannot ping your phone without end. diff --git a/src/discord-bridge.ts b/src/discord-bridge.ts index 6c46b0d9..be523876 100644 --- a/src/discord-bridge.ts +++ b/src/discord-bridge.ts @@ -213,6 +213,53 @@ export function screenTail(screen: string, maxLines = 14, maxChars = 900, compos return out.join('\n').slice(-maxChars); } +/** + * Discord does not render Markdown tables: a `| a | b |` block shows as raw pipes. Each table outside a + * code fence becomes a code block with the columns padded to their widest cell (right-aligned where the + * separator row says `---:`), which a monospace font lines up. Bold and code marks inside a cell are + * dropped, since a code block would show them literally. + */ +export function tablesToCodeBlocks(text: string): string { + const cells = (row: string): string[] => { + let r = row.trim(); + if (r.startsWith('|')) r = r.slice(1); + if (r.endsWith('|') && !r.endsWith('\\|')) r = r.slice(0, -1); + return r.split(/(? + c + .replace(/\\\|/g, '|') + .replace(/\*\*|`/g, '') + .trim() + ); + }; + const isSeparator = (row: string) => row.includes('|') && /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/.test(row); + const lines = text.split('\n'); + const out: string[] = []; + let fence = false; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (/^\s*```/.test(line)) fence = !fence; + if (fence || !line.includes('|') || i + 1 >= lines.length || !isSeparator(lines[i + 1])) { + out.push(line); + continue; + } + const header = cells(line); + const align = cells(lines[i + 1]).map((c) => (c.endsWith(':') ? 'right' : 'left')); + const rows = [header]; + let j = i + 2; + for (; j < lines.length && lines[j].includes('|') && lines[j].trim() !== ''; j++) rows.push(cells(lines[j])); + const cols = Math.max(...rows.map((r) => r.length)); + const width = Array.from({ length: cols }, (_, c) => Math.max(1, ...rows.map((r) => [...(r[c] ?? '')].length))); + const pad = (v: string, c: number) => (align[c] === 'right' ? v.padStart(width[c]) : v.padEnd(width[c])); + const fmt = (r: string[]) => + Array.from({ length: cols }, (_, c) => pad((r[c] ?? '').replace(/```/g, '\u2019\u2019\u2019'), c)) + .join(' ') + .trimEnd(); + out.push('```', fmt(header), width.map((w) => '-'.repeat(w)).join(' '), ...rows.slice(1).map(fmt), '```'); + i = j - 1; + } + return out.join('\n'); +} + /** * Split a long message into posts of at most `max` characters, on blank lines first, then lines, then * (for one huge line) by length. A code fence the cut lands inside is closed and reopened, so each post @@ -1030,7 +1077,7 @@ export class DiscordBridge { msg = { ...msg, tail: undefined }; // said already else { this.lastFull.set(msg.sessionId, full); - chunks = splitForDiscord(full); + chunks = splitForDiscord(tablesToCodeBlocks(full)); msg = { ...msg, tail: undefined }; } } diff --git a/test/discord-bridge.test.ts b/test/discord-bridge.test.ts index 5d11b59e..7112342c 100644 --- a/test/discord-bridge.test.ts +++ b/test/discord-bridge.test.ts @@ -25,6 +25,7 @@ import { safeAttachmentName, screenTail, splitForDiscord, + tablesToCodeBlocks, DEFAULT_DISCORD_CONFIG, DiscordApi, DiscordBridge, @@ -579,6 +580,24 @@ describe('DiscordBridge', () => { b.stop(); }); + it('posts a Markdown table as an aligned code block, since Discord does not render tables', async () => { + const b = bridge(); + const table = [ + '| Variant | Decode (tokens/s) |', + '|---|---:|', + '| kv-f16 | 557.0 |', + '| n-gram mod | 550.2 |', + ].join('\n'); + await b.notify({ ...MSG, fullText: async () => `Results:\n\n${table}` }); + const posted = posts + .filter((p) => p.path.endsWith('/messages')) + .map((p) => String(p.body?.content)) + .join('\n'); + expect(posted).toContain('```\nVariant Decode (tokens/s)\n'); + expect(posted).not.toContain('|---'); + b.stop(); + }); + it('does not post the same message twice (idle, then stop), and falls back to the tail without a transcript', async () => { const b = bridge(); await b.notify({ ...MSG, fullText: async () => 'Short answer.' }); @@ -1152,6 +1171,50 @@ describe('DiscordBridge files and startup hint', () => { }); }); +describe('tablesToCodeBlocks', () => { + it('turns a Markdown table into an aligned code block, dropping bold and code marks', () => { + const md = [ + 'Results:', + '', + '| Variant | Decode (tokens/s) |', + '|---|---:|', + '| `kv-f16` | 557.0 |', + '| **n-gram simple** | 554.5 |', + '| n-gram mod | 550.2 |', + '', + 'Done.', + ].join('\n'); + expect(tablesToCodeBlocks(md)).toBe( + [ + 'Results:', + '', + '```', + 'Variant Decode (tokens/s)', + '------------- -----------------', + 'kv-f16 557.0', + 'n-gram simple 554.5', + 'n-gram mod 550.2', + '```', + '', + 'Done.', + ].join('\n') + ); + }); + + it('leaves a table inside a code fence, prose with pipes and a table-less message alone', () => { + const fenced = ['```', '| a | b |', '|---|---|', '| 1 | 2 |', '```'].join('\n'); + expect(tablesToCodeBlocks(fenced)).toBe(fenced); + const prose = 'Use a | b to pipe.\nThen c | d.'; + expect(tablesToCodeBlocks(prose)).toBe(prose); + }); + + it('pads a short row and keeps an escaped pipe in a cell', () => { + const out = tablesToCodeBlocks(['| a | b |', '|---|---|', '| x \\| y |', '| p | q |'].join('\n')); + expect(out).toContain('x | y'); + expect(out.split('\n').every((l) => !/\s$/.test(l))).toBe(true); + }); +}); + describe('splitForDiscord', () => { it('keeps a short message whole', () => { expect(splitForDiscord('Line one\n\n**Two**\n- a\n- b')).toEqual(['Line one\n\n**Two**\n- a\n- b']);