Skip to content

fix(discord): dedupe inbound messages by message id#41145

Closed
Garyko0730 wants to merge 2 commits intoopenclaw:mainfrom
Garyko0730:fix/discord-messageid-dedupe
Closed

fix(discord): dedupe inbound messages by message id#41145
Garyko0730 wants to merge 2 commits intoopenclaw:mainfrom
Garyko0730:fix/discord-messageid-dedupe

Conversation

@Garyko0730
Copy link
Copy Markdown

Summary

This PR prevents duplicate processing of the same Discord inbound message by adding a short-TTL dedupe guard keyed by Discord message.id before the message enters the debounce queue.

Problem

OpenClaw already has duplicate protection in later stages (for example queued follow-up handling, and more general inbound dedupe at dispatch time), but a duplicate Discord event can still be admitted earlier in the pipeline.

In practice, this can happen when the same Discord message is replayed/re-injected into an active session path (observed around model switching in a live local deployment). When that happens, the same physical Discord message may enter processing twice.

The result can be:

  • duplicate session processing for the same inbound message
  • duplicate visible replies, or
  • user-visible behavior that only gets suppressed later by downstream safeguards

Fix

Add a lightweight inbound dedupe cache in src/discord/monitor/message-handler.ts keyed by:

  • accountId
  • channelId
  • authorId
  • message.id

This guard runs before debouncer.enqueue(...), so a duplicate Discord message never consumes debounce capacity and never re-enters the downstream processing path.

Why this approach

  • Preserves the existing debounce behavior (accountId + channelId + authorId) for normal short-burst message coalescing
  • Adds a stricter idempotency barrier specifically for duplicate physical Discord messages
  • Fixes the bug earlier in the pipeline instead of relying on downstream NO_REPLY / duplicate suppression behavior

Tests

Added a test in src/discord/monitor/message-handler.queue.test.ts to verify that two inbound Discord events with the same message.id are only processed once.

Validated locally with:

npx vitest run src/discord/monitor/message-handler.queue.test.ts --config vitest.channels.config.ts

Related

Notes

This PR intentionally keeps scope small and focused on Discord inbound idempotency. If maintainers want a second session-level guard as defense-in-depth for model-switch replay paths, that can be proposed separately.

@openclaw-barnacle openclaw-barnacle bot added channel: discord Channel integration: discord size: S labels Mar 9, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 9, 2026

Greptile Summary

This PR introduces a lightweight inbound dedupe guard in src/discord/monitor/message-handler.ts that drops duplicate Discord message events (same message.id) before they reach the debounce queue, preventing duplicate session processing and user-visible double replies observed during model switching.

  • A module-level DedupeCache (2-minute TTL, 10k max entries) keyed by accountId + channelId + authorId + message.id is added as a pre-queue idempotency barrier.
  • The guard correctly fires after the existing bot-own-message filter and before debouncer.enqueue(...), preserving existing debounce behavior for normal message coalescing.
  • A resetDiscordInboundMessageDedupe export is added for test isolation, with a matching beforeEach reset in the test suite.
  • One issue identified: the dedupe key is registered in the cache before debouncer.enqueue is awaited. If debouncer.enqueue throws, the key remains in the cache and the same message.id will be silently suppressed for up to 2 minutes — even though the message was never successfully processed. Moving the check call to after the await (or evicting the key on error) would preserve the ability to retry genuinely failed enqueues.

Confidence Score: 3/5

  • Safe to merge with low risk, but the pre-enqueue key registration can silently suppress retries of the same message after an enqueue failure.
  • The overall approach is correct and well-tested. The dedupe key is scoped precisely (accountId + channelId + authorId + messageId), the TTL and max-size bounds are reasonable, and the placement before the debounce queue is appropriate. The score is reduced because the key is committed to the cache before the enqueue succeeds — a failed enqueue will cause the message to be silently lost for 2 minutes instead of being retryable, which is a behavioral regression from the pre-PR state.
  • src/discord/monitor/message-handler.ts — review the ordering of discordInboundMessageDedupe.check(dedupeKey) relative to await debouncer.enqueue(...).

Last reviewed commit: 6bc1c52

Comment on lines 209 to 220
const dedupeKey = buildDiscordInboundMessageDedupeKey({
accountId: params.accountId,
data,
});
if (dedupeKey && discordInboundMessageDedupe.peek(dedupeKey)) {
return;
}
if (dedupeKey) {
discordInboundMessageDedupe.check(dedupeKey);
}

await debouncer.enqueue({ data, client, abortSignal: options?.abortSignal });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dedupe key registered before enqueue — message silently dropped on enqueue failure

The dedupe key is inserted into the cache before debouncer.enqueue(...) is awaited. If debouncer.enqueue throws (or rejects), the catch block logs the error and the handler returns — but the key is already in the cache. Any subsequent delivery of the exact same message.id will be silently dropped for the full 2-minute TTL, even though the message was never successfully enqueued.

Before this PR, a failed enqueue would have allowed the same message to be retried on the next delivery. After this change, it is silently suppressed for up to 2 minutes.

To preserve retry-ability on enqueue failures, the key should only be committed to the cache after debouncer.enqueue succeeds — or it should be evicted from the cache in the error path. Note that DedupeCache does not currently expose a single-key delete method, so the cleanest fix is to move discordInboundMessageDedupe.check(dedupeKey) to after the await debouncer.enqueue(...) call succeeds.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/discord/monitor/message-handler.ts
Line: 209-220

Comment:
**Dedupe key registered before enqueue — message silently dropped on enqueue failure**

The dedupe key is inserted into the cache _before_ `debouncer.enqueue(...)` is awaited. If `debouncer.enqueue` throws (or rejects), the `catch` block logs the error and the handler returns — but the key is already in the cache. Any subsequent delivery of the exact same `message.id` will be silently dropped for the full 2-minute TTL, even though the message was never successfully enqueued.

Before this PR, a failed enqueue would have allowed the same message to be retried on the next delivery. After this change, it is silently suppressed for up to 2 minutes.

To preserve retry-ability on enqueue failures, the key should only be committed to the cache after `debouncer.enqueue` succeeds — or it should be evicted from the cache in the error path. Note that `DedupeCache` does not currently expose a single-key delete method, so the cleanest fix is to move `discordInboundMessageDedupe.check(dedupeKey)` to after the `await debouncer.enqueue(...)` call succeeds.

How can I resolve this? If you propose a fix, please make it concise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: discord Channel integration: discord size: S

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant