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

@@ -34,7 +34,7 @@ import {
deliverCanonicalOutboundMessage, deliverCanonicalOutboundMessage,
deliverIpcOutboundMessage, deliverIpcOutboundMessage,
} from './ipc-outbound-delivery.js'; } from './ipc-outbound-delivery.js';
import { findChannel, formatOutbound } from './router.js'; import { findChannel, sanitizeForOutbound } from './router.js';
import { import {
buildRestartAnnouncement, buildRestartAnnouncement,
buildInterruptedRestartAnnouncement, buildInterruptedRestartAnnouncement,
@@ -99,7 +99,7 @@ export async function sendFormattedTrackedChannelMessage(
logger.warn({ jid }, 'No channel owns JID, cannot send tracked message'); logger.warn({ jid }, 'No channel owns JID, cannot send tracked message');
return null; return null;
} }
const text = formatOutbound(rawText); const text = sanitizeForOutbound(rawText);
if (!text || !channel.sendAndTrack) return null; if (!text || !channel.sendAndTrack) return null;
return channel.sendAndTrack(jid, text); return channel.sendAndTrack(jid, text);
} }
@@ -115,7 +115,7 @@ export async function editFormattedTrackedChannelMessage(
logger.warn({ jid }, 'No channel owns JID, cannot edit tracked message'); logger.warn({ jid }, 'No channel owns JID, cannot edit tracked message');
return; return;
} }
const text = formatOutbound(rawText); const text = sanitizeForOutbound(rawText);
if (!text || !channel.editMessage) return; if (!text || !channel.editMessage) return;
await channel.editMessage(jid, messageId, text); await channel.editMessage(jid, messageId, text);
} }
@@ -146,7 +146,7 @@ async function deliverFormattedCanonicalMessage(
rawText: string, rawText: string,
deliveryRole?: 'owner' | 'reviewer' | 'arbiter', deliveryRole?: 'owner' | 'reviewer' | 'arbiter',
): Promise<void> { ): Promise<void> {
const text = formatOutbound(rawText); const text = sanitizeForOutbound(rawText);
if (!text) return; if (!text) return;
await deliverCanonicalOutboundMessage( await deliverCanonicalOutboundMessage(
{ jid, text, deliveryRole }, { jid, text, deliveryRole },

View File

@@ -4,7 +4,7 @@ import {
getAgentOutputText, getAgentOutputText,
} from './agent-output.js'; } from './agent-output.js';
import { createScopedLogger, logger } from './logger.js'; import { createScopedLogger, logger } from './logger.js';
import { formatOutbound } from './router.js'; import { sanitizeForOutbound } from './router.js';
import { shouldResetSessionOnAgentFailure } from './session-recovery.js'; import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
import { TASK_STATUS_MESSAGE_PREFIX } from './task-watch-status.js'; import { TASK_STATUS_MESSAGE_PREFIX } from './task-watch-status.js';
import { formatElapsedKorean } from './utils.js'; import { formatElapsedKorean } from './utils.js';
@@ -169,7 +169,10 @@ export class MessageTurnController {
} }
const raw = getAgentOutputText(result); 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); const attachments = getAgentOutputAttachments(result);
if (raw) { if (raw) {

View File

@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
import { import {
findChannelForDeliveryRole, findChannelForDeliveryRole,
neutralizeStrayBackticks,
neutralizeStrayMarkdown,
resolveChannelForDeliveryRole, resolveChannelForDeliveryRole,
} from './router.js'; } from './router.js';
import { type Channel } from './types.js'; import { type Channel } from './types.js';
@@ -92,3 +94,134 @@ describe('findChannelForDeliveryRole', () => {
); );
}); });
}); });
describe('neutralizeStrayMarkdown', () => {
it('escapes inline backticks so they render literally', () => {
expect(neutralizeStrayMarkdown('use `foo` and `bar` here')).toBe(
'use \\`foo\\` and \\`bar\\` here',
);
});
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\\`',
);
});
it('escapes emphasis in an unterminated fence (treated as prose)', () => {
expect(neutralizeStrayMarkdown('oops ```code without close')).toBe(
'oops \\`\\`\\`code without close',
);
});
it('passes through plain text unchanged', () => {
const s = 'plain text with no markup';
expect(neutralizeStrayMarkdown(s)).toBe(s);
});
it('escapes italic asterisks', () => {
expect(neutralizeStrayMarkdown('이건 *기울임* 처리')).toBe(
'이건 \\*기울임\\* 처리',
);
});
it('escapes bold asterisks', () => {
expect(neutralizeStrayMarkdown('이건 **강조** 처리')).toBe(
'이건 \\*\\*강조\\*\\* 처리',
);
});
it('escapes bold-italic triple asterisks', () => {
expect(neutralizeStrayMarkdown('***hybrid***')).toBe(
'\\*\\*\\*hybrid\\*\\*\\*',
);
});
it('escapes italic underscores at word boundaries', () => {
expect(neutralizeStrayMarkdown('이건 _italic_ 처리')).toBe(
'이건 \\_italic\\_ 처리',
);
});
it('escapes every underscore including snake_case identifiers', () => {
expect(neutralizeStrayMarkdown('use foo_bar_baz here')).toBe(
'use foo\\_bar\\_baz here',
);
});
it('escapes underscores in absolute paths', () => {
expect(
neutralizeStrayMarkdown(
'경로: /home/claude/EJClaw/groups/mc_the_scene_plugin/src/main_file.ts',
),
).toBe(
'경로: /home/claude/EJClaw/groups/mc\\_the\\_scene\\_plugin/src/main\\_file.ts',
);
});
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',
);
});
it('escapes double-underscore underline markers', () => {
expect(neutralizeStrayMarkdown('__bold__ text')).toBe(
'\\_\\_bold\\_\\_ text',
);
});
it('escapes heading hashes at line start', () => {
expect(neutralizeStrayMarkdown('## 빌드 타임 메타\n본문')).toBe(
'\\## 빌드 타임 메타\n본문',
);
});
it('escapes block-quote markers at line start', () => {
expect(neutralizeStrayMarkdown('> quoted line\n> another')).toBe(
'\\> quoted line\n\\> another',
);
});
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('escapes spoiler pipes', () => {
expect(neutralizeStrayMarkdown('||비밀||')).toBe('\\|\\|비밀\\|\\|');
});
it('preserves Discord mention syntax (no markdown delimiters inside)', () => {
expect(neutralizeStrayMarkdown('<@123> hi <#456>')).toBe(
'<@123> hi <#456>',
);
});
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\\*',
);
});
it('escapes literal backslashes first so new escapes are not doubled', () => {
expect(neutralizeStrayMarkdown('a\\b *c*')).toBe('a\\\\b \\*c\\*');
});
it('back-compat alias still exported', () => {
expect(neutralizeStrayBackticks('*x*')).toBe('\\*x\\*');
});
});

