Skip to content

fix(bluebubbles): add staleness gate and GUID dedup for inbound messages#31159

Closed
dashhuang wants to merge 2 commits intoopenclaw:mainfrom
dashhuang:fix/bluebubbles-message-dedup
Closed

fix(bluebubbles): add staleness gate and GUID dedup for inbound messages#31159
dashhuang wants to merge 2 commits intoopenclaw:mainfrom
dashhuang:fix/bluebubbles-message-dedup

Conversation

@dashhuang
Copy link
Copy Markdown
Contributor

Summary

BlueBubbles MessagePoller uses a 1-week lookback window and can re-fire old messages as webhook events after server restarts or reconnections. OpenClaw currently has no protection against this, causing duplicate message processing and confusing replies to stale messages.

This PR adds two guards at the top of processMessage() in the BlueBubbles extension:

  • Staleness check: drop inbound messages with timestamps older than 5 minutes
  • GUID dedup: track recently processed message GUIDs in a TTL'd Map (30 min TTL, 5K entry cap) and drop duplicates

Both guards run before any other processing and log dropped messages via logVerbose.

Context

Design decisions

  • Hardcoded 5-min threshold rather than configurable maxMessageAgeSec — simpler, and 5 minutes is generous enough for any legitimate delivery delay. A configurable option could be added later if needed.
  • GUID dedup is in-memory — resets on gateway restart, but that's acceptable since the staleness gate catches old replays regardless. The dedup primarily protects against rapid duplicate webhook deliveries within a session.
  • Guards before fromMe check — stale/duplicate fromMe messages should also be dropped to avoid unnecessary cache operations.

Test plan

  • Tested on macOS 26.3 (Apple Silicon) with BlueBubbles Server
  • Gateway starts without TypeScript errors (Node.js 25.6.1 strip mode)
  • Normal messages are processed correctly
  • Stale messages (>5 min old) are dropped with verbose log
  • Duplicate GUIDs are dropped with verbose log
  • Maintainer review

Fixes #19176
Ref #12053

🤖 Generated with Claude Code

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 13b5b566ed

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

function _isDuplicateGuid(guid: string | undefined): boolean {
if (!guid) return false;
const now = Date.now();
if (_dedupGuidCache.size > DEDUP_GUID_CACHE_MAX) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Enforce GUID cache size cap on dedup map

The dedup map is only pruned when size > DEDUP_GUID_CACHE_MAX, and that pruning removes only expired entries, so bursts of >5k unique GUIDs inside the 30-minute TTL will not evict anything and the map will keep growing past the intended cap. This makes the claimed 5k-entry bound ineffective and can cause avoidable memory/CPU growth (full-map scans on each message once over threshold) in busy BlueBubbles deployments.

Useful? React with 👍 / 👎.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 2, 2026

Greptile Summary

This PR adds two defensive guards to prevent BlueBubbles from processing stale or duplicate webhook messages that can be re-delivered after server restarts.

Key changes:

  • Staleness gate drops messages older than 5 minutes using timestamp comparison
  • GUID deduplication using an in-memory Map (30-min TTL, ~5K entry soft cap)
  • Both checks run before any message processing, including the fromMe filter
  • Dropped messages are logged via logVerbose for debugging

Implementation notes:

  • Timestamp is correctly normalized to milliseconds in the existing code, so the staleness check is accurate
  • The GUID cache uses lazy cleanup: expired entries are purged when cache exceeds 5000 entries
  • Cache can theoretically grow beyond 5000 if message volume exceeds ~2.7 msg/sec sustained (unlikely for personal messaging)
  • In-memory cache resets on gateway restart, but the staleness gate handles old replays regardless

Production validation:

Confidence Score: 5/5

  • This PR is safe to merge with minimal risk
  • Simple, focused change that adds defensive guards against a documented problem. The implementation correctly handles edge cases (undefined timestamps/GUIDs), uses appropriate data structures, and has been validated in production. The in-memory cache design is pragmatic for the personal messaging use case where sustained high-volume message processing is unlikely.
  • No files require special attention

Last reviewed commit: 13b5b56

@openclaw-barnacle
Copy link
Copy Markdown

This pull request has been automatically marked as stale due to inactivity.
Please add updates or it will be closed.

@openclaw-barnacle openclaw-barnacle Bot added the stale Marked as stale due to inactivity label Apr 6, 2026
@dashhuang dashhuang force-pushed the fix/bluebubbles-message-dedup branch from 13b5b56 to 3c6cf2c Compare April 6, 2026 06:19
@dashhuang
Copy link
Copy Markdown
Contributor Author

Refreshed this PR onto current \ and resolved the BlueBubbles processing entrypoint drift. The patch remains the same focused behavior change: drop stale inbound messages and dedupe recently seen GUIDs before normal processing.\n\nProduction context is unchanged on our side: this is still a live local delta we carry for BlueBubbles replay/reconnect behavior.\n\nNote: I did not add a dedicated BlueBubbles test here because there still does not appear to be a direct test seam around this webhook path; this refresh is intentionally kept as the minimal one-file patch.

@dashhuang
Copy link
Copy Markdown
Contributor Author

Refreshed this PR onto current main and resolved the BlueBubbles processing entrypoint drift. The patch remains the same focused behavior change: drop stale inbound messages and dedupe recently seen GUIDs before normal processing.

Production context is unchanged on our side: this is still a live local delta we carry for BlueBubbles replay/reconnect behavior.

Note: I did not add a dedicated BlueBubbles test here because there still does not appear to be a direct test seam around this webhook path; this refresh is intentionally kept as the minimal one-file patch.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3c6cf2cb1d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

return age > STALE_MESSAGE_MAX_AGE_MS;
}

