From b08eef0508752e853277d15ea85e4ed7a5530b2a Mon Sep 17 00:00:00 2001 From: FullerStackDev <263060202+fuller-stack-dev@users.noreply.github.com> Date: Thu, 4 Jun 2026 02:54:27 -0600 Subject: [PATCH] fix(discord): hydrate reply context metadata --- docs/plugins/hooks.md | 5 +- .../monitor/message-handler.hydration.test.ts | 147 ++++++++++++++++++ .../src/monitor/message-handler.hydration.ts | 24 ++- .../reply/dispatch-from-config.test.ts | 10 ++ src/auto-reply/reply/dispatch-from-config.ts | 4 + src/hooks/message-hook-mappers.test.ts | 16 ++ src/hooks/message-hook-mappers.ts | 24 +++ src/plugins/hook-message.types.ts | 6 + src/plugins/hook-types.ts | 4 + 9 files changed, 237 insertions(+), 3 deletions(-) diff --git a/docs/plugins/hooks.md b/docs/plugins/hooks.md index 8b639bd3098b..2c374acb4fb8 100644 --- a/docs/plugins/hooks.md +++ b/docs/plugins/hooks.md @@ -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. diff --git a/extensions/discord/src/monitor/message-handler.hydration.test.ts b/extensions/discord/src/monitor/message-handler.hydration.test.ts index 1d9405640141..9754d32498d8 100644 --- a/extensions/discord/src/monitor/message-handler.hydration.test.ts +++ b/extensions/discord/src/monitor/message-handler.hydration.test.ts @@ -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); + }); }); diff --git a/extensions/discord/src/monitor/message-handler.hydration.ts b/extensions/discord/src/monitor/message-handler.hydration.ts index 730be1af2072..329d8ece4e1d 100644 --- a/extensions/discord/src/monitor/message-handler.hydration.ts +++ b/extensions/discord/src/monitor/message-handler.hydration.ts @@ -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[0] }; message: Message; diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index a0c082316b7d..fb81730fd88f 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -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, }); }); diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index ee30a5236ea2..cd04b1f148b8 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -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, }, ), ), diff --git a/src/hooks/message-hook-mappers.test.ts b/src/hooks/message-hook-mappers.test.ts index f380db6c0d87..e5db5ad1c248 100644 --- a/src/hooks/message-hook-mappers.test.ts +++ b/src/hooks/message-hook-mappers.test.ts @@ -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, }); }); diff --git a/src/hooks/message-hook-mappers.ts b/src/hooks/message-hook-mappers.ts index 7570b5646131..92cbe1314593 100644 --- a/src/hooks/message-hook-mappers.ts +++ b/src/hooks/message-hook-mappers.ts @@ -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, diff --git a/src/plugins/hook-message.types.ts b/src/plugins/hook-message.types.ts index 4241edde0109..bf4571860546 100644 --- a/src/plugins/hook-message.types.ts +++ b/src/plugins/hook-message.types.ts @@ -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; diff --git a/src/plugins/hook-types.ts b/src/plugins/hook-types.ts index 22ddc36bf1bf..17d2de6f118f 100644 --- a/src/plugins/hook-types.ts +++ b/src/plugins/hook-types.ts @@ -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 = {