fix(bluebubbles): add opt-in coalesceSameSenderDms for split-send DMs#69258
fix(bluebubbles): add opt-in coalesceSameSenderDms for split-send DMs#69258omarshahine merged 1 commit intomainfrom
Conversation
🔒 Aisle Security AnalysisWe found 1 potential security issue(s) in this PR:
1. 🟡 Unbounded coalescedMessageIds enables debounce-window DoS via O(N) memory and disk commits
DescriptionThe BlueBubbles DM coalescing change tracks every source With Impacts:
An attacker who can send many DMs quickly (or trigger many webhook events) can force large arrays and many disk operations per debounce flush, leading to resource exhaustion / DoS. Vulnerable code: const coalescedMessageIds: string[] = [];
for (const entry of entries) {
const id = entry.message.messageId?.trim();
if (!id || seenIds.has(id)) {
continue;
}
seenIds.add(id);
coalescedMessageIds.push(id);
}RecommendationBound and/or batch the tracking/commit of secondary IDs. Options (can be combined):
const MAX_COALESCED_IDS = 200; // example
...
if (coalescedMessageIds.length < MAX_COALESCED_IDS) {
coalescedMessageIds.push(id);
}
Analyzed PR: #69258 at commit Last updated on: 2026-04-21T08:43:08Z |
Greptile SummaryThis PR adds an opt-in Confidence Score: 5/5This PR is safe to merge — the feature is opt-in, the default behavior is unchanged, and the logic is well-guarded. All changes are additive and behind an opt-in flag. The buildKey guards (!isGroup, !balloonBundleId, !associatedMessageGuid) are correct and exhaustively cover the edge cases described. The Zod schema and TypeScript type are kept in sync. Three targeted test cases cover the on/off/group-chat paths. No pre-existing tests are broken. No security or data-integrity concerns identified. No files require special attention. Reviews (1): Last reviewed commit: "fix(bluebubbles): add opt-in coalesceSam..." | Re-trigger Greptile |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c2240901a5
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| !msg.isGroup && | ||
| !balloonBundleId && | ||
| !associatedMessageGuid | ||
| ) { | ||
| return `bluebubbles:${account.accountId}:dm:${chatKey}:${msg.senderId}`; |
There was a problem hiding this comment.
Keep dedupe semantics consistent with DM coalescing
When coalesceSameSenderDms is enabled, this branch merges distinct DM sends into one debounce bucket keyed by chat:sender, but downstream dedupe still finalizes only a single messageId (processMessage → resolveBlueBubblesInboundDedupeKey in extensions/bluebubbles/src/inbound-dedupe.ts). That means replay/order changes can either drop unseen messages (if a merged batch is keyed by an already-finalized ID) or reprocess a secondary message ID later as a duplicate turn. This is a data-loss/duplication risk specifically in restart/replay paths when multiple same-sender DMs were merged.
Useful? React with 👍 / 👎.
…wlist Review feedback on #69258: 1. Aisle Medium (CWE-400): "Unbounded DM message coalescing can create oversized payloads." With coalesceSameSenderDms enabled, a sender who rapid-fires many small DMs inside the debounce window would see all of them concatenated into one merged turn. Add explicit bounds in `combineDebounceEntries`: - Cap source entries at 10; beyond that, keep the first (original command/context) and the last (most recent payload) rather than silently dropping the tail. - Cap combined text at 4000 chars with an explicit `…[truncated]` marker so downstream LLM prompt size is bounded. - Cap merged attachments at 20 so media fan-out stays proportional to what a single message would carry. 2. CI `check-additional-boundaries` / `lint:tmp:no-raw-channel-fetch`: the drive-by signature change in `types.ts` shifted the raw-fetch callsite from line 189 to line 204. Update the allowlist. Test: new `bounds the coalesced output when many messages merge into one turn` exercises a 25-message flood and asserts the output text is truncated and the attachment list is capped. All 74 monitor tests and 10 inbound-dedupe tests pass.
|
Addressed review feedback in 8dcf358: Aisle security (Medium, CWE-400): Added explicit bounds in
A new test ( CI Unrelated CI flake: the two |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8dcf35838a
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| !balloonBundleId && | ||
| !associatedMessageGuid | ||
| ) { | ||
| return `bluebubbles:${account.accountId}:dm:${chatKey}:${msg.senderId}`; |
There was a problem hiding this comment.
Align DM coalescing with inbound dedupe identity
When coalesceSameSenderDms is enabled, this key path merges multiple distinct DM events into one debounce bucket, but downstream dedupe still finalizes only a single GUID from message.messageId (processMessage -> resolveBlueBubblesInboundDedupeKey). In an A+B merge, only A is committed; if BlueBubbles replays messages after restart/reconnect, B is treated as new and processed again as an extra turn, so opt-in coalescing is no longer idempotent. Fresh evidence: this change also adds bounded multi-entry merging (MAX_COALESCED_ENTRIES) while still emitting one messageId, which guarantees the identity mismatch whenever coalescing occurs.
Useful? React with 👍 / 👎.
…wlist Review feedback on #69258: 1. Aisle Medium (CWE-400): "Unbounded DM message coalescing can create oversized payloads." With coalesceSameSenderDms enabled, a sender who rapid-fires many small DMs inside the debounce window would see all of them concatenated into one merged turn. Add explicit bounds in `combineDebounceEntries`: - Cap source entries at 10; beyond that, keep the first (original command/context) and the last (most recent payload) rather than silently dropping the tail. - Cap combined text at 4000 chars with an explicit `…[truncated]` marker so downstream LLM prompt size is bounded. - Cap merged attachments at 20 so media fan-out stays proportional to what a single message would carry. 2. CI `check-additional-boundaries` / `lint:tmp:no-raw-channel-fetch`: the drive-by signature change in `types.ts` shifted the raw-fetch callsite from line 189 to line 204. Update the allowlist. Test: new `bounds the coalesced output when many messages merge into one turn` exercises a 25-message flood and asserts the output text is truncated and the attachment list is capped. All 74 monitor tests and 10 inbound-dedupe tests pass.
…dedupe P1 review feedback (Codex, Aisle, independent reviewers) on #69258: when coalesceSameSenderDms merges N webhook events into one agent turn, combineDebounceEntries picks a single representative messageId for the merged NormalizedWebhookMessage. processMessage then claims and finalizes only that one key via the inbound dedupe store. The other source messageIds are never marked as seen, so a later BlueBubbles MessagePoller replay (after a BB Server restart or the ~1-week lookback window) re-processes them as fresh agent turns — opt-in coalescing silently loses idempotency. Fix: - Add NormalizedWebhookMessage.coalescedMessageIds so combineDebounceEntries can pass every unique source messageId through to the processing layer. Populated from the unbounded entries list — IDs whose text/attachments the truncation caps dropped still reach dedupe. - Add commitBlueBubblesCoalescedMessageIds in inbound-dedupe.ts that skips claim() and commits each id directly (best-effort, per-id disk errors go to onDiskError without aborting the batch). - In processMessage, after finalize() of the primary succeeds, commit every other coalesced messageId so MessagePoller replay of any individual source event is recognized as a duplicate. - Tests: - inbound-dedupe.test.ts asserts replay of any coalesced source id is deduped, account scoping, and empty/overlong guid sanitation. - monitor.test.ts asserts coalescedMessageIds is populated end-to-end, including the 25-message flood test where text/attachments were capped. Test: pnpm test extensions/bluebubbles — 501 tests pass (+4 new). Test: pnpm tsgo:extensions:test clean.
8dcf358 to
b40e1ea
Compare
|
Addressed review feedback and CI failures in Review feedback — dedupe alignment with DM coalescing (Codex P1 ×2 on Reviewers were right — my earlier "inbound-dedupe still sees every messageId" claim on
Tests: 3 new cases in CI failures: the previously-red lanes ( Local verification:
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b40e1eacf7
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| account.config.coalesceSameSenderDms && | ||
| !msg.isGroup && | ||
| !balloonBundleId && | ||
| !associatedMessageGuid | ||
| ) { |
There was a problem hiding this comment.
Exclude attachment updates from DM coalescing buckets
When coalesceSameSenderDms is enabled, this condition coalesces all DM events (except balloon/associated cases) into dm:<chat>:<sender> without checking eventType. updated-message attachment updates are still admitted to message processing, so a delayed update for an older message can be merged with a newer new-message from the same sender if they land in one debounce window, mixing stale attachments/context into the wrong turn. This should be limited to the intended split-send case (for example, new-message events) to avoid cross-message contamination.
Useful? React with 👍 / 👎.
| const secondaryIds = (message.coalescedMessageIds ?? []).filter((id) => id !== dedupeKey); | ||
| if (secondaryIds.length > 0) { | ||
| try { | ||
| await commitBlueBubblesCoalescedMessageIds({ | ||
| messageIds: secondaryIds, |
There was a problem hiding this comment.
Reserve coalesced secondary IDs before processing begins
Secondary coalesced IDs are only committed after processMessageAfterDedupe completes, so they remain claimable during the full processing window. If BlueBubbles replays one of those secondary GUIDs while the primary turn is still running, it can be claimed and processed as a duplicate concurrent turn. Fresh evidence in this revision is that the new coalesced-ID handling writes those IDs only in this post-finalize block, leaving that replay race unprotected.
Useful? React with 👍 / 👎.
…-send traffic Live smoke testing against real BlueBubbles webhooks on #69258 showed the flag was a no-op for its primary motivating case. Three independent gates were swallowing traffic; all three are fixed here. 1. shouldDebounce bypassed for control commands. DM control-command text (e.g. 'Dump' as a registered skill alias) made hasControlCommand() return true, so the debouncer's shouldDebounce callback returned false and flushed the text event immediately — before the window ever armed for the follow-up URL-balloon webhook. Gate the instant-flush exit on the same conditions as the buildKey coalesce branch so group chats, balloon events linked via associatedMessageGuid, and disabled accounts keep instant dispatch; only DMs with the flag on and no associated parent now wait for the window. 2. buildKey excluded orphan URL-balloons. The new DM coalesce branch had a !balloonBundleId guard. Apple's pasted- URL webhook carries balloonBundleId='com.apple.messages.URLBalloonProvider' with no associatedMessageGuid linking it back to the text event — so it fell through to msg:<own id> and never shared a bucket with 'Dump'. The legacy text+balloon pairing case (both balloonBundleId AND associatedMessageGuid set) is already handled by the earlier branch, so the belt-and-suspenders check here was doing no safety work — only blocking the feature. Removed. 3. Default debounce window too narrow. DEFAULT_INBOUND_DEBOUNCE_MS=500 ms is tuned for BlueBubbles's own text+ balloon follow-ups (~100-300 ms). Live traces show Apple split-send cadence is 770-1771 ms — 500 ms flushes the first webhook before the second arrives. Introduce DEFAULT_COALESCE_INBOUND_DEBOUNCE_MS=2500 ms and use it when coalesceSameSenderDms is on AND no explicit messages.inbound.byChannel.bluebubbles override is set. Explicit user config still wins. Legacy (flag-off) users keep 500 ms. Also resolve the debounce value directly from config.messages.inbound instead of delegating to core.channel.debounce.resolveInboundDebounceMs, which the test runtime mock returned 0 for (tests previously relied on the unsetting path returning the default). Docs: - docs/channels/bluebubbles.md: new 'Coalescing split-send DMs' section with scenarios table, enablement, trade-offs, and a troubleshooting checklist covering the three layers above plus session-log-vs-BB-log timestamps, memory pressure, and reply-quote sends (which use replyToBody, not a second webhook). - docs/concepts/messages.md: cross-reference the exception for channels that opt into same-sender DM coalescing. - CHANGELOG.md: expanded entry to reflect all three fixes + default window change + docs link. Tests (506 pass, +5 new): - debounces DM control commands when coalesceSameSenderDms is on so a split-send URL can join the bucket - coalesces an orphan URL-balloon with a preceding DM control command (Apple split-send) — mirrors the live BB server log trace - widens the default debounce window when coalesceSameSenderDms is enabled without explicit config - keeps the legacy 500 ms default window when coalesceSameSenderDms is off - keeps control commands instant for group chats even when coalesceSameSenderDms is enabled Smoke-tested end-to-end against live BlueBubbles traffic: 'Dump <URL>' as one iMessage composition now arrives as a single merged agent turn with the URL + OG-preview attachments, and the dump skill runs the real payload.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4c5affa283
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| heartbeat: ChannelHeartbeatVisibilitySchema, | ||
| healthMonitor: ChannelHealthMonitorSchema, | ||
| responsePrefix: z.string().optional(), | ||
| coalesceSameSenderDms: z.boolean().optional(), |
There was a problem hiding this comment.
Wire coalesceSameSenderDms into BlueBubbles channel schema
Adding coalesceSameSenderDms only here leaves the runtime channel schema contract out of sync: BlueBubbles config validation is driven by bundled channel metadata (collectChannelSchemaMetadata in src/config/validation.ts) sourced from the plugin schema (extensions/bluebubbles/src/config-schema.ts / src/config/bundled-channel-config-metadata.generated.ts), which still omits this field and uses additionalProperties: false. As a result, setting channels.bluebubbles.coalesceSameSenderDms in real configs is treated as an unknown key (or dropped), so the new opt-in behavior cannot be reliably enabled.
Useful? React with 👍 / 👎.
…runtime actually reads it Live smoke testing exposed the final missing piece. Earlier commits on #69258 added coalesceSameSenderDms to the core zod schema (src/config/ zod-schema.providers-core.ts) and to the runtime type (extensions/ bluebubbles/src/types.ts), so 'openclaw config set channels.bluebubbles .coalesceSameSenderDms true' wrote the flag to openclaw.json and the CLI validator accepted it. But the plugin-local account schema at extensions/bluebubbles/src/config-schema.ts never saw the new key, so it was silently stripped during config resolution by the plugin runtime. Downstream, 'account.config.coalesceSameSenderDms' was undefined at debouncer-registry creation time, and both the buildKey coalesce branch and the shouldDebounce override fell through to the per-message legacy path. Result: even with the flag written to config, the registry was being built with coalesceSameSenderDms=false and the feature was a no-op at runtime. Root cause confirmed via instrumentation: the plugin trace showed 'coalesceRaw=undefined' and the list of resolved account.config keys did not contain coalesceSameSenderDms. Adding the key to the plugin-local schema fixes both the strip and the generated channel metadata consumer. Fix: - extensions/bluebubbles/src/config-schema.ts: add coalesceSameSenderDms: z.boolean().optional() to bluebubblesAccountSchema so it survives plugin-side account resolution. - src/config/bundled-channel-config-metadata.generated.ts: regenerate via 'pnpm config:channels:gen' to pick up the new field. The gateway preflight config validator reads this JSON schema (with additionalProperties: false) and was rejecting any config that set the flag until this generated output was refreshed. - docs/.generated/config-baseline.sha256: refreshed via 'pnpm config:docs:gen' to track the new metadata shape so the baseline drift check (check:base-config-schema) stays green. Live smoke test: BB dispatched 'Dump' at 22:25:06.625 and the URL webhook at 22:25:07.810 (1185 ms gap, inside the 2500 ms window); the dump skill ran on the merged payload in a single agent turn. Tests: pnpm test extensions/bluebubbles - 506 pass (no new cases needed, the existing 'coalesces same-sender DM messages' / 'orphan URL-balloon' etc. tests already covered the buildKey + shouldDebounce logic, and the config-schema gap was only visible at runtime through the plugin loader). Added a follow-up item for the test suite to lock in that the plugin-local schema preserves every public BB config field, so a future field addition cannot re-open the same gap.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6879308447
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // a later MessagePoller replay of any individual source event is | ||
| // recognized as a duplicate. The primary is already finalized above; | ||
| // commit the rest here (best-effort, per-id). | ||
| const secondaryIds = (message.coalescedMessageIds ?? []).filter((id) => id !== dedupeKey); |
There was a problem hiding this comment.
Skip secondary dedupe commits when primary finalize fails
This block commits coalescedMessageIds even when claim.finalize() has just failed, which can leave dedupe in a partial state: the primary key is not persisted, but some secondary keys are. On a later MessagePoller replay, the primary event can reprocess while secondary events are dropped as duplicates, so a previously merged turn may replay as an incomplete message. Secondary commits should run only after a successful primary finalize (or the whole batch should be retried together).
Useful? React with 👍 / 👎.
| await impl.commit(normalized, { | ||
| namespace: params.accountId, | ||
| onDiskError: params.onDiskError, | ||
| }); |
There was a problem hiding this comment.
Continue committing coalesced IDs after per-ID write errors
commitBlueBubblesCoalescedMessageIds stops at the first thrown impl.commit, so one failing write prevents all later IDs in the same merged batch from being recorded. In restart/replay scenarios this creates inconsistent dedupe coverage where early IDs dedupe but later IDs reprocess as fresh turns. Since this path is intended as best-effort secondary persistence, each commit should be isolated so one disk error does not abort the remaining IDs.
Useful? React with 👍 / 👎.
… helper Revert a short-lived local workaround in resolveBlueBubblesDebounceMs that read messages.inbound.byChannel.bluebubbles directly and skipped core.channel.debounce.resolveInboundDebounceMs. The workaround existed only because the test-runtime mock returned 0 unconditionally, which broke flag-on tests that pinned an explicit short window via config. Bypassing the shared helper in production just to satisfy a mock trades delegation correctness for test convenience: every other channel (Discord, iMessage, msteams, Signal, Slack, Telegram) delegates, and any future scaling/clamping/per-channel logic added to the helper would silently skip BlueBubbles. Fix the mock instead: - test/helpers/plugins/plugin-runtime-mock.ts: replace the `() => 0` stub with a config-aware implementation that reads overrideMs -> byChannel[channel] -> debounceMs -> 0, mirroring the production resolveInboundDebounceMs contract. Every channel that delegates now sees the same per-channel/global/default precedence in tests as it would at runtime. With the mock fixed, restore the clean resolve function shape in monitor-debounce.ts: if messages.inbound has no explicit debounce configured, return the flag-aware default (2500 ms when coalesceSameSenderDms is on, 500 ms otherwise); otherwise delegate to the shared helper. Verified: - pnpm test extensions/bluebubbles: 506 passed (unchanged). - pnpm test extensions (all bundled channel plugins): every shard passed tests; one shard reported a host JS-heap OOM at the worker level (8 GB Mac memory pressure), unrelated. - pnpm test:changed with FAST_COMMIT=1 pre-hook: every populated shard passed; same OOM artifact in one shard. ~3,400 tests green across the touched-helper fan-out. No functional change for production. Pays down the design debt flagged during PR review of the coalesceSameSenderDms feature (#69258) and keeps BB aligned with the shared inbound-debounce seam.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0cd057fa90
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (!id || seenIds.has(id)) { | ||
| continue; | ||
| } | ||
| seenIds.add(id); | ||
| coalescedMessageIds.push(id); |
There was a problem hiding this comment.
Preserve event-type suffixes in coalesced dedupe identities
This batch identity list stores only raw messageIds, but downstream dedupe keys are event-sensitive (updated-message becomes <id>:updated in resolveBlueBubblesInboundDedupeKey). With coalesceSameSenderDms enabled, if a coalesced batch includes an updated-message, committing only the raw ID leaves that updated replay claimable as a fresh turn after restart/reconnect; and when an updated event is primary, the later raw-ID commit can also collapse new-vs-updated separation. The coalesced identity payload should carry canonical dedupe keys (including event type) rather than raw IDs.
Useful? React with 👍 / 👎.
5ce79a9 to
5aae13a
Compare
Two distinct user sends to a DM — a command followed by a pasted URL that iMessage renders as a standalone URL-balloon message — are delivered by Apple/BlueBubbles as two separate webhooks ~0.8-2.0 s apart. Without coalescing, the agent replies to the command alone before the URL arrives (the dump skill sees an empty payload), and the URL lands as a second queued turn. Opt-in behind channels.bluebubbles.coalesceSameSenderDms (default false). When set, DM messages with no associatedMessageGuid hash to dm:<chat>:<sender> so the debounce window merges text + URL-balloon into one merged agent turn. Group chats and legacy text+balloon pairs linked via associatedMessageGuid keep per-message keys. The default inbound debounce window widens from 500 ms to 2500 ms when the flag is on and no explicit messages.inbound.byChannel.bluebubbles is set, covering Apple's observed split-send cadence. Merged output is bounded (<=4000 chars text with an explicit truncation marker, <=20 attachments, first-plus-latest sampling beyond 10 source entries). Every source messageId folded into the merged view is committed to the inbound dedupe store after processing, so a later BlueBubbles MessagePoller replay of any individual source event is recognized as a duplicate. Tests (506 BlueBubbles tests passing, multiple new cases covering the on/off matrix, control-command override, orphan URL-balloon, group-chat preservation, default-window widening, replay-of-any-source-id dedupe, and the 25-message flood bound). Smoke tested end-to-end against live BlueBubbles webhook traffic: Dump+URL composed as one iMessage -> BB dispatches two webhooks ~1 s apart -> debouncer coalesces both under dm:<chat>:<sender> -> dump skill runs on merged payload in one turn. Also updates docs/channels/bluebubbles.md with a scenarios table, enablement guide, trade-offs, and three-layer troubleshooting checklist; docs/concepts/messages.md cross-references the control-command debounce exception.
5aae13a to
8f1bd3c
Compare
|
Merged via squash.
Thanks @omarshahine! |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8f1bd3cf53
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (account.config.coalesceSameSenderDms && !msg.isGroup && !associatedMessageGuid) { | ||
| return `bluebubbles:${account.accountId}:dm:${chatKey}:${msg.senderId}`; |
There was a problem hiding this comment.
Exclude attachment-update events from DM coalescing
This keying branch coalesces every DM event without an associatedMessageGuid, including updated-message attachment updates that can arrive later for an older message. When coalesceSameSenderDms is enabled, a delayed update and a newer new-message from the same sender can land in one debounce bucket, causing stale attachments/text to be merged into the wrong turn. Restrict this coalescing path to the intended event types (for example new-message) to avoid cross-message contamination.
Useful? React with 👍 / 👎.
| const id = entry.message.messageId?.trim(); | ||
| if (!id || seenIds.has(id)) { |
There was a problem hiding this comment.
Preserve canonical dedupe keys for coalesced IDs
The coalesced identity list records only raw messageIds, but downstream dedupe keys are event-sensitive (updated-message uses <id>:updated). If a merged batch includes updated events, committing only raw IDs leaves updated replays claimable as fresh turns (or collapses new-message vs updated-message separation). Store canonical dedupe keys (including event-type suffixes) for each coalesced source event instead of bare message IDs.
Useful? React with 👍 / 👎.
| const secondaryIds = (message.coalescedMessageIds ?? []).filter((id) => id !== dedupeKey); | ||
| if (secondaryIds.length > 0) { |
There was a problem hiding this comment.
Reserve coalesced secondary IDs before processing
Secondary coalesced IDs are only persisted after processMessageAfterDedupe completes, so they remain claimable throughout the primary processing window. If MessagePoller replays one of those IDs during that window, claimBlueBubblesInboundMessage can claim and process it as a concurrent duplicate turn. To keep coalesced processing idempotent under replay/reconnect, secondary IDs need to be claimed/reserved before downstream processing begins.
Useful? React with 👍 / 👎.
* 'main' of https://github.com/openclaw/openclaw: (653 commits) docs(changelog): deduplicate openclaw#67800 entries in Unreleased (openclaw#69670) fix(agents): honor explicit long Anthropic cache TTL on custom hosts (openclaw#67800) fix: fix Telegram media file delivery (openclaw#69641) fix(media): preserve outbound attachment filenames fix(media): parse lowercase media directives fix(bluebubbles): add opt-in coalesceSameSenderDms for split-send DMs (openclaw#69258) fix: centralize provider thinking profiles docs: prepare 2026.4.20 changelog fix: stage ACP and Codex runtime deps fix(gateway): drop stale service env on reinstall test: add bundled channel dependency Docker smoke test: relax detached task recovery timing assertion fix: ignore placeholder shells in runtime detection (openclaw#69308) shell: fall back to sh when SHELL is /usr/bin/false or nologin fix(browser): clarify DevToolsActivePort attach failures fix: sanitize mcp transport warning fields fix: launch Windows startup gateway directly fix(openai-codex): normalize legacy copilot transport fix: narrow MCP stdio env safety filter (openclaw#69540) fix(mcp): block dangerous stdio env overrides ...
…openclaw#69258) Merged via squash. Prepared head SHA: 8f1bd3c Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com> Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com> Reviewed-by: @omarshahine
…openclaw#69258) Merged via squash. Prepared head SHA: 8f1bd3c Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com> Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com> Reviewed-by: @omarshahine
…openclaw#69258) Merged via squash. Prepared head SHA: 8f1bd3c Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com> Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com> Reviewed-by: @omarshahine
…openclaw#69258) Merged via squash. Prepared head SHA: 8f1bd3c Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com> Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com> Reviewed-by: @omarshahine
…openclaw#69258) Merged via squash. Prepared head SHA: 8f1bd3c Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com> Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com> Reviewed-by: @omarshahine
Problem
Two distinct user sends to a DM — a command followed by a pasted URL that iMessage renders as a standalone URL-balloon message — have distinct
messageIds and noassociatedMessageGuidcross-reference. The debouncer'sbuildKeyinextensions/bluebubbles/src/monitor-debounce.ts:141-165falls through tomsg:<messageId>in that case, so the two events hash to different buckets and the debounce window never merges them. The agent replies to the bare command before the URL arrives as a second turn.Repro (verified against live gateway):
Omar sent
Dumpfollowed ~1.3 s later by a pasted URL. BlueBubbles server log showed two distinct webhook dispatches:The second event carried the URL in its
textfield plus four.pluginPayloadAttachmentOG-image previews. Inside OpenClaw they landed with distinct debounce keys; the agent processed"Dump"alone, replied asking for a URL, and then received the URL as a queued second turn — too late to act on it. Bumpingmessages.inbound.byChannel.bluebubblesto 2500 confirmed the window is honored (6.3 s BB → session gap vs ~4 s on default-500) but did nothing for the coalescing problem because the two events never shared a key.Fix
Add an opt-in
channels.bluebubbles.coalesceSameSenderDmsflag. When set,buildKeyhashes DM messages (isGroup === false) without a balloon orassociatedMessageGuidtodm:<chat>:<sender>instead of per-message, so the existing debounce window can merge consecutive same-sender sends into one agent turn.associatedMessageGuidas before.Drive-by
_setFetchGuardForTestinginextensions/bluebubbles/src/types.tshad a signaturetypeof fetchWithSsrFGuard | nullthat the type-aware pre-commit lint pass rejected asany | nullredundant — the plugin-sdk DTS resolvestypeof fetchWithSsrFGuardasanyat this boundary. Rewrote the parameter to an explicit(...args: Parameters<...>) => ReturnType<...>form so the union is no longer redundant. Callers still passnullto restore the default; behavior is unchanged. This unblocks editing the file at all — without the fix, any touch totypes.tsfailed the hook on a pre-existing latent warning.Test plan
npx vitest run extensions/bluebubbles/src/monitor.test.ts— all 73 tests pass, including three new cases:npx vitest run extensions/bluebubbles/src/inbound-dedupe.test.ts— 10 tests pass (dedupe key resolution is unchanged)npx vitest run extensions/bluebubbles/src/send.test.ts extensions/bluebubbles/src/monitor.webhook-auth.test.ts— 85 tests pass (verifies_setFetchGuardForTestingsignature change doesn't break callers)pnpm checkclean🤖 Generated with Claude Code