Skip to content

fix(bluebubbles): add opt-in coalesceSameSenderDms for split-send DMs#69258

Merged
omarshahine merged 1 commit intomainfrom
fix/bluebubbles-coalesce-same-sender-dms
Apr 21, 2026
Merged

fix(bluebubbles): add opt-in coalesceSameSenderDms for split-send DMs#69258
omarshahine merged 1 commit intomainfrom
fix/bluebubbles-coalesce-same-sender-dms

Conversation

@omarshahine
Copy link
Copy Markdown
Contributor

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 no associatedMessageGuid cross-reference. The debouncer's buildKey in extensions/bluebubbles/src/monitor-debounce.ts:141-165 falls through to msg:<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 Dump followed ~1.3 s later by a pasted URL. BlueBubbles server log showed two distinct webhook dispatches:

00:12:40.214  New Message from +..., "Dump"
00:12:41.480  New Message from +..., "https://..."; Attachments: 4

The second event carried the URL in its text field plus four .pluginPayloadAttachment OG-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. Bumping messages.inbound.byChannel.bluebubbles to 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.coalesceSameSenderDms flag. When set, buildKey hashes DM messages (isGroup === false) without a balloon or associatedMessageGuid to dm:<chat>:<sender> instead of per-message, so the existing debounce window can merge consecutive same-sender sends into one agent turn.

  • Group chats continue to key per-message — coalescing multi-user conversation would corrupt turn structure.
  • BlueBubbles's own text+balloon follow-ups (e.g. the URL preview emitted for a single iMessage row) continue to coalesce via associatedMessageGuid as before.
  • Default is off to preserve existing behavior.

Drive-by

_setFetchGuardForTesting in extensions/bluebubbles/src/types.ts had a signature typeof fetchWithSsrFGuard | null that the type-aware pre-commit lint pass rejected as any | null redundant — the plugin-sdk DTS resolves typeof fetchWithSsrFGuard as any at this boundary. Rewrote the parameter to an explicit (...args: Parameters<...>) => ReturnType<...> form so the union is no longer redundant. Callers still pass null to restore the default; behavior is unchanged. This unblocks editing the file at all — without the fix, any touch to types.ts failed 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:
    • coalesces same-sender DM messages when flag is on
    • does not coalesce when flag is off (default)
    • does not coalesce group-chat messages even with flag on
  • 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 _setFetchGuardForTesting signature change doesn't break callers)
  • pnpm check clean

🤖 Generated with Claude Code

@aisle-research-bot
Copy link
Copy Markdown

aisle-research-bot Bot commented Apr 20, 2026

🔒 Aisle Security Analysis

We found 1 potential security issue(s) in this PR:

# Severity Title
1 🟡 Medium Unbounded coalescedMessageIds enables debounce-window DoS via O(N) memory and disk commits
1. 🟡 Unbounded coalescedMessageIds enables debounce-window DoS via O(N) memory and disk commits
Property Value
Severity Medium
CWE CWE-400
Location extensions/bluebubbles/src/monitor-debounce.ts:139-154

Description

The BlueBubbles DM coalescing change tracks every source messageId received during a debounce window and then persists each id individually.

With coalesceSameSenderDms enabled, the debounce key becomes dm:<chatKey>:<senderId>, so a single sender can cause many webhook events to land in the same buffer within the (now up to 2500ms) window. While the rendered merged output is capped (text/attachments/entries), combineDebounceEntries() still iterates the full unbounded entries list and builds an unbounded coalescedMessageIds array.

Impacts:

  • Memory/time amplification: coalescedMessageIds grows with the number of events in-window; it is attached to the merged message object and propagated.
  • Persistence amplification: later code commits each secondary id sequentially (see commitBlueBubblesCoalescedMessageIds()), producing O(N) awaited commits (likely disk writes) for a single flushed agent turn.

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);
}

Recommendation

Bound and/or batch the tracking/commit of secondary IDs.

Options (can be combined):

  1. Cap per-bucket tracked IDs (drop oldest IDs once cap reached, or store only a hash/range):
const MAX_COALESCED_IDS = 200; // example
...
if (coalescedMessageIds.length < MAX_COALESCED_IDS) {
  coalescedMessageIds.push(id);
}
  1. Cap buffered items per debounce key in createInboundDebouncer usage (or add support in the debouncer itself), flushing early when a cap is reached.

  2. Batch commit secondary IDs in the dedupe layer (single write/transaction), or provide a commitMany() API to reduce disk churn.

  3. Add rate limiting/backpressure for webhook intake per account/chat to prevent a single sender from monopolizing work.


