mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -06:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user