function _isDuplicateGuid(guid: string | undefined): boolean {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Scope GUID dedup cache per account

The new dedup cache is module-global and keyed only by guid, so processMessage will treat the same message ID from different BlueBubbles accounts as a duplicate and drop the later one. This can silently lose legitimate inbound messages in multi-account deployments (for example when two accounts emit overlapping ID formats), because account context is available in processMessage but not part of the dedup key.

Useful? React with 👍 / 👎.

@dashhuang
Copy link
Copy Markdown
Contributor Author

I pushed a follow-up test-only fix for the BlueBubbles CI failures.

Root cause: the new inbound GUID dedupe cache is module-global, while many BlueBubbles webhook tests reuse the default guid: "msg-1". After the first case, later cases were short-circuiting as duplicates, which is why the failures showed up as a wide spread of expected vi.fn() to be called / Number of calls: 0 assertions across monitor.test.ts and monitor.webhook-auth.test.ts.

Fix: add a dedicated test reset hook for the inbound dedupe cache and call it from the shared BlueBubbles monitor test reset helper. This keeps production behavior unchanged and only restores test isolation between cases.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 61acc3b630

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

}
}
if (_dedupGuidCache.has(guid)) return true;
_dedupGuidCache.set(guid, now);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Record GUID only after successful processing

_isDuplicateGuid stores every GUID before any downstream work runs, so a transient failure later in processMessage (for example during dispatch/model calls) still burns that GUID. When BlueBubbles re-delivers the same webhook, this guard drops it as a duplicate, turning retryable failures into permanent message loss; this is especially impactful because webhook processing errors are only logged and not retried in the current monitor flow.

Useful? React with 👍 / 👎.

@openclaw-barnacle openclaw-barnacle Bot removed the stale Marked as stale due to inactivity label Apr 7, 2026
@openclaw-barnacle
Copy link
Copy Markdown

This pull request has been automatically marked as stale due to inactivity.
Please add updates or it will be closed.

@openclaw-barnacle openclaw-barnacle Bot added the stale Marked as stale due to inactivity label Apr 12, 2026
@omarshahine
Copy link
Copy Markdown
Contributor

Taking a look at this — going to run it on macOS 26.3 / OpenClaw 2026.4.12 against a BlueBubbles 1.9.x server. The post-restart new-message replay symptom is biting me on every openclaw gateway restart (the agent replies to the most recent inbound message in the BB cache), so the staleness gate is exactly what I need. Will report back with results from a few days of runtime.

@omarshahine
Copy link
Copy Markdown
Contributor

Thanks for the original patch @dashhuang — the behavioral goal is exactly right, and this one sat in the queue for too long.

I've re-created and improved the fix over in #66230. The key change vs. this one is that the dedupe is now persistent on disk (via createPersistentDedupe, same pattern used by the Feishu plugin), so it survives the gateway-restart case that's actually the driver for the bug — plus it avoids the module-global mutable state that required test-reset plumbing here. You're credited in both the PR body and the CHANGELOG entry.

Closing in favor of #66230.

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

Labels

channel: bluebubbles Channel integration: bluebubbles size: S stale Marked as stale due to inactivity

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bluebubbles] Add maxMessageAge filter to drop stale re-delivered webhooks

2 participants