From 1fe3981dc73bd10c43f8310806ee468c420856e3 Mon Sep 17 00:00:00 2001 From: vycdev2 Date: Mon, 10 Aug 2026 02:41:45 +0000 Subject: [PATCH] fix: ignore reaction tags inside fenced code --- CHANGELOG.md | 2 +- src/discord/response.ts | 55 +++++++++++++++++++++++++++++--- tests/responseCodeFence.test.mjs | 12 +++++++ 3 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 tests/responseCodeFence.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d927b6..a3cb4e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ - Add typed per-workload model and effort routing for responses, profile updates, server-memory updates, and daily summaries, with legacy global fallbacks and workload-aware logs. ### Fixed - +- Keep literal reaction syntax inside fenced code blocks from triggering bot reactions. - Prevent mixed reaction replies from narrating the bot's internal choice to react while preserving natural reaction-plus-text responses. - Run Discord-initiated Claude login in a pseudo-terminal so the CLI accepts submitted OAuth codes. - Isolate saved history and summaries by Discord channel ID in dedicated storage namespaces so same-named channels do not share automatic context. diff --git a/src/discord/response.ts b/src/discord/response.ts index ac53c62..f32b71b 100644 --- a/src/discord/response.ts +++ b/src/discord/response.ts @@ -4,12 +4,57 @@ export interface ParsedClaudeResponse { historyContent: string; } -export function parseClaudeResponse(response: string): ParsedClaudeResponse { - const reactions = [...response.matchAll(/\[REACT:(.+?)\]/g)].map((match) => - match[1].trim(), - ); - const text = response.replace(/\[REACT:(.+?)\]\s*/g, "").trim(); +function maskFencedCode(text: string): string { + const lines = text.split(/(\r\n|\n|\r)/); + let inFence = false; + let fenceCharacter: "`" | "~" | undefined; + let fenceLength = 0; + + return lines + .map((line) => { + if (line === "\r\n" || line === "\n" || line === "\r") { + return line; + } + const opening = line.match(/^( {0,3})(`{3,}|~{3,})(.*)$/); + const closing = line.match(/^( {0,3})(`{3,}|~{3,})[ \t]*$/); + if (inFence) { + if ( + closing && + closing[2][0] === fenceCharacter && + closing[2].length >= fenceLength + ) { + inFence = false; + fenceCharacter = undefined; + fenceLength = 0; + } + return " ".repeat(line.length); + } + if ( + opening && + (opening[2][0] === "~" || !opening[3].includes("`")) + ) { + inFence = true; + fenceCharacter = opening[2][0] as "`" | "~"; + fenceLength = opening[2].length; + return " ".repeat(line.length); + } + return line; + }) + .join(""); +} +export function parseClaudeResponse(response: string): ParsedClaudeResponse { + const maskedResponse = maskFencedCode(response); + const matches = [ + ...maskedResponse.matchAll(/\[REACT:(.+?)\]\s*/g), + ]; + const reactions = matches.map((match) => match[1].trim()); + let text = response; + for (let i = matches.length - 1; i >= 0; i -= 1) { + const match = matches[i]; + text = text.slice(0, match.index) + text.slice(match.index + match[0].length); + } + text = text.trim(); return { reactions, text, diff --git a/tests/responseCodeFence.test.mjs b/tests/responseCodeFence.test.mjs new file mode 100644 index 0000000..a610f89 --- /dev/null +++ b/tests/responseCodeFence.test.mjs @@ -0,0 +1,12 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseClaudeResponse } from "../build/discord/response.js"; + +test("does not interpret reaction directives inside fenced code", () => { + const response = "Here is the literal format:\n```text\n[REACT:👍]\n```"; + assert.deepEqual(parseClaudeResponse(response), { + reactions: [], + text: response, + historyContent: response, + }); +});