From eaaba281a1a90e07d2c8ff61ea438d49c0c433bd Mon Sep 17 00:00:00 2001 From: vycdev2 Date: Mon, 10 Aug 2026 03:14:58 +0000 Subject: [PATCH] fix: ignore reaction directives in inline code --- src/discord/response.ts | 61 +++++++++++++++++++++++++++++-- tests/responseInlineCode.test.mjs | 15 ++++++++ 2 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 tests/responseInlineCode.test.mjs diff --git a/src/discord/response.ts b/src/discord/response.ts index ac53c62..00b5873 100644 --- a/src/discord/response.ts +++ b/src/discord/response.ts @@ -4,11 +4,64 @@ export interface ParsedClaudeResponse { historyContent: string; } +interface InlineCodeSpan { + start: number; + end: number; +} + +function findInlineCodeSpans(text: string): InlineCodeSpan[] { + const spans: InlineCodeSpan[] = []; + let index = 0; + + while (index < text.length) { + if (text[index] !== "`") { + index++; + continue; + } + + let markerEnd = index + 1; + while (text[markerEnd] === "`") markerEnd++; + const marker = text.slice(index, markerEnd); + const lineBreakOffset = text.slice(markerEnd).search(/[\r\n]/); + const lineEnd = + lineBreakOffset === -1 ? text.length : markerEnd + lineBreakOffset; + const closingStart = text.indexOf(marker, markerEnd); + if (closingStart === -1 || closingStart >= lineEnd) { + index = markerEnd; + continue; + } + + spans.push({ + start: index, + end: closingStart + marker.length, + }); + index = closingStart + marker.length; + } + + return spans; +} + +function isInsideInlineCode(spans: InlineCodeSpan[], index: number): boolean { + return spans.some((span) => index >= span.start && index < span.end); +} + 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(); + const inlineCodeSpans = findInlineCodeSpans(response); + const reactions: string[] = []; + const removals: { start: number; end: number }[] = []; + + for (const match of response.matchAll(/\[REACT:(.+?)\]\s*/g)) { + const start = match.index ?? 0; + if (isInsideInlineCode(inlineCodeSpans, start)) continue; + reactions.push(match[1].trim()); + removals.push({ start, end: start + match[0].length }); + } + + let text = response; + for (const removal of removals.reverse()) { + text = text.slice(0, removal.start) + text.slice(removal.end); + } + text = text.trim(); return { reactions, diff --git a/tests/responseInlineCode.test.mjs b/tests/responseInlineCode.test.mjs new file mode 100644 index 0000000..f8d1b9b --- /dev/null +++ b/tests/responseInlineCode.test.mjs @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { parseClaudeResponse } from "../build/discord/response.js"; + +test("keeps reaction tags inside inline code spans as literal text", () => { + assert.deepEqual( + parseClaudeResponse("Use `[REACT:literal]` in docs. [REACT:thumbsup] Done."), + { + reactions: ["thumbsup"], + text: "Use `[REACT:literal]` in docs. Done.", + historyContent: "Use `[REACT:literal]` in docs. Done.", + }, + ); +});