From 28ecf953f382011c91d797214af4efe4732cce94 Mon Sep 17 00:00:00 2001 From: Paul Frederiksen Date: Mon, 27 Jul 2026 23:26:55 -0700 Subject: [PATCH] fix(discord): reject mismatched reply context (#114716) * fix(discord): reject mismatched reply context * test(discord): prove safe reply context propagation --- .../monitor/message-handler.hydration.test.ts | 104 +++++++++++++++++- .../src/monitor/message-handler.hydration.ts | 34 +++++- 2 files changed, 131 insertions(+), 7 deletions(-) diff --git a/extensions/discord/src/monitor/message-handler.hydration.test.ts b/extensions/discord/src/monitor/message-handler.hydration.test.ts index e5bb6beabcbf..10cb1b7f71fa 100644 --- a/extensions/discord/src/monitor/message-handler.hydration.test.ts +++ b/extensions/discord/src/monitor/message-handler.hydration.test.ts @@ -172,7 +172,21 @@ describe("hydrateDiscordMessageIfNeeded", () => { const rest = createFakeRestClient([ createReferencedMessagePayload("the directly fetched message"), ]); - const message = new Message(client, createDefaultReplyPayload()); + const message = new Message( + client, + createDefaultReplyPayload({ + content: "<@bot> ok do it", + mentions: [ + { + id: "bot", + username: "openclaw", + global_name: null, + discriminator: "0", + avatar: null, + }, + ], + }), + ); const { message: hydrated } = await hydrateDiscordMessageIfNeeded({ client: { rest }, @@ -202,6 +216,94 @@ describe("hydrateDiscordMessageIfNeeded", () => { expect(result.ctxPayload.ReplyToBody).toBe("the directly fetched message"); }); + it("replaces a mismatched nested reply with the canonical referenced message", async () => { + const client = createInternalTestClient(); + const rest = createFakeRestClient([ + createReferencedMessagePayload("the canonical reply target"), + ]); + const message = new Message( + client, + createDefaultReplyPayload({ + referenced_message: createMessagePayload({ + id: "stale-message", + content: "unrelated older context", + }), + }), + ); + + const { message: hydrated } = await hydrateDiscordMessageIfNeeded({ + client: { rest }, + message, + messageChannelId: "c1", + }); + + expect(rest.calls.map((call) => call.path)).toEqual(["/channels/c1/messages/m0"]); + expect(hydrated.referencedMessage?.id).toBe("m0"); + expect(hydrated.referencedMessage?.content).toBe("the canonical reply target"); + + const ctx = await createBaseDiscordMessageContext({ + message: hydrated, + author: hydrated.author, + baseText: hydrated.content, + messageText: hydrated.content, + }); + const result = await buildDiscordMessageProcessContext({ + ctx, + text: hydrated.content, + mediaList: [], + }); + if (!result) { + throw new Error("expected a built Discord message context"); + } + + expect(result.ctxPayload.ReplyToId).toBe("m0"); + expect(result.ctxPayload.ReplyToBody).toBe("the canonical reply target"); + expect(result.ctxPayload.ReplyToBody).not.toContain("unrelated older context"); + }); + + it("discards a mismatched nested reply when canonical hydration fails", async () => { + const client = createInternalTestClient(); + const rest = createFakeRestClient(); + rest.get = vi.fn(async () => { + throw Object.assign(new Error("Missing Access"), { status: 403 }); + }); + const message = new Message( + client, + createDefaultReplyPayload({ + referenced_message: createMessagePayload({ + id: "stale-message", + content: "unrelated older context", + }), + }), + ); + + const { message: hydrated } = await hydrateDiscordMessageIfNeeded({ + client: { rest }, + message, + messageChannelId: "c1", + }); + + expect(hydrated.referencedMessage).toBeNull(); + + const ctx = await createBaseDiscordMessageContext({ + message: hydrated, + author: hydrated.author, + baseText: hydrated.content, + messageText: hydrated.content, + }); + const result = await buildDiscordMessageProcessContext({ + ctx, + text: hydrated.content, + mediaList: [], + }); + if (!result) { + throw new Error("expected a built Discord message context"); + } + + expect(result.ctxPayload.ReplyToId).toBeUndefined(); + expect(result.ctxPayload.ReplyToBody).toBeUndefined(); + }); + it("uses the referenced channel when directly hydrating a cross-channel reply", async () => { const client = createInternalTestClient(); const reply = createDefaultReplyPayload({ diff --git a/extensions/discord/src/monitor/message-handler.hydration.ts b/extensions/discord/src/monitor/message-handler.hydration.ts index 99fd958af02a..352013b8d48c 100644 --- a/extensions/discord/src/monitor/message-handler.hydration.ts +++ b/extensions/discord/src/monitor/message-handler.hydration.ts @@ -172,18 +172,32 @@ function shouldHydrateDiscordMessagePayload(params: { message: Message }) { return /<@!?\d+>|<@&\d+>|@everyone|@here/u.test(currentText); } -function hasMissingReferencedMessagePayload(message: Message): boolean { +type ReferencedMessagePayloadState = "complete" | "missing" | "invalid"; + +function resolveReferencedMessagePayloadState(message: Message): ReferencedMessagePayloadState { const reference = message.messageReference; if (!reference?.message_id) { - return false; + return "complete"; } if (reference.type != null && reference.type !== MessageReferenceType.Default) { - return false; + return "complete"; } if (message.type != null && message.type !== MessageType.Reply) { - return false; + return "complete"; } - return !Object.hasOwn(readMessageRawData(message), "referenced_message"); + const rawData = readMessageRawData(message); + if (!Object.hasOwn(rawData, "referenced_message")) { + return "missing"; + } + const referenced = rawData.referenced_message; + if (referenced == null) { + return "complete"; + } + return typeof referenced === "object" && + typeof referenced.id === "string" && + referenced.id === reference.message_id + ? "complete" + : "invalid"; } async function hydrateDiscordReplyReference(params: { @@ -191,7 +205,8 @@ async function hydrateDiscordReplyReference(params: { message: Message; messageChannelId: string; }): Promise { - if (!hasMissingReferencedMessagePayload(params.message)) { + const payloadState = resolveReferencedMessagePayloadState(params.message); + if (payloadState === "complete") { return params.message; } const reference = params.message.messageReference; @@ -216,6 +231,13 @@ async function hydrateDiscordReplyReference(params: { logVerbose( `discord: failed to hydrate referenced message ${referencedMessageId}: ${String(err)}`, ); + if (payloadState === "invalid") { + // A mismatched nested payload must never become reply context for another message. + return mergeFetchedDiscordMessage(params.message, { + ...readMessageRawData(params.message), + referenced_message: null, + } as APIMessage); + } return params.message; } }