diff --git a/CHANGELOG.md b/CHANGELOG.md index 5416dc1bd4a..0ac5ce03427 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Gateway/logging: keep deferred channel startup logs on the subsystem logger, so Slack, Discord, Telegram, and voice-call startup messages keep timestamped prefixes. Thanks @vincentkoc. +- Discord/threads: ignore webhook-authored copies in already-bound Discord session threads even when the webhook id differs, preventing PluralKit proxy copies from creating duplicate turn pressure. Fixes #52005. Thanks @acgh213. - Discord/threads: return the created thread as partial success when the follow-up initial message fails, so agents do not retry thread creation and create empty duplicate threads. Fixes #48450. Thanks @dahifi. - Discord/components: consume every button or select in a non-reusable component message after the first authorized click, so single-use panels cannot fire sibling callbacks. Fixes #54227. Thanks @fujiwarakasei. - Discord/reactions: skip reaction listener registration when DMs and group DMs are disabled and every configured guild has `reactionNotifications: "off"`, avoiding needless reaction-event queue work. Fixes #47516. Thanks @x4v13r1120. diff --git a/extensions/discord/src/monitor/message-handler.preflight-helpers.ts b/extensions/discord/src/monitor/message-handler.preflight-helpers.ts index e6914988476..d298c1fe879 100644 --- a/extensions/discord/src/monitor/message-handler.preflight-helpers.ts +++ b/extensions/discord/src/monitor/message-handler.preflight-helpers.ts @@ -146,16 +146,19 @@ export function shouldIgnoreBoundThreadWebhookMessage(params: { normalizeOptionalString(params.threadBinding?.webhookId) ?? normalizeOptionalString(params.threadBinding?.metadata?.webhookId) ?? ""; - if (!boundWebhookId) { - const threadId = normalizeOptionalString(params.threadId) ?? ""; - if (!threadId) { - return false; - } - return isRecentlyUnboundThreadWebhookMessage({ - accountId: params.accountId, - threadId, - webhookId, - }); + if (boundWebhookId && webhookId === boundWebhookId) { + return true; } - return webhookId === boundWebhookId; + const threadId = normalizeOptionalString(params.threadId) ?? ""; + if (!threadId) { + return false; + } + if (params.threadBinding) { + return true; + } + return isRecentlyUnboundThreadWebhookMessage({ + accountId: params.accountId, + threadId, + webhookId, + }); } diff --git a/extensions/discord/src/monitor/message-handler.preflight.test.ts b/extensions/discord/src/monitor/message-handler.preflight.test.ts index 1f19cea7ee9..d9396e2a158 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.test.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.test.ts @@ -624,7 +624,7 @@ describe("preflightDiscordMessage", () => { expect(result?.boundSessionKey).toBe(threadBinding.targetSessionKey); }); - it("drops hydrated bound-thread webhook echoes after fetching an empty payload", async () => { + it("drops hydrated bound-thread webhook copies after fetching an empty payload", async () => { const threadBinding = createThreadBinding({ targetKind: "session", targetSessionKey: "agent:main:acp:discord-thread-1", @@ -685,6 +685,38 @@ describe("preflightDiscordMessage", () => { expect(result).toBeNull(); }); + it("drops bound-thread webhook copies from other webhook ids", async () => { + const threadBinding = createThreadBinding({ + targetKind: "session", + targetSessionKey: "agent:main:acp:discord-thread-1", + }); + const threadId = "thread-webhook-proxy-1"; + const parentId = "channel-parent-webhook-proxy-1"; + const message = createDiscordMessage({ + id: "m-webhook-proxy-1", + channelId: threadId, + content: "proxied user message", + webhook_id: "pluralkit-webhook-1", + author: { + id: "relay-bot-1", + bot: true, + username: "Proxy", + }, + }); + + const result = await runThreadBoundPreflight({ + threadId, + parentId, + message, + threadBinding, + discordConfig: { + allowBots: true, + } as DiscordConfig, + }); + + expect(result).toBeNull(); + }); + it("bypasses mention gating in bound threads for allowed bot senders", async () => { const threadBinding = createThreadBinding(); const threadId = "thread-bot-focus"; @@ -1445,18 +1477,20 @@ describe("shouldIgnoreBoundThreadWebhookMessage", () => { ).toBe(true); }); - it("returns false when webhook ids differ", () => { + it("returns true when a bound thread receives a different webhook id", () => { expect( shouldIgnoreBoundThreadWebhookMessage({ + threadId: "thread-1", webhookId: "wh-other", threadBinding: createThreadBinding(), }), - ).toBe(false); + ).toBe(true); }); - it("returns false when there is no bound thread webhook", () => { + it("returns true when a bound thread receives a webhook without a recorded bound webhook id", () => { expect( shouldIgnoreBoundThreadWebhookMessage({ + threadId: "thread-1", webhookId: "wh-1", threadBinding: createThreadBinding({ metadata: { @@ -1464,6 +1498,15 @@ describe("shouldIgnoreBoundThreadWebhookMessage", () => { }, }), }), + ).toBe(true); + }); + + it("returns false for differing webhook ids without a known thread id", () => { + expect( + shouldIgnoreBoundThreadWebhookMessage({ + webhookId: "wh-other", + threadBinding: createThreadBinding(), + }), ).toBe(false); });