mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
fix(discord): hydrate reply context metadata
This commit is contained in:
committed by
Ayaan Zaidi
parent
9c6186de43
commit
b08eef0508
@@ -429,8 +429,9 @@ Message hook contexts expose stable correlation fields when available:
|
||||
`ctx.sessionKey`, `ctx.runId`, `ctx.messageId`, `ctx.senderId`, `ctx.trace`,
|
||||
`ctx.traceId`, `ctx.spanId`, `ctx.parentSpanId`, and `ctx.callDepth`. Inbound
|
||||
and `before_dispatch` contexts also expose reply metadata when the channel has
|
||||
visibility-filtered quoted message data: `replyToId`, `replyToBody`, and
|
||||
`replyToSender`. Prefer these first-class fields before reading legacy metadata.
|
||||
visibility-filtered quoted message data: `replyToId`, `replyToIdFull`,
|
||||
`replyToBody`, `replyToSender`, and `replyToIsQuote`. Prefer these first-class
|
||||
fields before reading legacy metadata.
|
||||
|
||||
Prefer typed `threadId` and `replyToId` fields before using channel-specific
|
||||
metadata.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Discord tests cover message handler.hydration plugin behavior.
|
||||
import { MessageReferenceType, MessageType } from "discord-api-types/v10";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Message } from "../internal/discord.js";
|
||||
import {
|
||||
@@ -78,4 +79,150 @@ describe("hydrateDiscordMessageIfNeeded", () => {
|
||||
expect(hydrated.mentionedRoles).toEqual(["role1"]);
|
||||
expect(hydrated.referencedMessage?.content).toBe("earlier");
|
||||
});
|
||||
|
||||
it("hydrates reply references when Discord omits referenced_message", async () => {
|
||||
const client = createInternalTestClient();
|
||||
const rest = createFakeRestClient([
|
||||
{
|
||||
id: "m1",
|
||||
channel_id: "c1",
|
||||
content: "what did this mean?",
|
||||
attachments: [],
|
||||
embeds: [],
|
||||
mentions: [],
|
||||
mention_roles: [],
|
||||
mention_everyone: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
author: {
|
||||
id: "u1",
|
||||
username: "alice",
|
||||
discriminator: "0",
|
||||
avatar: null,
|
||||
},
|
||||
message_reference: {
|
||||
type: MessageReferenceType.Default,
|
||||
message_id: "m0",
|
||||
channel_id: "c1",
|
||||
},
|
||||
referenced_message: {
|
||||
id: "m0",
|
||||
channel_id: "c1",
|
||||
content: "the replied-to message",
|
||||
attachments: [],
|
||||
embeds: [],
|
||||
mentions: [],
|
||||
mention_roles: [],
|
||||
mention_everyone: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
author: {
|
||||
id: "u2",
|
||||
username: "bob",
|
||||
discriminator: "0",
|
||||
avatar: null,
|
||||
},
|
||||
type: MessageType.Default,
|
||||
tts: false,
|
||||
pinned: false,
|
||||
flags: 0,
|
||||
},
|
||||
type: MessageType.Reply,
|
||||
tts: false,
|
||||
pinned: false,
|
||||
flags: 0,
|
||||
},
|
||||
]);
|
||||
const message = new Message(client, {
|
||||
id: "m1",
|
||||
channel_id: "c1",
|
||||
content: "what did this mean?",
|
||||
attachments: [],
|
||||
embeds: [],
|
||||
mentions: [],
|
||||
mention_roles: [],
|
||||
mention_everyone: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
author: {
|
||||
id: "u1",
|
||||
username: "alice",
|
||||
global_name: null,
|
||||
discriminator: "0",
|
||||
avatar: null,
|
||||
},
|
||||
message_reference: {
|
||||
type: MessageReferenceType.Default,
|
||||
message_id: "m0",
|
||||
channel_id: "c1",
|
||||
},
|
||||
type: MessageType.Reply,
|
||||
tts: false,
|
||||
pinned: false,
|
||||
});
|
||||
|
||||
const hydrated = await hydrateDiscordMessageIfNeeded({
|
||||
client: { rest },
|
||||
message,
|
||||
messageChannelId: "c1",
|
||||
});
|
||||
|
||||
expect(rest.calls).toHaveLength(1);
|
||||
expect(hydrated.referencedMessage?.content).toBe("the replied-to message");
|
||||
});
|
||||
|
||||
it("does not hydrate known-deleted or forwarded references", async () => {
|
||||
const client = createInternalTestClient();
|
||||
const rest = createFakeRestClient();
|
||||
const baseMessage = {
|
||||
id: "m1",
|
||||
channel_id: "c1",
|
||||
content: "what did this mean?",
|
||||
attachments: [],
|
||||
embeds: [],
|
||||
mentions: [],
|
||||
mention_roles: [],
|
||||
mention_everyone: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
author: {
|
||||
id: "u1",
|
||||
username: "alice",
|
||||
global_name: null,
|
||||
discriminator: "0",
|
||||
avatar: null,
|
||||
},
|
||||
tts: false,
|
||||
pinned: false,
|
||||
};
|
||||
|
||||
const deletedReply = new Message(client, {
|
||||
...baseMessage,
|
||||
message_reference: {
|
||||
type: MessageReferenceType.Default,
|
||||
message_id: "m0",
|
||||
channel_id: "c1",
|
||||
},
|
||||
referenced_message: null,
|
||||
type: MessageType.Reply,
|
||||
});
|
||||
const forwardedMessage = new Message(client, {
|
||||
...baseMessage,
|
||||
message_reference: {
|
||||
type: MessageReferenceType.Forward,
|
||||
message_id: "m0",
|
||||
channel_id: "c1",
|
||||
},
|
||||
type: MessageType.Default,
|
||||
});
|
||||
|
||||
await hydrateDiscordMessageIfNeeded({
|
||||
client: { rest },
|
||||
message: deletedReply,
|
||||
messageChannelId: "c1",
|
||||
});
|
||||
await hydrateDiscordMessageIfNeeded({
|
||||
client: { rest },
|
||||
message: forwardedMessage,
|
||||
messageChannelId: "c1",
|
||||
});
|
||||
|
||||
expect(rest.calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
// Discord plugin module implements message handler.hydration behavior.
|
||||
import type { APIMessage, APIUser } from "discord-api-types/v10";
|
||||
import {
|
||||
MessageReferenceType,
|
||||
MessageType,
|
||||
type APIMessage,
|
||||
type APIUser,
|
||||
} from "discord-api-types/v10";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { readStringValue as readString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { getChannelMessage, Message as DiscordMessage, type Message } from "../internal/discord.js";
|
||||
@@ -147,6 +152,9 @@ function copyRuntimeMessageFields(source: Message, target: Message): void {
|
||||
}
|
||||
|
||||
function shouldHydrateDiscordMessage(params: { message: Message }) {
|
||||
if (hasMissingReferencedMessagePayload(params.message)) {
|
||||
return true;
|
||||
}
|
||||
let currentText;
|
||||
try {
|
||||
currentText = resolveDiscordMessageText(params.message, {
|
||||
@@ -168,6 +176,20 @@ function shouldHydrateDiscordMessage(params: { message: Message }) {
|
||||
return /<@!?\d+>|<@&\d+>|@everyone|@here/u.test(currentText);
|
||||
}
|
||||
|
||||
function hasMissingReferencedMessagePayload(message: Message): boolean {
|
||||
const reference = message.messageReference;
|
||||
if (!reference?.message_id) {
|
||||
return false;
|
||||
}
|
||||
if (reference.type != null && reference.type !== MessageReferenceType.Default) {
|
||||
return false;
|
||||
}
|
||||
if (message.type != null && message.type !== MessageType.Reply) {
|
||||
return false;
|
||||
}
|
||||
return !Object.hasOwn(readMessageRawData(message), "referenced_message");
|
||||
}
|
||||
|
||||
export async function hydrateDiscordMessageIfNeeded(params: {
|
||||
client: { rest: Parameters<typeof getChannelMessage>[0] };
|
||||
message: Message;
|
||||
|
||||
@@ -7031,8 +7031,10 @@ describe("before_dispatch hook", () => {
|
||||
const dispatcher = createDispatcher();
|
||||
const ctx = createHookCtx({
|
||||
ReplyToId: "discord-reply-123",
|
||||
ReplyToIdFull: "discord:channel-1:discord-reply-123",
|
||||
ReplyToBody: "the quoted parent message",
|
||||
ReplyToSender: "Ada",
|
||||
ReplyToIsQuote: true,
|
||||
});
|
||||
|
||||
await dispatchReplyFromConfig({ ctx, cfg: emptyConfig, dispatcher });
|
||||
@@ -7044,25 +7046,33 @@ describe("before_dispatch hook", () => {
|
||||
| [
|
||||
{
|
||||
replyToId?: unknown;
|
||||
replyToIdFull?: unknown;
|
||||
replyToBody?: unknown;
|
||||
replyToSender?: unknown;
|
||||
replyToIsQuote?: unknown;
|
||||
},
|
||||
{
|
||||
replyToId?: unknown;
|
||||
replyToIdFull?: unknown;
|
||||
replyToBody?: unknown;
|
||||
replyToSender?: unknown;
|
||||
replyToIsQuote?: unknown;
|
||||
},
|
||||
]
|
||||
| undefined;
|
||||
expect(beforeDispatchCall?.[0]).toMatchObject({
|
||||
replyToId: "discord-reply-123",
|
||||
replyToIdFull: "discord:channel-1:discord-reply-123",
|
||||
replyToBody: "the quoted parent message",
|
||||
replyToSender: "Ada",
|
||||
replyToIsQuote: true,
|
||||
});
|
||||
expect(beforeDispatchCall?.[1]).toMatchObject({
|
||||
replyToId: "discord-reply-123",
|
||||
replyToIdFull: "discord:channel-1:discord-reply-123",
|
||||
replyToBody: "the quoted parent message",
|
||||
replyToSender: "Ada",
|
||||
replyToIsQuote: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2239,8 +2239,10 @@ export async function dispatchReplyFromConfig(
|
||||
sessionKey: sessionStoreEntry.sessionKey ?? sessionKey,
|
||||
senderId: hookContext.senderId,
|
||||
replyToId: hookContext.replyToId,
|
||||
replyToIdFull: hookContext.replyToIdFull,
|
||||
replyToBody: hookContext.replyToBody,
|
||||
replyToSender: hookContext.replyToSender,
|
||||
replyToIsQuote: hookContext.replyToIsQuote,
|
||||
isGroup: hookContext.isGroup,
|
||||
timestamp: hookContext.timestamp,
|
||||
},
|
||||
@@ -2251,8 +2253,10 @@ export async function dispatchReplyFromConfig(
|
||||
sessionKey: sessionStoreEntry.sessionKey ?? sessionKey,
|
||||
senderId: hookContext.senderId,
|
||||
replyToId: hookContext.replyToId,
|
||||
replyToIdFull: hookContext.replyToIdFull,
|
||||
replyToBody: hookContext.replyToBody,
|
||||
replyToSender: hookContext.replyToSender,
|
||||
replyToIsQuote: hookContext.replyToIsQuote,
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -132,50 +132,66 @@ describe("message hook mappers", () => {
|
||||
const canonical = deriveInboundMessageHookContext(
|
||||
makeInboundCtx({
|
||||
ReplyToId: "discord-message-42",
|
||||
ReplyToIdFull: "discord:channel-1:discord-message-42",
|
||||
ReplyToBody: "quoted Discord reply body",
|
||||
ReplyToSender: "Ada",
|
||||
ReplyToIsQuote: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(canonical.replyToId).toBe("discord-message-42");
|
||||
expect(canonical.replyToIdFull).toBe("discord:channel-1:discord-message-42");
|
||||
expect(canonical.replyToBody).toBe("quoted Discord reply body");
|
||||
expect(canonical.replyToSender).toBe("Ada");
|
||||
expect(canonical.replyToIsQuote).toBe(true);
|
||||
|
||||
expect(toPluginMessageContext(canonical)).toMatchObject({
|
||||
replyToId: "discord-message-42",
|
||||
replyToIdFull: "discord:channel-1:discord-message-42",
|
||||
replyToBody: "quoted Discord reply body",
|
||||
replyToSender: "Ada",
|
||||
replyToIsQuote: true,
|
||||
});
|
||||
|
||||
const claimContext = toPluginInboundClaimContext(canonical);
|
||||
expect(claimContext).toMatchObject({
|
||||
replyToId: "discord-message-42",
|
||||
replyToIdFull: "discord:channel-1:discord-message-42",
|
||||
replyToBody: "quoted Discord reply body",
|
||||
replyToSender: "Ada",
|
||||
replyToIsQuote: true,
|
||||
});
|
||||
|
||||
const claimEvent = toPluginInboundClaimEvent(canonical);
|
||||
expect(claimEvent).toMatchObject({
|
||||
replyToId: "discord-message-42",
|
||||
replyToIdFull: "discord:channel-1:discord-message-42",
|
||||
replyToBody: "quoted Discord reply body",
|
||||
replyToSender: "Ada",
|
||||
replyToIsQuote: true,
|
||||
});
|
||||
expect(claimEvent.metadata).toMatchObject({
|
||||
replyToId: "discord-message-42",
|
||||
replyToIdFull: "discord:channel-1:discord-message-42",
|
||||
replyToBody: "quoted Discord reply body",
|
||||
replyToSender: "Ada",
|
||||
replyToIsQuote: true,
|
||||
});
|
||||
|
||||
const receivedEvent = toPluginMessageReceivedEvent(canonical);
|
||||
expect(receivedEvent).toMatchObject({
|
||||
replyToId: "discord-message-42",
|
||||
replyToIdFull: "discord:channel-1:discord-message-42",
|
||||
replyToBody: "quoted Discord reply body",
|
||||
replyToSender: "Ada",
|
||||
replyToIsQuote: true,
|
||||
});
|
||||
expect(receivedEvent.metadata).toMatchObject({
|
||||
replyToId: "discord-message-42",
|
||||
replyToIdFull: "discord:channel-1:discord-message-42",
|
||||
replyToBody: "quoted Discord reply body",
|
||||
replyToSender: "Ada",
|
||||
replyToIsQuote: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -43,8 +43,10 @@ export type CanonicalInboundMessageHookContext = {
|
||||
senderUsername?: string;
|
||||
senderE164?: string;
|
||||
replyToId?: string;
|
||||
replyToIdFull?: string;
|
||||
replyToBody?: string;
|
||||
replyToSender?: string;
|
||||
replyToIsQuote?: boolean;
|
||||
provider?: string;
|
||||
surface?: string;
|
||||
threadId?: string | number;
|
||||
@@ -148,8 +150,10 @@ export function deriveInboundMessageHookContext(
|
||||
senderUsername: ctx.SenderUsername,
|
||||
senderE164: ctx.SenderE164,
|
||||
replyToId: ctx.ReplyToId,
|
||||
replyToIdFull: ctx.ReplyToIdFull,
|
||||
replyToBody: ctx.ReplyToBody,
|
||||
replyToSender: ctx.ReplyToSender,
|
||||
replyToIsQuote: ctx.ReplyToIsQuote,
|
||||
provider: ctx.Provider,
|
||||
surface: ctx.Surface,
|
||||
threadId: ctx.MessageThreadId,
|
||||
@@ -250,12 +254,18 @@ export function toPluginMessageContext(
|
||||
if ("replyToId" in canonical && canonical.replyToId !== undefined) {
|
||||
context.replyToId = canonical.replyToId;
|
||||
}
|
||||
if ("replyToIdFull" in canonical && canonical.replyToIdFull !== undefined) {
|
||||
context.replyToIdFull = canonical.replyToIdFull;
|
||||
}
|
||||
if ("replyToBody" in canonical && canonical.replyToBody !== undefined) {
|
||||
context.replyToBody = canonical.replyToBody;
|
||||
}
|
||||
if ("replyToSender" in canonical && canonical.replyToSender !== undefined) {
|
||||
context.replyToSender = canonical.replyToSender;
|
||||
}
|
||||
if ("replyToIsQuote" in canonical && canonical.replyToIsQuote !== undefined) {
|
||||
context.replyToIsQuote = canonical.replyToIsQuote;
|
||||
}
|
||||
assignTraceFields(context, canonical.trace);
|
||||
if (canonical.callDepth != null) {
|
||||
context.callDepth = canonical.callDepth;
|
||||
@@ -323,12 +333,18 @@ export function toPluginInboundClaimContext(
|
||||
if (canonical.replyToId !== undefined) {
|
||||
context.replyToId = canonical.replyToId;
|
||||
}
|
||||
if (canonical.replyToIdFull !== undefined) {
|
||||
context.replyToIdFull = canonical.replyToIdFull;
|
||||
}
|
||||
if (canonical.replyToBody !== undefined) {
|
||||
context.replyToBody = canonical.replyToBody;
|
||||
}
|
||||
if (canonical.replyToSender !== undefined) {
|
||||
context.replyToSender = canonical.replyToSender;
|
||||
}
|
||||
if (canonical.replyToIsQuote !== undefined) {
|
||||
context.replyToIsQuote = canonical.replyToIsQuote;
|
||||
}
|
||||
assignTraceFields(context, canonical.trace);
|
||||
return context;
|
||||
}
|
||||
@@ -355,8 +371,10 @@ export function toPluginInboundClaimEvent(
|
||||
senderName: canonical.senderName,
|
||||
senderUsername: canonical.senderUsername,
|
||||
...(canonical.replyToId !== undefined ? { replyToId: canonical.replyToId } : {}),
|
||||
...(canonical.replyToIdFull !== undefined ? { replyToIdFull: canonical.replyToIdFull } : {}),
|
||||
...(canonical.replyToBody !== undefined ? { replyToBody: canonical.replyToBody } : {}),
|
||||
...(canonical.replyToSender !== undefined ? { replyToSender: canonical.replyToSender } : {}),
|
||||
...(canonical.replyToIsQuote !== undefined ? { replyToIsQuote: canonical.replyToIsQuote } : {}),
|
||||
threadId: canonical.threadId,
|
||||
messageId: canonical.messageId,
|
||||
sessionKey: canonical.sessionKey,
|
||||
@@ -373,8 +391,10 @@ export function toPluginInboundClaimEvent(
|
||||
originatingTo: canonical.originatingTo,
|
||||
senderE164: canonical.senderE164,
|
||||
replyToId: canonical.replyToId,
|
||||
replyToIdFull: canonical.replyToIdFull,
|
||||
replyToBody: canonical.replyToBody,
|
||||
replyToSender: canonical.replyToSender,
|
||||
replyToIsQuote: canonical.replyToIsQuote,
|
||||
mediaPath: canonical.mediaPath,
|
||||
mediaUrl: canonical.mediaUrl,
|
||||
mediaType: canonical.mediaType,
|
||||
@@ -402,8 +422,10 @@ export function toPluginMessageReceivedEvent(
|
||||
messageId: canonical.messageId,
|
||||
senderId: canonical.senderId,
|
||||
...(canonical.replyToId !== undefined ? { replyToId: canonical.replyToId } : {}),
|
||||
...(canonical.replyToIdFull !== undefined ? { replyToIdFull: canonical.replyToIdFull } : {}),
|
||||
...(canonical.replyToBody !== undefined ? { replyToBody: canonical.replyToBody } : {}),
|
||||
...(canonical.replyToSender !== undefined ? { replyToSender: canonical.replyToSender } : {}),
|
||||
...(canonical.replyToIsQuote !== undefined ? { replyToIsQuote: canonical.replyToIsQuote } : {}),
|
||||
sessionKey: canonical.sessionKey,
|
||||
runId: canonical.runId,
|
||||
metadata: {
|
||||
@@ -419,8 +441,10 @@ export function toPluginMessageReceivedEvent(
|
||||
senderUsername: canonical.senderUsername,
|
||||
senderE164: canonical.senderE164,
|
||||
replyToId: canonical.replyToId,
|
||||
replyToIdFull: canonical.replyToIdFull,
|
||||
replyToBody: canonical.replyToBody,
|
||||
replyToSender: canonical.replyToSender,
|
||||
replyToIsQuote: canonical.replyToIsQuote,
|
||||
mediaPath: canonical.mediaPath,
|
||||
mediaUrl: canonical.mediaUrl,
|
||||
mediaType: canonical.mediaType,
|
||||
|
||||
@@ -46,8 +46,10 @@ export type PluginHookMessageContext = {
|
||||
messageId?: string;
|
||||
senderId?: string;
|
||||
replyToId?: string;
|
||||
replyToIdFull?: string;
|
||||
replyToBody?: string;
|
||||
replyToSender?: string;
|
||||
replyToIsQuote?: boolean;
|
||||
trace?: DiagnosticTraceContext;
|
||||
traceId?: string;
|
||||
spanId?: string;
|
||||
@@ -76,8 +78,10 @@ export type PluginHookInboundClaimEvent = {
|
||||
senderName?: string;
|
||||
senderUsername?: string;
|
||||
replyToId?: string;
|
||||
replyToIdFull?: string;
|
||||
replyToBody?: string;
|
||||
replyToSender?: string;
|
||||
replyToIsQuote?: boolean;
|
||||
threadId?: string | number;
|
||||
messageId?: string;
|
||||
sessionKey?: string;
|
||||
@@ -100,8 +104,10 @@ export type PluginHookMessageReceivedEvent = {
|
||||
messageId?: string;
|
||||
senderId?: string;
|
||||
replyToId?: string;
|
||||
replyToIdFull?: string;
|
||||
replyToBody?: string;
|
||||
replyToSender?: string;
|
||||
replyToIsQuote?: boolean;
|
||||
sessionKey?: string;
|
||||
runId?: string;
|
||||
trace?: DiagnosticTraceContext;
|
||||
|
||||
@@ -420,8 +420,10 @@ export type PluginHookBeforeDispatchEvent = {
|
||||
sessionKey?: string;
|
||||
senderId?: string;
|
||||
replyToId?: string;
|
||||
replyToIdFull?: string;
|
||||
replyToBody?: string;
|
||||
replyToSender?: string;
|
||||
replyToIsQuote?: boolean;
|
||||
isGroup?: boolean;
|
||||
timestamp?: number;
|
||||
};
|
||||
@@ -433,8 +435,10 @@ export type PluginHookBeforeDispatchContext = {
|
||||
sessionKey?: string;
|
||||
senderId?: string;
|
||||
replyToId?: string;
|
||||
replyToIdFull?: string;
|
||||
replyToBody?: string;
|
||||
replyToSender?: string;
|
||||
replyToIsQuote?: boolean;
|
||||
};
|
||||
|
||||
export type PluginHookBeforeDispatchResult = {
|
||||
|
||||
Reference in New Issue
Block a user