View File

@@ -78,7 +78,85 @@ export function stripToolCallLeaks(text: string): string {
return stripped.replace(/\n{3,}/g, '\n\n').trim(); 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); let text = stripInternalTags(rawText);
if (!text) return ''; if (!text) return '';
text = stripToolCallLeaks(text); text = stripToolCallLeaks(text);
@@ -86,6 +164,17 @@ export function formatOutbound(rawText: string): string {
return redactSecrets(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( export function findChannel(
channels: Channel[], channels: Channel[],
jid: string, jid: string,

View File

@@ -1,7 +1,7 @@
import { getAgentOutputText } from './agent-output.js'; import { getAgentOutputText } from './agent-output.js';
import type { NewMessage } from './types.js'; import type { NewMessage } from './types.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
import { formatOutbound } from './router.js'; import { sanitizeForOutbound } from './router.js';
import type { StructuredAgentOutput } from './types.js'; import type { StructuredAgentOutput } from './types.js';
const SESSION_COMMAND_CONTROL_PATTERNS = [ const SESSION_COMMAND_CONTROL_PATTERNS = [
@@ -86,7 +86,7 @@ function agentResultToText(result: AgentResult): string {
result: result.result ?? null, result: result.result ?? null,
output: result.output, output: result.output,
}); });
return raw ? formatOutbound(raw) : ''; return raw ? sanitizeForOutbound(raw) : '';
} }
/** /**