fix(router): escape Discord markdown delimiters instead of stripping
Previously formatOutbound stripped `*`, `_`, `~`, `|`, `` ` ``, `#`, `>`
from prose so they wouldn't trigger Discord formatting. That worked but
silently lost information — `**DONE**`, `STEP_DONE`, ~~strike~~ all
arrived in Discord with the source characters missing.
Switch to backslash-escape so the literal source text shows up while
still suppressing the formatting interpretation. Triple-backtick fenced
code blocks are still preserved verbatim.
Escape is non-idempotent (running it twice double-escapes backslashes),
so split the pipeline:
- sanitizeForOutbound: strip internal tags + tool-call leaks + redact
secrets. Use this for intermediate text that will pass through
another formatOutbound call downstream (work-item storage, channel
wrappers, session-command output).
- formatOutbound: sanitize + neutralizeStrayMarkdown. Reserved for the
single Discord-send boundary in channels/discord.ts.
Internal callers (message-turn-controller, session-commands, the
sendFormattedChannelMessage / sendFormattedTrackedChannelMessage /
editFormattedTrackedChannelMessage wrappers in index.ts, and the cron
reviewer-bot path) now use sanitizeForOutbound so the markdown escape
runs exactly once at the channel boundary.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
10
src/index.ts
10
src/index.ts
@@ -34,7 +34,7 @@ import { startIpcWatcher } from './ipc.js';
|
|||||||
import {
|
import {
|
||||||
findChannel,
|
findChannel,
|
||||||
findChannelByName,
|
findChannelByName,
|
||||||
formatOutbound,
|
sanitizeForOutbound,
|
||||||
normalizeMessageForDedupe,
|
normalizeMessageForDedupe,
|
||||||
resolveChannelForDeliveryRole,
|
resolveChannelForDeliveryRole,
|
||||||
} from './router.js';
|
} from './router.js';
|
||||||
@@ -98,7 +98,7 @@ export async function sendFormattedChannelMessage(
|
|||||||
logger.warn({ jid }, 'No channel owns JID, cannot send message');
|
logger.warn({ jid }, 'No channel owns JID, cannot send message');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const text = formatOutbound(rawText);
|
const text = sanitizeForOutbound(rawText);
|
||||||
if (text) await channel.sendMessage(jid, text);
|
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');
|
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);
|
||||||
}
|
}
|
||||||
@@ -128,7 +128,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);
|
||||||
}
|
}
|
||||||
@@ -388,7 +388,7 @@ async function main(): Promise<void> {
|
|||||||
sendFormattedChannelMessage(channels, jid, rawText),
|
sendFormattedChannelMessage(channels, jid, rawText),
|
||||||
sendMessageViaReviewerBot: reviewerChannelForCron
|
sendMessageViaReviewerBot: reviewerChannelForCron
|
||||||
? async (jid, rawText) => {
|
? async (jid, rawText) => {
|
||||||
const text = formatOutbound(rawText);
|
const text = sanitizeForOutbound(rawText);
|
||||||
if (text) await reviewerChannelForCron.sendMessage(jid, text);
|
if (text) await reviewerChannelForCron.sendMessage(jid, text);
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -166,7 +166,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) {
|
||||||
|
|||||||
@@ -97,22 +97,22 @@ describe('findChannelForDeliveryRole', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('neutralizeStrayMarkdown', () => {
|
describe('neutralizeStrayMarkdown', () => {
|
||||||
it('strips inline backticks', () => {
|
it('escapes inline backticks so they render literally', () => {
|
||||||
expect(neutralizeStrayMarkdown('use `foo` and `bar` here')).toBe(
|
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`';
|
const input = 'before\n```ts\nconst x = `tpl`;\n```\nafter `inline`';
|
||||||
expect(neutralizeStrayMarkdown(input)).toBe(
|
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(
|
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);
|
expect(neutralizeStrayMarkdown(s)).toBe(s);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('strips italic asterisks', () => {
|
it('escapes italic asterisks', () => {
|
||||||
expect(neutralizeStrayMarkdown('이건 *기울임* 처리')).toBe(
|
expect(neutralizeStrayMarkdown('이건 *기울임* 처리')).toBe(
|
||||||
'이건 기울임 처리',
|
'이건 \\*기울임\\* 처리',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('strips bold asterisks', () => {
|
it('escapes bold asterisks', () => {
|
||||||
expect(neutralizeStrayMarkdown('이건 **강조** 처리')).toBe(
|
expect(neutralizeStrayMarkdown('이건 **강조** 처리')).toBe(
|
||||||
'이건 강조 처리',
|
'이건 \\*\\*강조\\*\\* 처리',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('strips bold-italic triple asterisks', () => {
|
it('escapes bold-italic triple asterisks', () => {
|
||||||
expect(neutralizeStrayMarkdown('***hybrid***')).toBe('hybrid');
|
expect(neutralizeStrayMarkdown('***hybrid***')).toBe(
|
||||||
|
'\\*\\*\\*hybrid\\*\\*\\*',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('strips italic underscores at word boundaries', () => {
|
it('escapes italic underscores at word boundaries', () => {
|
||||||
expect(neutralizeStrayMarkdown('이건 _italic_ 처리')).toBe(
|
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(
|
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(
|
expect(
|
||||||
neutralizeStrayMarkdown(
|
neutralizeStrayMarkdown(
|
||||||
'경로: /home/claude/EJClaw/groups/mc_the_scene_plugin/src/main_file.ts',
|
'경로: /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(
|
expect(
|
||||||
neutralizeStrayMarkdown(
|
neutralizeStrayMarkdown(
|
||||||
'경로: C:\\Custom_vscode\\minecraft_launcher\\src\\main_file.ts',
|
'경로: C:\\Custom_vscode\\minecraft_launcher\\src\\main_file.ts',
|
||||||
),
|
),
|
||||||
).toBe(
|
).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', () => {
|
it('escapes double-underscore underline markers', () => {
|
||||||
expect(neutralizeStrayMarkdown('__bold__ text')).toBe('bold text');
|
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(
|
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(
|
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(
|
expect(neutralizeStrayMarkdown('이건 ~~취소선~~ 입니다')).toBe(
|
||||||
'이건 취소선 입니다',
|
'이건 \\~\\~취소선\\~\\~ 입니다',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('strips spoiler markers', () => {
|
it('escapes spoiler pipes', () => {
|
||||||
expect(neutralizeStrayMarkdown('||비밀||')).toBe('비밀');
|
expect(neutralizeStrayMarkdown('||비밀||')).toBe('\\|\\|비밀\\|\\|');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves Discord mention syntax', () => {
|
it('preserves Discord mention syntax (no markdown delimiters inside)', () => {
|
||||||
expect(neutralizeStrayMarkdown('<@123> hi <#456>')).toBe(
|
expect(neutralizeStrayMarkdown('<@123> hi <#456>')).toBe(
|
||||||
'<@123> hi <#456>',
|
'<@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*';
|
const input = '**bold** outside\n```py\nx = *2\n```\nand *more*';
|
||||||
expect(neutralizeStrayMarkdown(input)).toBe(
|
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', () => {
|
it('back-compat alias still exported', () => {
|
||||||
expect(neutralizeStrayBackticks('*x*')).toBe('x');
|
expect(neutralizeStrayBackticks('*x*')).toBe('\\*x\\*');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('formatOutbound', () => {
|
describe('formatOutbound', () => {
|
||||||
it('runs the full pipeline and strips stray backticks', () => {
|
it('runs the full pipeline and escapes stray backticks', () => {
|
||||||
const raw = '<internal>note</internal>Use `foo` in your code.';
|
const raw = '<internal>note</internal>Use `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', () => {
|
it('preserves fenced code blocks through the pipeline', () => {
|
||||||
const raw = 'See:\n```js\nconst a = 1;\n```\nor `inline`.';
|
const raw = 'See:\n```js\nconst a = 1;\n```\nor `inline`.';
|
||||||
expect(formatOutbound(raw)).toBe(
|
expect(formatOutbound(raw)).toBe(
|
||||||
'See:\n```js\nconst a = 1;\n```\nor inline.',
|
'See:\n```js\nconst a = 1;\n```\nor \\`inline\\`.',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -78,55 +78,46 @@ export function stripToolCallLeaks(text: string): string {
|
|||||||
return stripped.replace(/\n{3,}/g, '\n\n').trim();
|
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
|
* Escape Discord markdown delimiters in a prose segment so the source
|
||||||
* output when wrapping across lines. Used inside non-code segments only —
|
* characters render literally instead of triggering formatting. Used on
|
||||||
* callers should preserve fenced code blocks separately.
|
* non-code segments only — callers must preserve fenced code blocks
|
||||||
|
* separately.
|
||||||
*
|
*
|
||||||
* Discord rendering reference:
|
* Discord rendering reference:
|
||||||
* *italic*, _italic_, **bold**, ***bold-italic***
|
* *italic*, _italic_, **bold**, ***bold-italic***
|
||||||
* __underline__ ~~strike~~ ||spoiler||
|
* __underline__ ~~strike~~ ||spoiler||
|
||||||
* # H1 ## H2 ### H3 (at line start)
|
* `inline` # H1 ## H2 ### H3 (at line start)
|
||||||
* > quote, >>> multi-line quote (at line start)
|
* > quote, >>> multi-line quote (at line start)
|
||||||
*
|
*
|
||||||
* `<@id>`/`<#id>`/`<:emoji:id>` mentions and `[text](url)` links are left
|
* `<@id>`/`<#id>`/`<:emoji:id>` mentions and `[text](url)` links contain no
|
||||||
* untouched (handled elsewhere or harmless as raw text).
|
* markdown delimiters and pass through unchanged.
|
||||||
*/
|
*/
|
||||||
function stripMarkdownInProse(segment: string): string {
|
function escapeMarkdownInProse(segment: string): string {
|
||||||
return (
|
return (
|
||||||
escapePathMarkdownUnderscores(segment)
|
segment
|
||||||
// Inline backtick code — agents abuse this to highlight identifiers
|
// Escape backslashes first so we don't double-escape the backslashes
|
||||||
// and Discord then fragments coloring across line wraps.
|
// we are about to introduce for the other markers.
|
||||||
.replace(/`/g, '')
|
.replace(/\\/g, '\\\\')
|
||||||
// Strikethrough and spoiler wrappers (always paired punctuation)
|
// Inline markdown delimiters — escape positionally so Discord prints
|
||||||
.replace(/~~/g, '')
|
// them as literal characters.
|
||||||
.replace(/\|\|/g, '')
|
.replace(/`/g, '\\`')
|
||||||
// Heading markers at the start of a line (after optional whitespace)
|
.replace(/\*/g, '\\*')
|
||||||
.replace(/^[ \t]*#{1,3}[ \t]+/gm, '')
|
.replace(/_/g, '\\_')
|
||||||
// Block quote markers at the start of a line
|
.replace(/~/g, '\\~')
|
||||||
.replace(/^[ \t]*>{1,3}[ \t]?/gm, '')
|
.replace(/\|/g, '\\|')
|
||||||
// Bold/italic asterisks — Discord italicizes even mid-word (a*b*c),
|
// Heading hashes — only meaningful at the start of a line (after
|
||||||
// so just remove every '*'. Math/code uses live in fenced blocks.
|
// optional indent) and followed by a space. Escape only the first
|
||||||
.replace(/\*/g, '')
|
// hash; the rest are now harmless literal characters.
|
||||||
// Underline/bold uses '__'; remove paired runs of 2+ underscores.
|
.replace(/^([ \t]*)(#)(?=#{0,2}[ \t])/gm, '$1\\$2')
|
||||||
.replace(/_{2,}/g, '')
|
// Block-quote markers — same line-start constraint.
|
||||||
// Italic '_word_' — only strip when the underscores are at word
|
.replace(/^([ \t]*)(>)(?=>{0,2}([ \t]|$))/gm, '$1\\$2')
|
||||||
// boundaries (so snake_case identifiers survive intact).
|
|
||||||
.replace(/(^|[^A-Za-z0-9_])_([^_\n]+?)_(?=$|[^A-Za-z0-9_])/g, '$1$2')
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Neutralize stray Discord markdown in prose while preserving well-formed
|
* Escape stray Discord markdown delimiters in prose while preserving
|
||||||
* triple-backtick fenced code blocks (legit code snippets).
|
* well-formed triple-backtick fenced code blocks (legit code snippets).
|
||||||
*/
|
*/
|
||||||
export function neutralizeStrayMarkdown(text: string): string {
|
export function neutralizeStrayMarkdown(text: string): string {
|
||||||
if (!text) return text;
|
if (!text) return text;
|
||||||
@@ -135,14 +126,14 @@ export function neutralizeStrayMarkdown(text: string): string {
|
|||||||
while (i < text.length) {
|
while (i < text.length) {
|
||||||
const fenceStart = text.indexOf('```', i);
|
const fenceStart = text.indexOf('```', i);
|
||||||
if (fenceStart === -1) {
|
if (fenceStart === -1) {
|
||||||
parts.push(stripMarkdownInProse(text.slice(i)));
|
parts.push(escapeMarkdownInProse(text.slice(i)));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
parts.push(stripMarkdownInProse(text.slice(i, fenceStart)));
|
parts.push(escapeMarkdownInProse(text.slice(i, fenceStart)));
|
||||||
const fenceEnd = text.indexOf('```', fenceStart + 3);
|
const fenceEnd = text.indexOf('```', fenceStart + 3);
|
||||||
if (fenceEnd === -1) {
|
if (fenceEnd === -1) {
|
||||||
// Unterminated fence — not a real code block; treat as prose.
|
// Unterminated fence — not a real code block; treat as prose.
|
||||||
parts.push(stripMarkdownInProse(text.slice(fenceStart)));
|
parts.push(escapeMarkdownInProse(text.slice(fenceStart)));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Preserve the entire fenced block including delimiters.
|
// Preserve the entire fenced block including delimiters.
|
||||||
@@ -155,13 +146,33 @@ export function neutralizeStrayMarkdown(text: string): string {
|
|||||||
/** @deprecated Kept for back-compat; use neutralizeStrayMarkdown. */
|
/** @deprecated Kept for back-compat; use neutralizeStrayMarkdown. */
|
||||||
export const neutralizeStrayBackticks = 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);
|
let text = stripInternalTags(rawText);
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
text = stripToolCallLeaks(text);
|
text = stripToolCallLeaks(text);
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
text = redactSecrets(text);
|
return redactSecrets(text);
|
||||||
return neutralizeStrayMarkdown(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(
|
||||||
|
|||||||
@@ -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 = [
|
||||||
@@ -84,7 +84,8 @@ export interface SessionCommandDeps {
|
|||||||
function resultToText(result: string | object | null | undefined): string {
|
function resultToText(result: string | object | null | undefined): string {
|
||||||
if (!result) return '';
|
if (!result) return '';
|
||||||
const raw = typeof result === 'string' ? result : JSON.stringify(result);
|
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 {
|
function agentResultToText(result: AgentResult): string {
|
||||||
@@ -92,7 +93,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) : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user