fix(discord): prevent accidental mentions inside unfinished code (#129338)

* fix(discord): protect mentions inside unfinished inline code

* fix(discord): respect escaped inline-code delimiters
This commit is contained in:
Peter Steinberger
2026-08-25 07:11:28 -07:00
committed by GitHub
parent 18234ea964
commit 712a97d2ab
4 changed files with 104 additions and 48 deletions
+33 -8
View File
@@ -85,19 +85,44 @@ describe("rewriteDiscordKnownMentions", () => {
expect(rewritten).toBe("hello @unknown @everyone @here");
});
it("does not rewrite mentions inside markdown code spans", () => {
it.each([
{
name: "balanced inline and fenced code",
input: "inline `@alice` fence ```\n@alice\n``` text @alice",
expected: "inline `@alice` fence ```\n@alice\n``` text <@123456789>",
},
{
name: "unterminated single-backtick code",
input: "outside @alice then `inside @alice",
expected: "outside <@123456789> then `inside @alice",
},
{
name: "unterminated double-backtick code",
input: "outside @alice then ``inside @alice",
expected: "outside <@123456789> then ``inside @alice",
},
{
name: "escaped literal backticks",
input: "literal \\` outside @alice",
expected: "literal \\` outside <@123456789>",
},
{
name: "backticks after an even number of backslashes",
input: "literal \\\\` inside @alice",
expected: "literal \\\\` inside @alice",
},
{
name: "escaped backticks before real unterminated code",
input: "literal \\` outside @alice then `inside @alice",
expected: "literal \\` outside <@123456789> then `inside @alice",
},
])("does not rewrite mentions inside $name", ({ input, expected }) => {
rememberDiscordDirectoryUser({
accountId: "default",
userId: "123456789",
handles: ["alice"],
});
const rewritten = rewriteDiscordKnownMentions(
"inline `@alice` fence ```\n@alice\n``` text @alice",
{
accountId: "default",
},
);
expect(rewritten).toBe("inline `@alice` fence ```\n@alice\n``` text <@123456789>");
expect(rewriteDiscordKnownMentions(input, { accountId: "default" })).toBe(expected);
});
it("does not end longer code fences at triple-backtick literals inside the body", () => {
+11 -19
View File
@@ -155,26 +155,18 @@ function findNextMarkdownCodeSegment(
text: string,
startIndex: number,
): { startIndex: number; endIndex: number } | null {
let searchIndex = startIndex;
while (searchIndex < text.length) {
const segmentStart = text.indexOf("`", searchIndex);
if (segmentStart === -1) {
return null;
}
const runLength = countBacktickRun(text, segmentStart);
const inlineEndIndex = findSameLineBacktickRun(text, segmentStart + runLength, runLength);
if (inlineEndIndex !== null) {
return { startIndex: segmentStart, endIndex: inlineEndIndex };
}
if (runLength >= 3) {
return {
startIndex: segmentStart,
endIndex: findFenceEnd(text, segmentStart, runLength),
};
}
searchIndex = segmentStart + runLength;
const segmentOffset = text.slice(startIndex).search(/(?<=(?:^|[^\\])(?:\\\\)*)`/);
if (segmentOffset === -1) {
return null;
}
return null;
const segmentStart = startIndex + segmentOffset;
const runLength = countBacktickRun(text, segmentStart);
return {
startIndex: segmentStart,
endIndex:
findSameLineBacktickRun(text, segmentStart + runLength, runLength) ??
(runLength >= 3 ? findFenceEnd(text, segmentStart, runLength) : text.length),
};
}
export function rewriteDiscordKnownMentions(
@@ -680,27 +680,35 @@ describe("sendMessageDiscord", () => {
expect(body).not.toHaveProperty("flags");
});
it("rewrites cached @username mentions to id-based mentions", async () => {
rememberDiscordDirectoryUser({
accountId: "default",
userId: "123456789012345678",
handles: ["Alice"],
});
const { rest, postMock, getMock } = makeDiscordRest();
getMock.mockResolvedValueOnce({ type: ChannelType.GuildText });
postMock.mockResolvedValue({
id: "msg1",
channel_id: "789",
});
await sendMessageDiscord("channel:789", "ping @Alice", {
rest,
token: "t",
cfg: DISCORD_TEST_CFG,
accountId: "default",
});
expectRestRoute(postMock, 0, Routes.channelMessages("789"));
expect(requireRestBody(postMock).content).toBe("ping <@123456789012345678>");
});
it.each([
{ input: "ping @Alice", expected: "ping <@123456789012345678>" },
{ input: "Run `notify @Alice", expected: "Run `notify @Alice" },
{ input: "literal \\` ping @Alice", expected: "literal \\` ping <@123456789012345678>" },
{ input: "literal \\\\` inside @Alice", expected: "literal \\\\` inside @Alice" },
])(
"rewrites cached @username mentions only outside code: $input",
async ({ input, expected }) => {
rememberDiscordDirectoryUser({
accountId: "default",
userId: "123456789012345678",
handles: ["Alice"],
});
const { rest, postMock, getMock } = makeDiscordRest();
getMock.mockResolvedValueOnce({ type: ChannelType.GuildText });
postMock.mockResolvedValue({
id: "msg1",
channel_id: "789",
});
await sendMessageDiscord("channel:789", input, {
rest,
token: "t",
cfg: DISCORD_TEST_CFG,
accountId: "default",
});
expectRestRoute(postMock, 0, Routes.channelMessages("789"));
expect(requireRestBody(postMock).content).toBe(expected);
},
);
it("rewrites configured @username aliases to id-based mentions", async () => {
const { rest, postMock, getMock } = makeDiscordRest();
@@ -153,6 +153,37 @@ describe("sendWebhookMessageDiscord proxy support", () => {
globalFetchMock.mockRestore();
});
it.each([
{ input: "Run `notify @OpsLead", expected: "Run `notify @OpsLead" },
{
input: "literal \\` ping @OpsLead",
expected: "literal \\` ping <@123456789012345678>",
},
])("only rewrites webhook mentions outside inline code: $input", async ({ input, expected }) => {
const globalFetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(new Response(JSON.stringify({ id: "msg-code" }), { status: 200 }));
await sendWebhookMessageDiscord(input, {
cfg: {
channels: {
discord: {
token: "Bot test-token",
mentionAliases: { opslead: "123456789012345678" },
},
},
} as OpenClawConfig,
accountId: "default",
webhookId: "123",
webhookToken: "abc",
wait: true,
});
expect(globalFetchMock.mock.calls[0]?.[1]?.body).toContain(
`"content":${JSON.stringify(expected)}`,
);
});
it("accepts Discord's no-body webhook response when wait is false", async () => {
const response = new Response(null, { status: 204 });
const jsonSpy = vi.spyOn(response, "json");