diff --git a/src/index.ts b/src/index.ts index c19a31b..69a4020 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,7 +34,7 @@ import { startIpcWatcher } from './ipc.js'; import { findChannel, findChannelByName, - formatOutbound, + sanitizeForOutbound, normalizeMessageForDedupe, resolveChannelForDeliveryRole, } from './router.js'; @@ -98,7 +98,7 @@ export async function sendFormattedChannelMessage( logger.warn({ jid }, 'No channel owns JID, cannot send message'); return; } - const text = formatOutbound(rawText); + const text = sanitizeForOutbound(rawText); if (text) await channel.sendMessage(jid, text); } @@ -112,7 +112,7 @@ export async function sendFormattedTrackedChannelMessage( logger.warn({ jid }, 'No channel owns JID, cannot send tracked message'); return null; } - const text = formatOutbound(rawText); + const text = sanitizeForOutbound(rawText); if (!text || !channel.sendAndTrack) return null; return channel.sendAndTrack(jid, text); } @@ -128,7 +128,7 @@ export async function editFormattedTrackedChannelMessage( logger.warn({ jid }, 'No channel owns JID, cannot edit tracked message'); return; } - const text = formatOutbound(rawText); + const text = sanitizeForOutbound(rawText); if (!text || !channel.editMessage) return; await channel.editMessage(jid, messageId, text); } @@ -388,7 +388,7 @@ async function main(): Promise { sendFormattedChannelMessage(channels, jid, rawText), sendMessageViaReviewerBot: reviewerChannelForCron ? async (jid, rawText) => { - const text = formatOutbound(rawText); + const text = sanitizeForOutbound(rawText); if (text) await reviewerChannelForCron.sendMessage(jid, text); } : undefined, diff --git a/src/message-turn-controller.ts b/src/message-turn-controller.ts index be3e426..a9944b1 100644 --- a/src/message-turn-controller.ts +++ b/src/message-turn-controller.ts @@ -4,7 +4,7 @@ import { getAgentOutputText, } from './agent-output.js'; import { createScopedLogger, logger } from './logger.js'; -import { formatOutbound } from './router.js'; +import { sanitizeForOutbound } from './router.js'; import { shouldResetSessionOnAgentFailure } from './session-recovery.js'; import { TASK_STATUS_MESSAGE_PREFIX } from './task-watch-status.js'; import { formatElapsedKorean } from './utils.js'; @@ -166,7 +166,10 @@ export class MessageTurnController { } const raw = getAgentOutputText(result); - const text = raw ? formatOutbound(raw) : null; + // Use sanitize (no markdown escape) — the Discord channel boundary + // applies the final escape pass, and double-escaping would produce + // visible backslash garbage. + const text = raw ? sanitizeForOutbound(raw) : null; const attachments = getAgentOutputAttachments(result); if (raw) { diff --git a/src/router.test.ts b/src/router.test.ts index 81c29c2..325db9d 100644 --- a/src/router.test.ts +++ b/src/router.test.ts @@ -97,22 +97,22 @@ describe('findChannelForDeliveryRole', () => { }); describe('neutralizeStrayMarkdown', () => { - it('strips inline backticks', () => { + it('escapes inline backticks so they render literally', () => { expect(neutralizeStrayMarkdown('use `foo` and `bar` here')).toBe( - 'use foo and bar here', + 'use \\`foo\\` and \\`bar\\` here', ); }); - it('preserves well-formed fenced code blocks', () => { + it('preserves well-formed fenced code blocks and escapes outside prose', () => { const input = 'before\n```ts\nconst x = `tpl`;\n```\nafter `inline`'; expect(neutralizeStrayMarkdown(input)).toBe( - 'before\n```ts\nconst x = `tpl`;\n```\nafter inline', + 'before\n```ts\nconst x = `tpl`;\n```\nafter \\`inline\\`', ); }); - it('strips emphasis in an unterminated fence', () => { + it('escapes emphasis in an unterminated fence (treated as prose)', () => { expect(neutralizeStrayMarkdown('oops ```code without close')).toBe( - 'oops code without close', + 'oops \\`\\`\\`code without close', ); }); @@ -121,35 +121,37 @@ describe('neutralizeStrayMarkdown', () => { expect(neutralizeStrayMarkdown(s)).toBe(s); }); - it('strips italic asterisks', () => { + it('escapes italic asterisks', () => { expect(neutralizeStrayMarkdown('이건 *기울임* 처리')).toBe( - '이건 기울임 처리', + '이건 \\*기울임\\* 처리', ); }); - it('strips bold asterisks', () => { + it('escapes bold asterisks', () => { expect(neutralizeStrayMarkdown('이건 **강조** 처리')).toBe( - '이건 강조 처리', + '이건 \\*\\*강조\\*\\* 처리', ); }); - it('strips bold-italic triple asterisks', () => { - expect(neutralizeStrayMarkdown('***hybrid***')).toBe('hybrid'); + it('escapes bold-italic triple asterisks', () => { + expect(neutralizeStrayMarkdown('***hybrid***')).toBe( + '\\*\\*\\*hybrid\\*\\*\\*', + ); }); - it('strips italic underscores at word boundaries', () => { + it('escapes italic underscores at word boundaries', () => { expect(neutralizeStrayMarkdown('이건 _italic_ 처리')).toBe( - '이건 italic 처리', + '이건 \\_italic\\_ 처리', ); }); - it('preserves single underscores in snake_case identifiers', () => { + it('escapes every underscore including snake_case identifiers', () => { expect(neutralizeStrayMarkdown('use foo_bar_baz here')).toBe( - 'use foo_bar_baz here', + 'use foo\\_bar\\_baz here', ); }); - it('escapes underscores in absolute paths so Discord does not italicize them', () => { + it('escapes underscores in absolute paths', () => { expect( neutralizeStrayMarkdown( '경로: /home/claude/EJClaw/groups/mc_the_scene_plugin/src/main_file.ts', @@ -159,70 +161,85 @@ describe('neutralizeStrayMarkdown', () => { ); }); - it('escapes underscores in Windows paths', () => { + it('escapes underscores and backslashes in Windows paths', () => { expect( neutralizeStrayMarkdown( '경로: C:\\Custom_vscode\\minecraft_launcher\\src\\main_file.ts', ), ).toBe( - '경로: C:\\Custom\\_vscode\\minecraft\\_launcher\\src\\main\\_file.ts', + '경로: C:\\\\Custom\\_vscode\\\\minecraft\\_launcher\\\\src\\\\main\\_file.ts', ); }); - it('strips double-underscore underline markers', () => { - expect(neutralizeStrayMarkdown('__bold__ text')).toBe('bold text'); + it('escapes double-underscore underline markers', () => { + expect(neutralizeStrayMarkdown('__bold__ text')).toBe( + '\\_\\_bold\\_\\_ text', + ); }); - it('strips heading hashes at line start', () => { + it('escapes heading hashes at line start', () => { expect(neutralizeStrayMarkdown('## 빌드 타임 메타\n본문')).toBe( - '빌드 타임 메타\n본문', + '\\## 빌드 타임 메타\n본문', ); }); - it('strips block-quote markers', () => { + it('escapes block-quote markers at line start', () => { expect(neutralizeStrayMarkdown('> quoted line\n> another')).toBe( - 'quoted line\nanother', + '\\> quoted line\n\\> another', ); }); - it('strips strikethrough', () => { + it('leaves mid-line hash and gt unchanged (not heading/quote markers)', () => { + expect(neutralizeStrayMarkdown('a #tag here and a > b')).toBe( + 'a #tag here and a > b', + ); + }); + + it('escapes strikethrough tildes', () => { expect(neutralizeStrayMarkdown('이건 ~~취소선~~ 입니다')).toBe( - '이건 취소선 입니다', + '이건 \\~\\~취소선\\~\\~ 입니다', ); }); - it('strips spoiler markers', () => { - expect(neutralizeStrayMarkdown('||비밀||')).toBe('비밀'); + it('escapes spoiler pipes', () => { + expect(neutralizeStrayMarkdown('||비밀||')).toBe('\\|\\|비밀\\|\\|'); }); - it('preserves Discord mention syntax', () => { + it('preserves Discord mention syntax (no markdown delimiters inside)', () => { expect(neutralizeStrayMarkdown('<@123> hi <#456>')).toBe( '<@123> hi <#456>', ); }); - it('preserves code fence content while stripping prose emphasis', () => { + it('preserves code fence content while escaping prose emphasis', () => { const input = '**bold** outside\n```py\nx = *2\n```\nand *more*'; expect(neutralizeStrayMarkdown(input)).toBe( - 'bold outside\n```py\nx = *2\n```\nand more', + '\\*\\*bold\\*\\* outside\n```py\nx = *2\n```\nand \\*more\\*', ); }); + it('escapes literal backslashes first so new escapes are not doubled', () => { + // Single literal backslash before an asterisk: the backslash must + // double (so Discord prints a literal `\`) and then the asterisk picks + // up its own escape. + expect(neutralizeStrayMarkdown('a\\b *c*')).toBe('a\\\\b \\*c\\*'); + }); + it('back-compat alias still exported', () => { - expect(neutralizeStrayBackticks('*x*')).toBe('x'); + expect(neutralizeStrayBackticks('*x*')).toBe('\\*x\\*'); }); }); describe('formatOutbound', () => { - it('runs the full pipeline and strips stray backticks', () => { + it('runs the full pipeline and escapes stray backticks', () => { const raw = 'noteUse `foo` in your code.'; - expect(formatOutbound(raw)).toBe('Use foo in your code.'); + expect(formatOutbound(raw)).toBe('Use \\`foo\\` in your code.'); }); it('preserves fenced code blocks through the pipeline', () => { const raw = 'See:\n```js\nconst a = 1;\n```\nor `inline`.'; expect(formatOutbound(raw)).toBe( - 'See:\n```js\nconst a = 1;\n```\nor inline.', + 'See:\n```js\nconst a = 1;\n```\nor \\`inline\\`.', ); }); }); diff --git a/src/router.ts b/src/router.ts index 0a05343..fcf5d65 100644 --- a/src/router.ts +++ b/src/router.ts @@ -78,55 +78,46 @@ export function stripToolCallLeaks(text: string): string { return stripped.replace(/\n{3,}/g, '\n\n').trim(); } -function escapePathMarkdownUnderscores(segment: string): string { - return segment.replace( - /(^|[\s[{<])((?:[A-Za-z]:[\\/]|~?[\\/]|\.{1,2}[\\/])\S*_\S*_\S*)/g, - (_match, prefix: string, filePath: string) => - `${prefix}${filePath.replace(/_/g, '\\_')}`, - ); -} - /** - * Strip Discord markdown emphasis/heading markers that mangle plain-prose - * output when wrapping across lines. Used inside non-code segments only — - * callers should preserve fenced code blocks separately. + * Escape Discord markdown delimiters in a prose segment so the source + * characters render literally instead of triggering formatting. Used on + * non-code segments only — callers must preserve fenced code blocks + * separately. * * Discord rendering reference: * *italic*, _italic_, **bold**, ***bold-italic*** * __underline__ ~~strike~~ ||spoiler|| - * # H1 ## H2 ### H3 (at line start) + * `inline` # H1 ## H2 ### H3 (at line start) * > quote, >>> multi-line quote (at line start) * - * `<@id>`/`<#id>`/`<:emoji:id>` mentions and `[text](url)` links are left - * untouched (handled elsewhere or harmless as raw text). + * `<@id>`/`<#id>`/`<:emoji:id>` mentions and `[text](url)` links contain no + * markdown delimiters and pass through unchanged. */ -function stripMarkdownInProse(segment: string): string { +function escapeMarkdownInProse(segment: string): string { return ( - escapePathMarkdownUnderscores(segment) - // Inline backtick code — agents abuse this to highlight identifiers - // and Discord then fragments coloring across line wraps. - .replace(/`/g, '') - // Strikethrough and spoiler wrappers (always paired punctuation) - .replace(/~~/g, '') - .replace(/\|\|/g, '') - // Heading markers at the start of a line (after optional whitespace) - .replace(/^[ \t]*#{1,3}[ \t]+/gm, '') - // Block quote markers at the start of a line - .replace(/^[ \t]*>{1,3}[ \t]?/gm, '') - // Bold/italic asterisks — Discord italicizes even mid-word (a*b*c), - // so just remove every '*'. Math/code uses live in fenced blocks. - .replace(/\*/g, '') - // Underline/bold uses '__'; remove paired runs of 2+ underscores. - .replace(/_{2,}/g, '') - // Italic '_word_' — only strip when the underscores are at word - // boundaries (so snake_case identifiers survive intact). - .replace(/(^|[^A-Za-z0-9_])_([^_\n]+?)_(?=$|[^A-Za-z0-9_])/g, '$1$2') + segment + // Escape backslashes first so we don't double-escape the backslashes + // we are about to introduce for the other markers. + .replace(/\\/g, '\\\\') + // Inline markdown delimiters — escape positionally so Discord prints + // them as literal characters. + .replace(/`/g, '\\`') + .replace(/\*/g, '\\*') + .replace(/_/g, '\\_') + .replace(/~/g, '\\~') + .replace(/\|/g, '\\|') + // Heading hashes — only meaningful at the start of a line (after + // optional indent) and followed by a space. Escape only the first + // hash; the rest are now harmless literal characters. + .replace(/^([ \t]*)(#)(?=#{0,2}[ \t])/gm, '$1\\$2') + // Block-quote markers — same line-start constraint. + .replace(/^([ \t]*)(>)(?=>{0,2}([ \t]|$))/gm, '$1\\$2') ); } /** - * Neutralize stray Discord markdown in prose while preserving well-formed - * triple-backtick fenced code blocks (legit code snippets). + * Escape stray Discord markdown delimiters in prose while preserving + * well-formed triple-backtick fenced code blocks (legit code snippets). */ export function neutralizeStrayMarkdown(text: string): string { if (!text) return text; @@ -135,14 +126,14 @@ export function neutralizeStrayMarkdown(text: string): string { while (i < text.length) { const fenceStart = text.indexOf('```', i); if (fenceStart === -1) { - parts.push(stripMarkdownInProse(text.slice(i))); + parts.push(escapeMarkdownInProse(text.slice(i))); break; } - parts.push(stripMarkdownInProse(text.slice(i, fenceStart))); + parts.push(escapeMarkdownInProse(text.slice(i, fenceStart))); const fenceEnd = text.indexOf('```', fenceStart + 3); if (fenceEnd === -1) { // Unterminated fence — not a real code block; treat as prose. - parts.push(stripMarkdownInProse(text.slice(fenceStart))); + parts.push(escapeMarkdownInProse(text.slice(fenceStart))); break; } // Preserve the entire fenced block including delimiters. @@ -155,13 +146,33 @@ export function neutralizeStrayMarkdown(text: string): string { /** @deprecated Kept for back-compat; use neutralizeStrayMarkdown. */ export const neutralizeStrayBackticks = neutralizeStrayMarkdown; -export function formatOutbound(rawText: string): string { +/** + * Sanitize raw agent output for internal use (storage, IPC, intermediate + * channel buffers). Strips internal tags + tool-call leaks and redacts + * secrets, but does NOT touch markdown delimiters. + * + * Use this when the text will pass through another `formatOutbound` call + * downstream (e.g., the Discord channel boundary). Applying the markdown + * escape twice would double-escape backslashes and produce visible garbage + * in Discord. + */ +export function sanitizeForOutbound(rawText: string): string { let text = stripInternalTags(rawText); if (!text) return ''; text = stripToolCallLeaks(text); if (!text) return ''; - text = redactSecrets(text); - return neutralizeStrayMarkdown(text); + return redactSecrets(text); +} + +/** + * Full outbound formatting for the final Discord-send boundary: sanitize + * + escape Discord markdown delimiters. Call this exactly once per + * outbound message, at the channel boundary. + */ +export function formatOutbound(rawText: string): string { + const sanitized = sanitizeForOutbound(rawText); + if (!sanitized) return ''; + return neutralizeStrayMarkdown(sanitized); } export function findChannel( diff --git a/src/session-commands.ts b/src/session-commands.ts index 6c57181..0b687b1 100644 --- a/src/session-commands.ts +++ b/src/session-commands.ts @@ -1,7 +1,7 @@ import { getAgentOutputText } from './agent-output.js'; import type { NewMessage } from './types.js'; import { logger } from './logger.js'; -import { formatOutbound } from './router.js'; +import { sanitizeForOutbound } from './router.js'; import type { StructuredAgentOutput } from './types.js'; const SESSION_COMMAND_CONTROL_PATTERNS = [ @@ -84,7 +84,8 @@ export interface SessionCommandDeps { function resultToText(result: string | object | null | undefined): string { if (!result) return ''; const raw = typeof result === 'string' ? result : JSON.stringify(result); - return formatOutbound(raw); + // sanitize only — the channel boundary applies the markdown escape pass. + return sanitizeForOutbound(raw); } function agentResultToText(result: AgentResult): string { @@ -92,7 +93,7 @@ function agentResultToText(result: AgentResult): string { result: result.result ?? null, output: result.output, }); - return raw ? formatOutbound(raw) : ''; + return raw ? sanitizeForOutbound(raw) : ''; } /**