From 70dcd81f03beab644fef073b0274fb4b806cb72c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 10:36:13 +0100 Subject: [PATCH] fix: canonicalize discord pluralkit inbound ids --- CHANGELOG.md | 1 + .../src/monitor/message-handler.context.ts | 6 +- .../message-handler.preflight-pluralkit.ts | 3 +- .../monitor/message-handler.preflight.test.ts | 79 +++++++++++++++++++ .../src/monitor/message-handler.preflight.ts | 43 +++++----- .../message-handler.preflight.types.ts | 1 + .../monitor/message-handler.process.test.ts | 23 ++++++ 7 files changed, 131 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index deee70e2e16..bf944f598f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai - Discord: allow explicitly configured ack reactions in tool-only guild channels while keeping automatic lifecycle/status reactions suppressed. Fixes #74922. Thanks @samvilian and @BlueBirdBack. - Discord: preserve attachment and sticker filenames when saving inbound media, so agents can see human-readable file names instead of only UUID-based paths. Fixes #59744. Thanks @xela92 and @rockcent. - Discord: preserve non-ASCII channel names in session display labels while keeping allowlist matching on the existing ASCII slug contract. Thanks @swjeong9. +- Discord/PluralKit: canonicalize proxied webhook turns to the original Discord message id for inbound dedupe, while preserving the proxy message id for reply routing. Thanks @acgh213. - Gateway/diagnostics: include a bounded redacted startup error message in stability bundles, so crash-loop reports identify the failing plugin or contract without exposing secrets. Refs #75797. Thanks @ymebosma. - Gateway/pricing: abort in-flight model pricing catalog fetches when Gateway shutdown stops the refresh loop, and avoid post-stop cache writes or refresh timers. Fixes #72208. Thanks @rzcq. - Codex/app-server: make startup retry cleanup ownership-aware so concurrent Codex lanes cannot close another lane's freshly restarted shared app-server client. Thanks @vincentkoc. diff --git a/extensions/discord/src/monitor/message-handler.context.ts b/extensions/discord/src/monitor/message-handler.context.ts index 9456dc0f08c..a83e9e39a03 100644 --- a/extensions/discord/src/monitor/message-handler.context.ts +++ b/extensions/discord/src/monitor/message-handler.context.ts @@ -52,6 +52,7 @@ export async function buildDiscordMessageProcessContext(params: { message, author, sender, + canonicalMessageId, data, client, channelInfo, @@ -323,7 +324,10 @@ export async function buildDiscordMessageProcessContext(params: { Provider: "discord" as const, Surface: "discord" as const, WasMentioned: ctx.effectiveWasMentioned, - MessageSid: message.id, + MessageSid: canonicalMessageId ?? message.id, + ...(canonicalMessageId && canonicalMessageId !== message.id + ? { MessageSidFull: message.id } + : {}), ReplyToId: filteredReplyContext?.id, ReplyToBody: filteredReplyContext?.body, ReplyToSender: filteredReplyContext?.sender, diff --git a/extensions/discord/src/monitor/message-handler.preflight-pluralkit.ts b/extensions/discord/src/monitor/message-handler.preflight-pluralkit.ts index c63269e2d00..0a2e2682a8f 100644 --- a/extensions/discord/src/monitor/message-handler.preflight-pluralkit.ts +++ b/extensions/discord/src/monitor/message-handler.preflight-pluralkit.ts @@ -4,13 +4,12 @@ import type { DiscordMessageEvent } from "./message-handler.preflight.types.js"; export async function resolveDiscordPreflightPluralKitInfo(params: { message: DiscordMessageEvent["message"]; - webhookId?: string | null; config?: NonNullable< NonNullable["discord"] >["pluralkit"]; abortSignal?: AbortSignal; }): Promise>> { - if (!params.config?.enabled || params.webhookId) { + if (!params.config?.enabled) { return null; } try { diff --git a/extensions/discord/src/monitor/message-handler.preflight.test.ts b/extensions/discord/src/monitor/message-handler.preflight.test.ts index 042088eb9b3..2b805d61ae5 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.test.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.test.ts @@ -3,9 +3,13 @@ import { ChannelType } from "../internal/discord.js"; import { createPartialDiscordChannelWithThrowingGetters } from "../test-support/partial-channel.js"; const transcribeFirstAudioMock = vi.hoisted(() => vi.fn()); +const fetchPluralKitMessageInfoMock = vi.hoisted(() => vi.fn()); const resolveDiscordDmCommandAccessMock = vi.hoisted(() => vi.fn()); const handleDiscordDmCommandDecisionMock = vi.hoisted(() => vi.fn(async () => {})); +vi.mock("../pluralkit.js", () => ({ + fetchPluralKitMessageInfo: (...args: unknown[]) => fetchPluralKitMessageInfoMock(...args), +})); vi.mock("./preflight-audio.runtime.js", () => ({ transcribeFirstAudio: transcribeFirstAudioMock, })); @@ -45,6 +49,10 @@ beforeAll(async () => { await import("./thread-bindings.js")); }); +beforeEach(() => { + fetchPluralKitMessageInfoMock.mockReset(); +}); + function createThreadBinding( overrides?: Partial, ) { @@ -717,6 +725,77 @@ describe("preflightDiscordMessage", () => { expect(result).toBeNull(); }); + it("canonicalizes PluralKit webhook messages to the original Discord message id", async () => { + fetchPluralKitMessageInfoMock.mockResolvedValue({ + id: "proxy-456", + original: "orig-123", + member: { id: "member-1", name: "Echo" }, + system: { id: "system-1", name: "System" }, + }); + + const result = await runGuildPreflight({ + channelId: "c1", + guildId: "g1", + message: createDiscordMessage({ + id: "proxy-456", + channelId: "c1", + content: "<@openclaw-bot> hello", + webhookId: "pluralkit-webhook-1", + author: { + id: "webhook-author", + bot: true, + username: "PluralKit", + }, + mentionedUsers: [{ id: "openclaw-bot" }], + }), + discordConfig: { + pluralkit: { enabled: true }, + } as DiscordConfig, + }); + + expect(fetchPluralKitMessageInfoMock).toHaveBeenCalledWith( + expect.objectContaining({ + messageId: "proxy-456", + config: expect.objectContaining({ enabled: true }), + }), + ); + expect(result).not.toBeNull(); + expect(result?.sender.isPluralKit).toBe(true); + expect(result?.canonicalMessageId).toBe("orig-123"); + }); + + it("skips PluralKit lookup for bound-thread webhook echoes", async () => { + const threadBinding = createThreadBinding({ + targetKind: "session", + targetSessionKey: "agent:main:acp:discord-thread-1", + }); + const threadId = "thread-webhook-pk-echo-1"; + const parentId = "channel-parent-webhook-pk-echo-1"; + + const result = await runThreadBoundPreflight({ + threadId, + parentId, + threadBinding, + message: createDiscordMessage({ + id: "m-webhook-pk-echo-1", + channelId: threadId, + content: "proxied user message", + webhookId: "pluralkit-webhook-1", + author: { + id: "relay-bot-1", + bot: true, + username: "Proxy", + }, + }), + discordConfig: { + pluralkit: { enabled: true }, + } as DiscordConfig, + }); + + expect(result).toBeNull(); + expect(fetchPluralKitMessageInfoMock).not.toHaveBeenCalled(); + }); + it("bypasses mention gating in bound threads for allowed bot senders", async () => { const threadBinding = createThreadBinding(); const threadId = "thread-bot-focus"; diff --git a/extensions/discord/src/monitor/message-handler.preflight.ts b/extensions/discord/src/monitor/message-handler.preflight.ts index d7a7ada4986..1d0d0cad50a 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.ts @@ -121,28 +121,6 @@ export async function preflightDiscordMessage( const pluralkitConfig = params.discordConfig?.pluralkit; const webhookId = resolveDiscordWebhookId(message); - const pluralkitInfo = await resolveDiscordPreflightPluralKitInfo({ - message, - webhookId, - config: pluralkitConfig, - abortSignal: params.abortSignal, - }); - if (isPreflightAborted(params.abortSignal)) { - return null; - } - const sender = resolveDiscordSenderIdentity({ - author, - member: params.data.member, - pluralkitInfo, - }); - - if (author.bot) { - if (allowBotsMode === "off" && !sender.isPluralKit) { - logVerbose("discord: drop bot message (allowBots=false)"); - return null; - } - } - const isGuildMessage = Boolean(params.data.guild_id); const channelInfo = await resolveDiscordChannelInfo(params.client, messageChannelId); if (isPreflightAborted(params.abortSignal)) { @@ -189,6 +167,26 @@ export async function preflightDiscordMessage( logVerbose(`discord: drop bound-thread bot system message ${message.id}`); return null; } + const pluralkitInfo = await resolveDiscordPreflightPluralKitInfo({ + message, + config: pluralkitConfig, + abortSignal: params.abortSignal, + }); + if (isPreflightAborted(params.abortSignal)) { + return null; + } + const sender = resolveDiscordSenderIdentity({ + author, + member: params.data.member, + pluralkitInfo, + }); + + if (author.bot) { + if (allowBotsMode === "off" && !sender.isPluralKit) { + logVerbose("discord: drop bot message (allowBots=false)"); + return null; + } + } const data = message === params.data.message ? params.data : { ...params.data, message }; logDebug( `[discord-preflight] channelId=${messageChannelId} guild_id=${params.data.guild_id} channelType=${channelInfo?.type} isGuild=${isGuildMessage} isDM=${isDirectMessage} isGroupDm=${isGroupDm}`, @@ -639,6 +637,7 @@ export async function preflightDiscordMessage( messageChannelId, author, sender, + canonicalMessageId: pluralkitInfo?.original?.trim() || undefined, memberRoleIds, channelInfo, channelName, diff --git a/extensions/discord/src/monitor/message-handler.preflight.types.ts b/extensions/discord/src/monitor/message-handler.preflight.types.ts index 1ea71002570..7a12494c3dd 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.types.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.types.ts @@ -42,6 +42,7 @@ export type DiscordMessagePreflightContext = DiscordMessagePreflightSharedFields messageChannelId: string; author: User; sender: DiscordSenderIdentity; + canonicalMessageId?: string; memberRoleIds: string[]; channelInfo: DiscordChannelInfo | null; diff --git a/extensions/discord/src/monitor/message-handler.process.test.ts b/extensions/discord/src/monitor/message-handler.process.test.ts index 8622c4be862..ca23c83ea43 100644 --- a/extensions/discord/src/monitor/message-handler.process.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.test.ts @@ -357,6 +357,8 @@ function getLastDispatchCtx(): CommandBody?: string; From?: string; MediaTranscribedIndexes?: number[]; + MessageSid?: string; + MessageSidFull?: string; MessageThreadId?: string | number; ModelParentSessionKey?: string; OriginatingTo?: string; @@ -375,6 +377,8 @@ function getLastDispatchCtx(): CommandBody?: string; From?: string; MediaTranscribedIndexes?: number[]; + MessageSid?: string; + MessageSidFull?: string; MessageThreadId?: string | number; ModelParentSessionKey?: string; OriginatingTo?: string; @@ -925,6 +929,25 @@ describe("processDiscordMessage session routing", () => { expect(sendMocks.removeReactionDiscord).not.toHaveBeenCalled(); }); + it("uses PluralKit original ids for inbound dedupe while preserving the Discord message id", async () => { + const ctx = await createBaseContext({ + canonicalMessageId: "orig-123", + message: { + id: "proxy-456", + channelId: "c1", + timestamp: new Date().toISOString(), + attachments: [], + }, + }); + + await runProcessDiscordMessage(ctx); + + expect(getLastDispatchCtx()).toMatchObject({ + MessageSid: "orig-123", + MessageSidFull: "proxy-456", + }); + }); + it("defaults guild replies to message-tool-only source delivery", async () => { await runProcessDiscordMessage( await createBaseContext({