Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
55 changes: 50 additions & 5 deletions src/discord/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions tests/responseCodeFence.test.mjs
Original file line number Diff line number Diff line change
@@ -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,
});
});