Analyzed PR: #69258 at commit 8f1bd3c

Last updated on: 2026-04-21T08:43:08Z

@openclaw-barnacle openclaw-barnacle Bot added channel: bluebubbles Channel integration: bluebubbles size: M maintainer Maintainer-authored PR labels Apr 20, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 20, 2026

Greptile Summary

This PR adds an opt-in coalesceSameSenderDms flag to the BlueBubbles channel config. When enabled, consecutive DM messages from the same sender (without a balloonBundleId or associatedMessageGuid) hash to a shared dm:<chat>:<sender> debounce key instead of per-message keys, so two distinct sends that arrive inside the debounce window (e.g., a bare command followed by a pasted URL rendered as a separate iMessage balloon) are merged into a single agent turn. Group chats, balloon follow-ups, and the default off behavior are all preserved.

Confidence Score: 5/5

This 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

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: 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".

Comment on lines +170 to +174
!msg.isGroup &&
!balloonBundleId &&
!associatedMessageGuid
) {
return `bluebubbles:${account.accountId}:dm:${chatKey}:${msg.senderId}`;
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 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 (processMessageresolveBlueBubblesInboundDedupeKey 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 👍 / 👎.

omarshahine added a commit that referenced this pull request Apr 20, 2026
…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.
@omarshahine
Copy link
Copy Markdown
Contributor Author

Addressed review feedback in 8dcf358:

Aisle security (Medium, CWE-400): Added explicit bounds in combineDebounceEntries:

  • Source entries capped at MAX_COALESCED_ENTRIES = 10; beyond that, first + last are kept (preserves original command/context and latest payload).
  • Combined text capped at MAX_COALESCED_TEXT_CHARS = 4000 with explicit …[truncated] marker.
  • Merged attachments capped at MAX_COALESCED_ATTACHMENTS = 20.

A new test (bounds the coalesced output when many messages merge into one turn) feeds a 25-message flood and asserts the output is truncated and the attachment list is capped. Inbound-dedupe still sees every messageId — this only caps what reaches the downstream prompt.

CI check-additional-boundaries: the drive-by signature change in types.ts shifted the raw-fetch callsite from line 189 → 204. Updated the allowlist in scripts/check-no-raw-channel-fetch.mjs. Boundary check now passes locally.

Unrelated CI flake: the two ui/src/ui/connect-error.node.test.ts failures are pre-existing on main (verified locally — they pass in this worktree) and untouched by this PR. Not addressed here.

@openclaw-barnacle openclaw-barnacle Bot added the scripts Repository scripts label Apr 20, 2026
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: 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}`;
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 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 👍 / 👎.

omarshahine added a commit that referenced this pull request Apr 21, 2026
…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.
omarshahine added a commit that referenced this pull request Apr 21, 2026
…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.
@omarshahine omarshahine force-pushed the fix/bluebubbles-coalesce-same-sender-dms branch from 8dcf358 to b40e1ea Compare April 21, 2026 02:14
@omarshahine
Copy link
Copy Markdown
Contributor Author

Addressed review feedback and CI failures in b40e1ea:

Review feedback — dedupe alignment with DM coalescing (Codex P1 ×2 on 8dcf358, Aisle Medium CWE-841):

Reviewers were right — my earlier "inbound-dedupe still sees every messageId" claim on 8dcf358 was wrong. combineDebounceEntries collapses N source messageIds into a single representative on the merged NormalizedWebhookMessage, and processMessage only claim/finalized that one key. A MessagePoller replay of any other source event would re-process as a fresh agent turn. Fix now in the commit:

  • NormalizedWebhookMessage.coalescedMessageIds: string[]combineDebounceEntries populates it with every unique source messageId, walking the unbounded entries list so IDs whose text/attachments the 10-entry / 4000-char / 20-attachment caps dropped still reach dedupe.
  • commitBlueBubblesCoalescedMessageIds in extensions/bluebubbles/src/inbound-dedupe.ts commits each id directly (best-effort; per-id disk errors route to onDiskError without aborting the batch).
  • processMessage finalizes the primary as before, then commits every other coalesced id so a later MessagePoller replay of any individual source event is recognized as a duplicate.

Tests: 3 new cases in inbound-dedupe.test.ts cover replay-of-any-source-id → duplicate, account scoping, and sanitization of empty/overlong guids. The existing monitor.test.ts cases now assert coalescedMessageIds is populated end-to-end, including the 25-message flood test where the text/attachment caps kicked in.

CI failures: the previously-red lanes (check-test-types feishu reply-once.lifecycle types, checks-node-extensions-shard-5 + extension-fast(telegram) directory-contract sort order, checks-windows-node-test corepack pnpm install) all pass on current origin/main. The branch was 563 commits behind; rebased on latest origin/main, which picks up those upstream fixes. Force-pushed.

Local verification:

  • pnpm test extensions/bluebubbles — 501 tests, all green (+4 new).
  • pnpm tsgo:extensions:test and pnpm tsgo:extensions — clean.
  • node scripts/check-no-raw-channel-fetch.mjs — passes.

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: 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".

Comment on lines +217 to +221
account.config.coalesceSameSenderDms &&
!msg.isGroup &&
!balloonBundleId &&
!associatedMessageGuid
) {
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 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 👍 / 👎.

Comment on lines +675 to +679
const secondaryIds = (message.coalescedMessageIds ?? []).filter((id) => id !== dedupeKey);
if (secondaryIds.length > 0) {
try {
await commitBlueBubblesCoalescedMessageIds({
messageIds: secondaryIds,
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 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 👍 / 👎.

omarshahine added a commit that referenced this pull request Apr 21, 2026
…-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.
@openclaw-barnacle openclaw-barnacle Bot added docs Improvements or additions to documentation size: L and removed size: M labels Apr 21, 2026
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: 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(),
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 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 👍 / 👎.

omarshahine added a commit that referenced this pull request Apr 21, 2026
…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.
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: 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);
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 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 👍 / 👎.

Comment on lines +235 to +238
await impl.commit(normalized, {
namespace: params.accountId,
onDiskError: params.onDiskError,
});
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 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 👍 / 👎.

omarshahine added a commit that referenced this pull request Apr 21, 2026
… 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.
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: 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".

Comment on lines +149 to +153
if (!id || seenIds.has(id)) {
continue;
}
seenIds.add(id);
coalescedMessageIds.push(id);
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 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 👍 / 👎.

@omarshahine omarshahine force-pushed the fix/bluebubbles-coalesce-same-sender-dms branch from 5ce79a9 to 5aae13a Compare April 21, 2026 08:33
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.
@omarshahine omarshahine force-pushed the fix/bluebubbles-coalesce-same-sender-dms branch from 5aae13a to 8f1bd3c Compare April 21, 2026 08:38
@omarshahine omarshahine merged commit 14506ae into main Apr 21, 2026
92 checks passed
@omarshahine omarshahine deleted the fix/bluebubbles-coalesce-same-sender-dms branch April 21, 2026 08:43
@omarshahine
Copy link
Copy Markdown
Contributor Author

Merged via squash.

Thanks @omarshahine!

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: 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".

Comment on lines +251 to +252
if (account.config.coalesceSameSenderDms && !msg.isGroup && !associatedMessageGuid) {
return `bluebubbles:${account.accountId}:dm:${chatKey}:${msg.senderId}`;
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 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 👍 / 👎.

Comment on lines +148 to +149
const id = entry.message.messageId?.trim();
if (!id || seenIds.has(id)) {
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 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 👍 / 👎.

Comment on lines +675 to +676
const secondaryIds = (message.coalescedMessageIds ?? []).filter((id) => id !== dedupeKey);
if (secondaryIds.length > 0) {
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 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 👍 / 👎.

zhongmairen pushed a commit to agencyos-cn/openclaw-bad that referenced this pull request Apr 21, 2026
* '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
  ...
gdibble pushed a commit to gdibble/openclaw that referenced this pull request Apr 21, 2026
…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
medikoo pushed a commit to medikoo/openclaw that referenced this pull request Apr 24, 2026
…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
ogt-redknie pushed a commit to ogt-redknie/OPENX that referenced this pull request May 2, 2026
…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
zhonghe0615 pushed a commit to zhonghe0615/openclaw that referenced this pull request May 7, 2026
…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
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 9, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: bluebubbles Channel integration: bluebubbles docs Improvements or additions to documentation maintainer Maintainer-authored PR scripts Repository scripts size: L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant