diff --git a/extensions/discord/src/mentions.test.ts b/extensions/discord/src/mentions.test.ts index f427e8402120..a63af5bb85f3 100644 --- a/extensions/discord/src/mentions.test.ts +++ b/extensions/discord/src/mentions.test.ts @@ -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", () => { diff --git a/extensions/discord/src/mentions.ts b/extensions/discord/src/mentions.ts index 0e09aa123365..d5a62a02a8c7 100644 --- a/extensions/discord/src/mentions.ts +++ b/extensions/discord/src/mentions.ts @@ -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( diff --git a/extensions/discord/src/send.sends-basic-channel-messages.test.ts b/extensions/discord/src/send.sends-basic-channel-messages.test.ts index f36f221b91d4..4b50213fbab2 100644 --- a/extensions/discord/src/send.sends-basic-channel-messages.test.ts +++ b/extensions/discord/src/send.sends-basic-channel-messages.test.ts @@ -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(); diff --git a/extensions/discord/src/send.webhook.proxy.test.ts b/extensions/discord/src/send.webhook.proxy.test.ts index bd2821a56ff3..5d642f600321 100644 --- a/extensions/discord/src/send.webhook.proxy.test.ts +++ b/extensions/discord/src/send.webhook.proxy.test.ts @@ -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");