fix(discord): reject mismatched reply context (#114716)

* fix(discord): reject mismatched reply context

* test(discord): prove safe reply context propagation
This commit is contained in:
Paul Frederiksen
2026-07-27 23:26:55 -07:00
committed by GitHub
parent cb167acf5e
commit 28ecf953f3
2 changed files with 131 additions and 7 deletions
@@ -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({
@@ -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<Message> {
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;
}
}