Files
EJClaw/src/router.ts
Codex cf9bf08197 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>
2026-05-27 14:52:33 +09:00

171 lines
5.0 KiB
TypeScript

import { Channel, NewMessage } from './types.js';
import { formatLocalTime } from './timezone.js';
export function escapeXml(s: string): string {
if (!s) return '';
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
export function formatMessages(
messages: NewMessage[],
timezone: string,
): string {
const lines = messages.map((m) => {
const displayTime = formatLocalTime(m.timestamp, timezone);
return `<message sender="${escapeXml(m.sender_name)}" time="${escapeXml(displayTime)}">${escapeXml(m.content)}</message>`;
});
const header = `<context timezone="${escapeXml(timezone)}" />\n`;
return `${header}<messages>\n${lines.join('\n')}\n</messages>`;
}
export function stripInternalTags(text: string): string {
return text.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
}
/**
* Patterns that match common API keys / tokens.
* Matched strings are replaced with `[REDACTED]`.
*/
const SECRET_PATTERNS: RegExp[] = [
/sk-ant-[A-Za-z0-9_-]{20,}/g, // Anthropic
/sk-[A-Za-z0-9_-]{20,}/g, // OpenAI
/gsk_[A-Za-z0-9_-]{20,}/g, // Groq
/xai-[A-Za-z0-9_-]{20,}/g, // xAI
/ghp_[A-Za-z0-9_]{36,}/g, // GitHub PAT classic
/github_pat_[A-Za-z0-9_]{20,}/g, // GitHub PAT fine-grained
/glpat-[A-Za-z0-9_-]{20,}/g, // GitLab PAT
/AKIA[A-Z0-9]{16}/g, // AWS Access Key
/Bearer\s+eyJ[A-Za-z0-9_-]{40,}/g, // Bearer JWT
];
function redactSecrets(text: string): string {
let result = text;
for (const pattern of SECRET_PATTERNS) {
pattern.lastIndex = 0;
result = result.replace(pattern, '[REDACTED]');
}
return result;
}
/**
* Strip leaked tool-call serialization text.
*
* When a model (especially Codex) enters a degenerate loop, it emits
* tool-call intent as plaintext instead of actual tool calls. The format is:
* to=functions.<name> <arbitrary tokens> {<json>}
* e.g. `to=functions.exec_command code {"cmd":"git status","yield_time_ms":1000}`
*
* The tokens between the function name and JSON body can include non-ASCII
* characters (CJK, etc.) when the model hallucinates. The regex allows one or
* more non-whitespace descriptor tokens before the JSON brace.
*
* This function removes such fragments so they never reach Discord.
*/
export function stripToolCallLeaks(text: string): string {
// Match tool-call serialization: to=functions.<name> <descriptor tokens> {<json>}
// Handles up to one level of nested braces in the JSON body.
const stripped = text.replace(
/to=functions\.\w+(?:\s+[^\s{}]+)+\s+\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g,
'',
);
// Collapse excessive blank lines left after stripping
return stripped.replace(/\n{3,}/g, '\n\n').trim();
}
export function formatOutbound(rawText: string): string {
let text = stripInternalTags(rawText);
if (!text) return '';
text = stripToolCallLeaks(text);
if (!text) return '';
return redactSecrets(text);
}
export function findChannel(
channels: Channel[],
jid: string,
): Channel | undefined {
return channels.find((c) => c.ownsJid(jid));
}
export interface DeliveryRouteResolution {
channel?: Channel;
requestedRoleChannelName: string | null;
selectedChannelName: string | null;
usedRoleChannel: boolean;
fallbackUsed: boolean;
}
function resolveRequestedRoleChannelName(senderRole?: string): string | null {
if (senderRole === 'reviewer') return 'discord-review';
if (senderRole === 'arbiter') return 'discord-arbiter';
return null;
}
export function resolveChannelForDeliveryRole(
channels: Channel[],
jid: string,
senderRole?: string,
): DeliveryRouteResolution {
const requestedRoleChannelName = resolveRequestedRoleChannelName(senderRole);
if (!requestedRoleChannelName) {
const channel = findChannel(channels, jid);
return {
channel,
requestedRoleChannelName,
selectedChannelName: channel?.name ?? null,
usedRoleChannel: false,
fallbackUsed: false,
};
}
const roleChannel = findChannelByName(channels, requestedRoleChannelName);
if (roleChannel) {
return {
channel: roleChannel,
requestedRoleChannelName,
selectedChannelName: roleChannel.name,
usedRoleChannel: true,
fallbackUsed: false,
};
}
const fallbackChannel = findChannel(channels, jid);
return {
channel: fallbackChannel,
requestedRoleChannelName,
selectedChannelName: fallbackChannel?.name ?? null,
usedRoleChannel: false,
fallbackUsed: true,
};
}
export function findChannelForDeliveryRole(
channels: Channel[],
jid: string,
senderRole?: string,
): Channel | undefined {
return resolveChannelForDeliveryRole(channels, jid, senderRole).channel;
}
export function findChannelByName(
channels: Channel[],
name: string,
): Channel | undefined {
return channels.find((c) => c.name === name);
}
/**
* Normalize message text for deduplication comparison.
* - Trim leading/trailing whitespace
* - Collapse consecutive whitespace/newlines into single space
*/
export function normalizeMessageForDedupe(text: string): string {
return text.trim().replace(/\s+/g, ' ').toLowerCase();
}