fix(router): escape Discord markdown delimiters instead of stripping

This commit is contained in:
claude-bot
2026-06-01 17:49:54 +09:00
parent ccc747ae1c
commit 5af1c5b1d1
5 changed files with 234 additions and 9 deletions

View File

@@ -78,7 +78,85 @@ export function stripToolCallLeaks(text: string): string {
return stripped.replace(/\n{3,}/g, '\n\n').trim();
}
export function formatOutbound(rawText: string): string {
/**
* 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||
* `inline` # H1 ## H2 ### H3 (at line start)
* > quote, >>> multi-line quote (at line start)
*
* `<@id>`/`<#id>`/`<:emoji:id>` mentions and `[text](url)` links contain no
* markdown delimiters and pass through unchanged.
*/
function escapeMarkdownInProse(segment: string): string {
return (
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')
);
}
/**
* 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;
const parts: string[] = [];
let i = 0;
while (i < text.length) {
const fenceStart = text.indexOf('```', i);
if (fenceStart === -1) {
parts.push(escapeMarkdownInProse(text.slice(i)));
break;
}
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(escapeMarkdownInProse(text.slice(fenceStart)));
break;
}
// Preserve the entire fenced block including delimiters.
parts.push(text.slice(fenceStart, fenceEnd + 3));
i = fenceEnd + 3;
}
return parts.join('');
}
/** @deprecated Kept for back-compat; use neutralizeStrayMarkdown. */
export const neutralizeStrayBackticks = neutralizeStrayMarkdown;
/**
* 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);
@@ -86,6 +164,17 @@ export function formatOutbound(rawText: string): string {
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(
channels: Channel[],
jid: string,