Skip to content

fix(bluebubbles): dedupe inbound webhooks across restarts (#19176, #12053)#66230

Closed
omarshahine wants to merge 1 commit intomainfrom
lobster/bb-inbound-dedupe
Closed

fix(bluebubbles): dedupe inbound webhooks across restarts (#19176, #12053)#66230
omarshahine wants to merge 1 commit intomainfrom
lobster/bb-inbound-dedupe

Conversation

@omarshahine
Copy link
Copy Markdown
Contributor

Summary

BlueBubbles `MessagePoller` keeps a ~1-week lookback and re-fires `new-message` webhooks after BB Server restart or reconnection. With no sequence number or ack in the BB webhook protocol, the gateway previously had no way to recognize replays and would happily re-reply to messages it had already handled before the restart — producing duplicated outbound messages, and confusing replies to stale inbound that the user had already moved on from (see #19176, #12053).

This PR adds a persistent, file-backed inbound dedupe keyed by message GUID, modeled after the same `createPersistentDedupe` pattern used by the Feishu plugin.

  • TTL = 7 days (matches BB's lookback window, so any replay is guaranteed to land on a remembered GUID).
  • On-disk state at `~/.openclaw/bluebubbles/inbound-dedupe/.json` — survives gateway restarts, which is the crucial property the previous in-memory dedupe layers can't provide.
  • Applied at the top of `processMessage`, before any downstream work, so stale replays never reach pairing, dispatch, or the reply cache.

Why this approach

Other channels with monotonic sequence IDs (Telegram's `update_id`, Matrix's sync token, Discord's gateway sequence) can dedupe natively via protocol. BlueBubbles does not expose anything like that, so an identity-based persistent dedupe at the message layer is the closest equivalent that fits how BB actually delivers webhooks.

Interaction with edit events (`updated-message`)

PR #52277 raised a related concern: if dedupe keys are GUID-only, a legitimate `updated-message` event would share a GUID with its original `new-message` and get dropped as a duplicate.

In the current codebase this cannot happen: `monitor.ts` routes `updated-message` payloads differently — without a reaction they are dropped at the webhook layer ("ignored without reaction"), and with a reaction they flow through `processReaction`, not `processMessage`. Our dedupe sits inside `processMessage`, so only `new-message` events are gated. Edits can't collide today.

If the separate work in #52277 lands and begins routing text-edit bodies into `processMessage`, the dedupe key will need to expand to include event type and edit metadata (e.g. `guid + eventType + (dateEdited||"")`) so an edit is treated as a distinct key. That's a straightforward forward-compatible change when it's needed.

Validation

  • New scoped test: `extensions/bluebubbles/src/inbound-dedupe.test.ts` (3 cases covering claim/reject, per-account scoping, missing GUID).
  • Full BlueBubbles suite passes (387/387).
  • End-to-end tested on a live gateway (macOS 26.3, BB Server 1.9.x) with a synthetic replay script: first webhook gets processed + recorded, second webhook with the same GUID is dropped before dispatch, dedupe file timestamp unchanged. Sample script lives in `scripts/test-bb-dedupe.sh` for the PR lifecycle; remove before merge if not desired.
  • `pnpm check` green.

Credits

Re-creates and improves on the focused fix from #31159 by @dashhuang — same behavioral goal (drop stale BB webhook replays), implemented as a persistent on-disk dedupe so it actually survives the gateway-restart case that drives the bug, and without the module-global mutable state that made the original patch need test-reset plumbing.

Fixes #19176, #12053.

Test plan

  • New scoped test passes (`pnpm test extensions/bluebubbles/src/inbound-dedupe.test.ts`)
  • Full BlueBubbles suite passes (`pnpm test extensions/bluebubbles/`)
  • `pnpm check` green
  • Live test on macOS 26.3 with BB Server — synthetic replay dropped, dedupe file persisted across gateway restart
  • Maintainer review

@aisle-research-bot
Copy link
Copy Markdown

aisle-research-bot Bot commented Apr 14, 2026

🔒 Aisle Security Analysis

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

# Severity Title
1 🟡 Medium Symlink-following arbitrary file overwrite in Windows atomic write fallback (copyFile)
2 🟡 Medium Potential disk I/O & CPU Denial-of-Service from per-message persistent dedupe commits (50k-entry JSON rewrite)
3 🟡 Medium Insecure temporary state directory selection via NODE_ENV/VITEST can redirect persistent dedupe storage into world-writable tmp
4 🔵 Low Log forging via unsanitized error string in BlueBubbles reply failure logging
5 🔵 Low Sensitive identifier disclosure in verbose logs (inbound dedupe drop/finalize paths)
1. 🟡 Symlink-following arbitrary file overwrite in Windows atomic write fallback (copyFile)
Property Value
Severity Medium
CWE CWE-59
Location src/infra/json-files.ts:20

Description

writeTextAtomic() attempts an atomic replace by rename(), but on Windows it falls back to copyFile() when rename() fails with EPERM/EEXIST. fs.copyFile(src, dest) follows symlinks at dest, so if an attacker can pre-create a symlink at the destination path (e.g., the per-namespace dedupe JSON file used by createClaimableDedupe), the fallback will overwrite the symlink target rather than replacing the symlink itself.

This becomes reachable in this change because extensions/bluebubbles/src/inbound-dedupe.ts enables on-disk persistence and calls writeJsonFileAtomically() (via createClaimableDedupewriteJsonFileAtomicallywriteJsonAtomicwriteTextAtomic).

Impact:

  • Potential arbitrary file overwrite/clobber on Windows if an attacker can place a symlink/hardlink at the target JSON path inside the configured state directory.
  • Could lead to configuration corruption or other security impact depending on what file is targeted and process privileges.

Vulnerable code:

await fs.copyFile(tempPath, filePath);

Recommendation

Harden the Windows fallback to avoid following symlinks/hardlinks at the destination.

Options:

  1. Refuse to write if destination is a symlink (recommended):
import fs from "node:fs/promises";// before copyFile
try {
  const st = await fs.lstat(filePath);
  if (st.isSymbolicLink()) {
    throw new Error(`Refusing to write through symlink: ${filePath}`);
  }
} catch (e: any) {
  if (e?.code !== "ENOENT") throw e;
}

await fs.copyFile(tempPath, filePath, fs.constants.COPYFILE_FICLONE_FORCE);
  1. Prefer an unlink-and-rename strategy on Windows when safe, or write to a temp file in the same directory and use APIs/flags that don't follow reparse points where possible.

Additionally consider validating that the state directory is owned by the current user and not group/world-writable when running in multi-user environments.

2. 🟡 Potential disk I/O & CPU Denial-of-Service from per-message persistent dedupe commits (50k-entry JSON rewrite)
Property Value
Severity Medium
CWE CWE-400
Location extensions/bluebubbles/src/monitor-processing.ts:619-674

Description

The new BlueBubbles inbound dedupe persists every successfully-processed inbound message GUID to a per-account JSON file, with a high cap (FILE_MAX_ENTRIES = 50_000) and a 7-day TTL.

In processMessage, each inbound webhook message that is not a duplicate/inflight will:

  • claim a GUID key derived from webhook fields
  • after processing, finalize() which calls impl.commit(...)
  • the underlying persistent store (createPersistentDedupe) performs a lock-protected read → parse → prune → write of the entire JSON object on every commit

Because the file is rewritten in full, once the dedupe file grows towards 50k keys, each new unique inbound GUID forces O(n) JSON serialization and a full file write. A remote party who can trigger many inbound messages (and thus many distinct GUIDs) can significantly increase disk I/O and CPU usage, degrading the service (persistent-storage DoS).

Vulnerable flow (high level):

  • input: remote inbound webhook messageId/associatedMessageGuid → dedupe key
  • sink: per-message claim.finalize() → disk-backed JSON rewrite

Vulnerable code (new usage/config):

const FILE_MAX_ENTRIES = 50_000;
...
return createClaimableDedupe({
  ttlMs: DEDUP_TTL_MS,
  memoryMaxSize: MEMORY_MAX_SIZE,
  fileMaxEntries: FILE_MAX_ENTRIES,
  resolveFilePath: resolveNamespaceFilePath,
});
// called for each inbound message
await claim.finalize();

Underlying expensive operation (existing implementation):

const { value } = await readJsonFileWithFallback(path, {});
...
pruneData(data, now, ttlMs, fileMaxEntries);
await writeJsonFileAtomically(path, data);

Recommendation

Reduce the DoS surface by limiting write amplification and bounding per-message work:

  • Lower fileMaxEntries to the minimum needed for replay protection (or make it configurable).
  • Avoid rewriting the full JSON file on every message. Prefer an append-only log with periodic compaction, or batch/flush commits on an interval (e.g., every N seconds / N entries) with a maximum flush frequency.
  • Add rate limiting/backpressure on inbound webhook processing (per account and/or per sender) so a single remote party cannot force sustained high-frequency commits.

Example: batch commits to disk (conceptual):

// Pseudocode: buffer commits in memory, flush at most once per 1s
const pending = new Map<string, number>();
setInterval(() => flush(pending), 1000);

function record(key: string) {
  pending.set(key, Date.now());
}

If immediate persistence is required, consider a storage format supporting incremental updates (SQLite with an index; LMDB; or a compact binary keyset) rather than whole-file JSON rewrites.

3. 🟡 Insecure temporary state directory selection via NODE_ENV/VITEST can redirect persistent dedupe storage into world-writable tmp
Property Value
Severity Medium
CWE CWE-377
Location extensions/bluebubbles/src/inbound-dedupe.ts:23-29

Description

resolveStateDirFromEnv() selects a per-pid directory under os.tmpdir() whenever env.VITEST is set or env.NODE_ENV === "test".

If a non-test deployment is misconfigured to run with NODE_ENV=test (or VITEST is set), the BlueBubbles inbound dedupe persistence layer will write its JSON store under a typically world-writable temp directory. This creates a local attack surface:

  • A local attacker can pre-create/symlink parts of the temp path to influence where the dedupe JSON is written.
  • The persistent dedupe layer performs atomic writes/renames to the resolved path; if the directory tree is attacker-controlled via symlinks, this can enable arbitrary file overwrite within the privileges of the running process.
  • Even without overwrite, tampering with the dedupe JSON can cause replay/dedupe bypass or message-drop DoS.

Vulnerable code:

function resolveStateDirFromEnv(env: NodeJS.ProcessEnv = process.env): string {
  if (env.VITEST || env.NODE_ENV === "test") {
    const name = "openclaw-vitest-" + process.pid;
    return path.join(os.tmpdir(), name);
  }
  return resolveStateDir(env);
}

Recommendation

Avoid using a world-writable temp directory for persistent state based on generic environment flags.

Recommended fixes (pick one):

  1. Remove NODE_ENV === "test" from runtime behavior and let tests set OPENCLAW_STATE_DIR explicitly.
function resolveStateDirFromEnv(env: NodeJS.ProcessEnv = process.env): string {// Only test runner should set OPENCLAW_STATE_DIR to a temp dir.
  return resolveStateDir(env);
}
  1. If you must support test isolation automatically, gate it behind a dedicated, hard-to-misconfigure flag and use fs.mkdtemp() to create a unique directory, plus verify it is not symlinked:
import fs from "node:fs/promises";

async function resolveStateDirFromEnv(env: NodeJS.ProcessEnv = process.env): Promise<string> {
  if (env.OPENCLAW_TEST_TMP_STATE === "1") {
    const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-vitest-"));// Optionally: verify realpath(dir) starts with realpath(os.tmpdir())
    return dir;
  }
  return resolveStateDir(env);
}

Also consider validating that the resolved state dir is owned by the current user and not group/world-writable before writing state files.

4. 🔵 Log forging via unsanitized error string in BlueBubbles reply failure logging
Property Value
Severity Low
CWE CWE-117
Location extensions/bluebubbles/src/monitor-processing.ts:1694-1709

Description

The BlueBubbles reply pipeline logs String(err) directly in an error message. If err contains attacker-influenced content (e.g., propagated from upstream HTTP responses or other externally-derived error messages), it may include newlines/control characters, enabling log forging/CRLF injection (creating fake log lines, hiding subsequent entries) or terminal control-sequence injection depending on the log viewer.

Although sanitizeForLog() was introduced and used for some verbose logs, it is not applied to this runtime.error logging path.

Vulnerable code:

runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${String(err)}`);

Recommendation

Sanitize any potentially untrusted values before logging, including error objects.

Example fix (reuse the existing sanitizer):

runtime.error?.(
  `BlueBubbles ${sanitizeForLog(info.kind)} reply failed: ${sanitizeForLog(err)}`,
);

Optionally, prefer structured logging APIs that keep fields separate from message formatting to reduce injection risk.

5. 🔵 Sensitive identifier disclosure in verbose logs (inbound dedupe drop/finalize paths)
Property Value
Severity Low
CWE CWE-532
Location extensions/bluebubbles/src/monitor-processing.ts:635-640

Description

processMessage logs BlueBubbles inbound dedupe keys (message GUIDs) and senderId when dropping duplicates/inflight deliveries and when finalize fails.

  • dedupeKey is derived from BlueBubbles messageId / associatedMessageGuid (message GUID)
  • message.senderId is a user identifier (phone/email/handle depending on BlueBubbles configuration)
  • These values are written to logs via logVerbose(...) and may be retained/forwarded by operators, unintentionally disclosing PII/sensitive metadata

Vulnerable code:

logVerbose(
  core,
  runtime,
  `drop: ${claim.kind} inbound key=${sanitizeForLog(dedupeKey ?? "")} sender=${sanitizeForLog(message.senderId)}`,
);

While sanitizeForLog mitigates log injection (control characters), it does not minimize or redact sensitive identifiers.

Recommendation

Avoid logging full message GUIDs and sender identifiers.

Options (choose one or combine):

  1. Redact most of the identifier:
function redactId(value: unknown): string {
  const s = String(value ?? "");
  if (s.length <= 8) return "[redacted]";
  return `${s.slice(0, 4)}${s.slice(-4)}`;
}

logVerbose(core, runtime, `drop: ${claim.kind} inbound key=${redactId(dedupeKey)} sender=${redactId(message.senderId)}`);
  1. Hash for correlation (preferred if operators need to correlate events without seeing raw IDs):
import { createHash } from "node:crypto";

function hashForLog(value: unknown): string {
  return createHash("sha256").update(String(value ?? ""), "utf8").digest("hex").slice(0, 12);
}

logVerbose(core, runtime, `drop: ${claim.kind} inbound keyHash=${hashForLog(dedupeKey)} senderHash=${hashForLog(message.senderId)}`);
  1. Ensure this logging is disabled by default / only enabled in a secure debug mode, and document that logs may contain sensitive metadata when enabled.

Analyzed PR: #66230 at commit 82bce26

Last updated on: 2026-04-14T21:10:14Z

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: 45def7dd83

ℹ️ 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 thread extensions/bluebubbles/src/monitor-processing.ts Outdated
Comment thread extensions/bluebubbles/src/monitor-processing.ts Outdated
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 14, 2026

Greptile Summary

This PR adds a persistent file-backed GUID dedupe to the BlueBubbles inbound path using the existing createClaimableDedupe SDK helper, fixing webhook replay after BB Server restart (#19176, #12053). The two-phase claim/finalize/release pattern correctly handles transient failures — the GUID is only committed to disk after processing succeeds AND the final reply delivers, and is released (allowing a future replay to retry) on any exception or delivery failure.

Confidence Score: 5/5

  • Safe to merge — the core dedupe logic and finalize/release pattern are correct, previous reviewer concerns have been addressed, and all remaining findings are minor documentation.
  • All P0/P1 concerns from prior review rounds are resolved. The two-phase claim/finalize/release pattern properly handles transient failures, the test state reset is wired into the shared monitor test helper, and the scripts/test-bb-dedupe.sh scratch script was removed before merge. Only one P2 comment remains (a slightly misleading inline comment about a "scoped dedupe test" that doesn't exist yet).
  • No files require special attention.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: extensions/bluebubbles/src/inbound-dedupe.ts
Line: 24-28

Comment:
**Misleading comment about "scoped dedupe test"**

The inline comment says "Stable-per-pid so the scoped dedupe test can observe persistence," but no test in `inbound-dedupe.test.ts` actually exercises the persistent (file-backed) path — all five tests install the memory-only impl via `_resetBlueBubblesInboundDedupForTest`. The VITEST branch here is only ever reached at module-load time before the first reset, so the pid-based dir is never populated during tests. Consider either dropping the aspiration from the comment or noting that this isolation applies to the initial module-load construction only.

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

Reviews (2): Last reviewed commit: "fix(bluebubbles): dedupe inbound webhook..." | Re-trigger Greptile

Comment thread extensions/bluebubbles/src/inbound-dedupe.ts Outdated
Comment thread extensions/bluebubbles/src/inbound-dedupe.test.ts Outdated
@omarshahine omarshahine force-pushed the lobster/bb-inbound-dedupe branch from 8dad605 to 3ff4cb0 Compare April 14, 2026 00:43
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: 3ff4cb05d0

ℹ️ 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 thread extensions/bluebubbles/src/monitor-processing.ts Outdated
omarshahine pushed a commit that referenced this pull request Apr 14, 2026
- Switch to claim/commit/release pattern via createClaimableDedupe so a
  thrown error after claim releases the GUID (lets a later replay retry
  instead of being permanently blocked for the 7-day TTL). Addresses the
  Feishu-style two-phase claim pattern noted in the Greptile review.
- Cap GUID length at 512 chars and reject oversize values before
  persisting, preventing on-disk bloat from malformed/hostile payloads
  (security review: CWE-400).
- Sanitize guid/sender/error strings in verbose log lines to strip
  control characters, avoiding log forging (security review: CWE-117).
- Drop the unused OPENCLAW_STATE_DIR stubbing from the scoped test; the
  memory-only impl does not touch disk. Add coverage for oversize GUID
  rejection and the release/retry path.
@omarshahine omarshahine force-pushed the lobster/bb-inbound-dedupe branch from 3ff4cb0 to 50af391 Compare April 14, 2026 01:16
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: 50af391f44

ℹ️ 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 thread extensions/bluebubbles/src/inbound-dedupe.ts Outdated
omarshahine pushed a commit that referenced this pull request Apr 14, 2026
…66230 review

- Resolve thread rwF8: finalize the inbound dedupe claim only when reply
  dispatch actually succeeded. Reply-delivery errors surface through the
  dispatcher's onError callback, not as thrown exceptions, so a plain
  try/catch cannot detect them. Thread a dedupeSignal through
  processMessageAfterDedupe, flip the flag in dispatcher onError, and have
  the outer wrapper release the claim on failure so BB MessagePoller
  replays on a later reconnect can retry the send (and the user is not
  silently denied their reply for 7 days after a transient send error).
- Resolve thread rkzI: align the dedupe key with the debouncer's
  buildKey logic. BlueBubbles sends URL-preview / sticker 'balloon' events
  with a different messageId than the originating text message; when
  balloonBundleId + associatedMessageGuid are present, dedupe against the
  underlying associatedMessageGuid so balloon-first vs text-first delivery
  order cannot produce two distinct dedupe keys for the same logical
  message.
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: dc71d95275

ℹ️ 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 thread extensions/bluebubbles/src/inbound-dedupe.ts
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: e14a9dfa5c

ℹ️ 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 thread extensions/bluebubbles/src/inbound-dedupe.ts
omarshahine pushed a commit that referenced this pull request Apr 14, 2026
- Switch to claim/commit/release pattern via createClaimableDedupe so a
  thrown error after claim releases the GUID (lets a later replay retry
  instead of being permanently blocked for the 7-day TTL). Addresses the
  Feishu-style two-phase claim pattern noted in the Greptile review.
- Cap GUID length at 512 chars and reject oversize values before
  persisting, preventing on-disk bloat from malformed/hostile payloads
  (security review: CWE-400).
- Sanitize guid/sender/error strings in verbose log lines to strip
  control characters, avoiding log forging (security review: CWE-117).
- Drop the unused OPENCLAW_STATE_DIR stubbing from the scoped test; the
  memory-only impl does not touch disk. Add coverage for oversize GUID
  rejection and the release/retry path.
omarshahine pushed a commit that referenced this pull request Apr 14, 2026
…66230 review

- Resolve thread rwF8: finalize the inbound dedupe claim only when reply
  dispatch actually succeeded. Reply-delivery errors surface through the
  dispatcher's onError callback, not as thrown exceptions, so a plain
  try/catch cannot detect them. Thread a dedupeSignal through
  processMessageAfterDedupe, flip the flag in dispatcher onError, and have
  the outer wrapper release the claim on failure so BB MessagePoller
  replays on a later reconnect can retry the send (and the user is not
  silently denied their reply for 7 days after a transient send error).
- Resolve thread rkzI: align the dedupe key with the debouncer's
  buildKey logic. BlueBubbles sends URL-preview / sticker 'balloon' events
  with a different messageId than the originating text message; when
  balloonBundleId + associatedMessageGuid are present, dedupe against the
  underlying associatedMessageGuid so balloon-first vs text-first delivery
  order cannot produce two distinct dedupe keys for the same logical
  message.
omarshahine pushed a commit that referenced this pull request Apr 14, 2026
Reviewer raised a P1 on #66230: preferring associatedMessageGuid for
every inbound message is wrong because that field doubles as reply
linkage. extractReplyMetadata in monitor-normalize.ts falls back to
associatedMessageGuid for non-reaction messages, so two distinct user
replies to the same parent message would share a single dedupe key —
and once the first finalized, every later reply to that same parent
would be silently dropped as a 'duplicate' for the full 7-day TTL.

Revert the gate to the debouncer's original predicate
(balloonBundleId && associatedMessageGuid) so only the balloon +
underlying-text coalescing case uses associatedMessageGuid. Regular
replies now correctly dedupe by their own messageId.

Known narrower tradeoff: combineDebounceEntries clears balloonBundleId
on merged entries, so a post-merge balloon+text message falls back to
messageId here. If a later MessagePoller replay merges the two
webhooks in a different order it may produce a different messageId
and bypass dedupe for that one message. That window is much narrower
than the alternative — which silently drops real user replies — and
is documented in the comment.
@omarshahine omarshahine force-pushed the lobster/bb-inbound-dedupe branch from e14a9df to d5525d9 Compare April 14, 2026 04:02
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: d5525d9160

ℹ️ 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 thread extensions/bluebubbles/src/inbound-dedupe.ts Outdated
omarshahine pushed a commit that referenced this pull request Apr 14, 2026
…uard

Two related fixes after CI + Codex feedback on #66230:

- P2 (Codex): the plugin was resolving OPENCLAW_STATE_DIR verbatim so
  values like '~/state' were not home-expanded, and legacy-vs-new state
  dir fallback diverged from the rest of OpenClaw. Use the shared
  resolveStateDir helper from openclaw/plugin-sdk/state-paths so the
  BlueBubbles dedupe file lands alongside the rest of state across all
  supported OPENCLAW_STATE_DIR / OPENCLAW_HOME configurations.

- CI (temp-path-guard + lint:tmp:no-random-messaging): the vitest
  branch used path.join(os.tmpdir(), `openclaw-vitest-${pid}`) — a
  dynamic template literal passed directly as a path.join argument,
  which the security guard flags. Store the name in a local and pass
  it as a plain variable (matching the guard's allowed 'segment'
  fixture) while keeping identical runtime behavior.
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: 116e0d275e

ℹ️ 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 thread extensions/bluebubbles/src/monitor-processing.ts Outdated
omarshahine pushed a commit that referenced this pull request Apr 14, 2026
Codex P2 on #66230: dispatcher.onError fires for tool, block, and final
kinds, but only 'final' represents the user-visible answer. Dispatcher
continues past tool/block failures and may still deliver final cleanly.
Releasing the dedupe claim for non-terminal failures would invite a
replay that re-runs tool side effects and resends already-delivered
chunks. Gate dedupeSignal.deliveryFailed = true on info.kind === 'final'.
@omarshahine omarshahine force-pushed the lobster/bb-inbound-dedupe branch from 0b233e4 to 88c67f1 Compare April 14, 2026 18:11
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: 88c67f1963

ℹ️ 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 +1694 to +1696
if (info.kind === "final") {
dedupeSignal.deliveryFailed = true;
}
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 Handle block-only send failures before finalizing dedupe

onError only flips dedupeSignal.deliveryFailed for final payloads, but the shared reply flow explicitly allows successful block-streaming turns with no final payload (src/auto-reply/reply/dispatch-from-config.ts notes this at the block-streaming path). In those runs, a failed BlueBubbles block send leaves deliveryFailed false, so processMessage still calls claim.finalize() and permanently dedupes the inbound message even though user-visible delivery failed. That drops later replay retries for the TTL window and can lose replies after transient send errors.

Useful? React with 👍 / 👎.

@omarshahine omarshahine reopened this Apr 14, 2026
@omarshahine omarshahine force-pushed the lobster/bb-inbound-dedupe branch 2 times, most recently from 88c67f1 to cd00811 Compare April 14, 2026 18:33
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: cd00811112

ℹ️ 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 remembered GUID, and the file-backed store survives gateway restarts.
const DEDUP_TTL_MS = 7 * 24 * 60 * 60 * 1_000;
const MEMORY_MAX_SIZE = 5_000;
const FILE_MAX_ENTRIES = 50_000;
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 Preserve 7-day replay coverage in dedupe capacity

Increase or make configurable this per-account cap, because createPersistentDedupe evicts oldest keys when fileMaxEntries is exceeded even if those keys are still within TTL (src/plugin-sdk/persistent-dedupe.ts pruneData). With DEDUP_TTL_MS set to 7 days but FILE_MAX_ENTRIES fixed at 50,000, higher-volume accounts can churn through more than 50k messages in a week, causing still-recent GUIDs to be dropped and replayed webhooks after restart to bypass dedupe and re-run message handling.

Useful? React with 👍 / 👎.

omarshahine pushed a commit that referenced this pull request Apr 14, 2026
…rt (#66721)

Adds a per-account startup catchup pass that queries BB Server for messages
delivered since a persisted cursor and re-feeds each through the existing
processMessage pipeline. Fixes the missed-message hole documented in #66721:
BB's WebhookService is fire-and-forget on POST failure and MessagePoller
does not replay webhook-receiver recovery, so inbound messages delivered
while the gateway was down/wedged/restarting were permanently lost.

Design

- New `extensions/bluebubbles/src/catchup.ts`:
  - `fetchBlueBubblesMessagesSince(sinceMs, limit, opts)` calls
    `/api/v1/message/query` with `{after, sort:"ASC", with:[chat,
    chat.participants, attachment]}` so replays carry the same shape
    `normalizeWebhookMessage` already handles on live dispatch.
  - `loadBlueBubblesCatchupCursor` / `saveBlueBubblesCatchupCursor`
    persist a single `{lastSeenMs, updatedAt}` per account at
    `<stateDir>/bluebubbles/catchup/<accountId>__<hash>.json`, using
    the plugin-sdk's atomic JSON helpers. File layout mirrors the
    inbound-dedupe store from #66230.
  - `runBlueBubblesCatchup(target)` orchestrates: clamp config, skip
    recent runs (<30s), clamp window to `maxAgeMinutes`, fetch, filter
    `isFromMe` and pre-cursor records, dispatch to `processMessage`,
    advance cursor to `nowMs` on success.
- `monitor.ts`: after the webhook target registers, fire catchup as a
  background task; errors are logged but never block readiness.
- `config-schema.ts`: new optional `catchup` block (`enabled`,
  `maxAgeMinutes`, `perRunLimit`, `firstRunLookbackMinutes`), defaults
  on with 2h lookback / 50 msg cap / 30-min first-run lookback.

Safety

- Goes through the same `processMessage` path webhooks use, so auth,
  allowlist, pairing, and downstream agent dispatch all apply unchanged.
- Dedupes against #66230's persistent inbound GUID cache, so a webhook
  delivery that already succeeded cannot be reprocessed by catchup.
- Never dispatches `isFromMe` records (double-checked before and after
  normalization) so the agent's own sends cannot enter the inbound path.
- Cursor is only advanced on a successful query; a failed fetch leaves
  the prior cursor in place so the next run retries the same window.
- 30s minimum interval between runs prevents churn on rolling restarts.
- Hard ceilings: 12h max lookback, 500 messages per run.

Validation

- New scoped tests in `extensions/bluebubbles/src/catchup.test.ts`
  (14 cases covering cursor round-trip, account scoping, FS-unsafe
  account IDs, firstRunLookback, maxAge clamp, enabled:false, rapid
  restart skip, isFromMe filter, query failure preserving cursor,
  per-message failure isolation, and pre-cursor defense-in-depth).
- Full BlueBubbles suite (403/403) and `pnpm check` green.
- End-to-end verified on macOS with a live BB Server 1.9.x: stop
  gateway, send N messages to monitored threads (verified 3/3
  ECONNREFUSED in BB's `main.log`), restart gateway; catchup queried
  and re-POSTed all N missed messages through `processMessage`, and
  subsequent boots were no-ops (empty delta).

Retires the reference implementation at
`openclaw-agents/lobster/scripts/bb-catchup.sh` in the Lobster
workspace, which has been running this exact shape for ~4 weeks.

Closes #66721.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
omarshahine pushed a commit that referenced this pull request Apr 14, 2026
…kew gate

Addresses Codex review findings on PR #66760:

- P1: Avoid committing catchup cursor after replay failures
  Previously the cursor advanced to nowMs unconditionally, so a
  transient processMessage failure during startup catchup permanently
  dropped the failing record — the next sweep queried only `after
  nowMs` and never retried it. Now we track the earliest timestamp
  where processMessage threw and hold the cursor just before it (still
  clamped to >= the prior cursor and <= nowMs), so retries pick up
  exactly the failed records on the next run. The inbound-dedupe cache
  from #66230 keeps successfully-replayed messages from being
  re-processed on retry. Normalize failures (record didn't yield a
  usable NormalizedWebhookMessage) are still treated as permanent
  skips, so a malformed payload doesn't wedge catchup forever.

- P2: Skip min-interval gate when stored cursor is in the future
  If the host clock jumped backwards (NTP correction, manual adjust),
  `nowMs - existing.lastSeenMs` was negative, which still satisfies
  `< MIN_INTERVAL_MS`, so catchup returned null repeatedly until wall
  time caught up. Added `nowMs >= existing.lastSeenMs` precondition so
  future-dated cursors stop disabling catchup.

Tests: 3 new cases (cursor-held-on-failure, clamp-to-prior-cursor,
clock-skew-bypass-gate); BB suite remains 408/408.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…2053)

BlueBubbles MessagePoller replays its ~1-week lookback window as new-message
webhooks after BB Server restart or reconnect. Without persistent dedup, the
gateway re-replies to messages it already handled before the restart.

Add a persistent file-backed GUID dedupe (TTL=7d, matching BB's lookback
window) at the top of processMessage, using the same createClaimableDedupe
SDK primitive as Feishu. The on-disk store at
~/.openclaw/bluebubbles/inbound-dedupe/<account>.json survives gateway
restarts. Claim/finalize/release semantics ensure transient delivery failures
release the GUID so a later replay can retry, while successful deliveries are
committed and block future replays.

Fixes #19176, #12053.
@omarshahine omarshahine force-pushed the lobster/bb-inbound-dedupe branch from 4d63af6 to 82bce26 Compare April 14, 2026 21:02
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: 82bce2647e

ℹ️ 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".

throw error;
}
if (claim.kind === "claimed") {
if (signal.deliveryFailed) {
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 Handle pairing reply failures before finalizing dedupe

This finalize path runs whenever dedupeSignal.deliveryFailed is false, but pairing-challenge send failures do not set that signal. In the pairing branch, issueChallenge catches sendPairingReply exceptions and only invokes onReplyError (src/pairing/pairing-challenge.ts), so processMessageAfterDedupe can return successfully even when the pairing message was never delivered. The GUID is then committed here, which makes subsequent BlueBubbles replays look like duplicates for 7 days and prevents the user from receiving a retry pairing code after transient send failures.

Useful? React with 👍 / 👎.

@omarshahine
Copy link
Copy Markdown
Contributor Author

Closing to reopen as a fresh PR — GitHub stopped firing pull_request CI events after the branch history rewrite. Same branch, same code.

omarshahine pushed a commit that referenced this pull request Apr 14, 2026
…66810

The inbound-dedupe PR was reopened as #66810 (#66230 closed without
merging at 21:10:28Z, #66810 created 7s later on the same
lobster/bb-inbound-dedupe branch). Updating code comments and the
catchup CHANGELOG entry to point at the live PR. The branch dependency
chain (this PR stacked on lobster/bb-inbound-dedupe) is unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
omarshahine pushed a commit that referenced this pull request Apr 14, 2026
…66816

The inbound-dedupe PR was reopened again as #66816 (closed-without-merge
trail: #66230#66810#66816). The branch was force-pushed and the
new PR uses the parallel `fix/bb-inbound-dedupe` branch. Updating code
comments and the catchup CHANGELOG entry to point at the live PR.
Stacking on top of the dedupe branch will be addressed in a follow-up
rebase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@omarshahine omarshahine deleted the lobster/bb-inbound-dedupe branch April 14, 2026 22:48
omarshahine pushed a commit that referenced this pull request Apr 14, 2026
…rt (#66721)

Adds a per-account startup catchup pass that queries BB Server for messages
delivered since a persisted cursor and re-feeds each through the existing
processMessage pipeline. Fixes the missed-message hole documented in #66721:
BB's WebhookService is fire-and-forget on POST failure and MessagePoller
does not replay webhook-receiver recovery, so inbound messages delivered
while the gateway was down/wedged/restarting were permanently lost.

Design

- New `extensions/bluebubbles/src/catchup.ts`:
  - `fetchBlueBubblesMessagesSince(sinceMs, limit, opts)` calls
    `/api/v1/message/query` with `{after, sort:"ASC", with:[chat,
    chat.participants, attachment]}` so replays carry the same shape
    `normalizeWebhookMessage` already handles on live dispatch.
  - `loadBlueBubblesCatchupCursor` / `saveBlueBubblesCatchupCursor`
    persist a single `{lastSeenMs, updatedAt}` per account at
    `<stateDir>/bluebubbles/catchup/<accountId>__<hash>.json`, using
    the plugin-sdk's atomic JSON helpers. File layout mirrors the
    inbound-dedupe store from #66230.
  - `runBlueBubblesCatchup(target)` orchestrates: clamp config, skip
    recent runs (<30s), clamp window to `maxAgeMinutes`, fetch, filter
    `isFromMe` and pre-cursor records, dispatch to `processMessage`,
    advance cursor to `nowMs` on success.
- `monitor.ts`: after the webhook target registers, fire catchup as a
  background task; errors are logged but never block readiness.
- `config-schema.ts`: new optional `catchup` block (`enabled`,
  `maxAgeMinutes`, `perRunLimit`, `firstRunLookbackMinutes`), defaults
  on with 2h lookback / 50 msg cap / 30-min first-run lookback.

Safety

- Goes through the same `processMessage` path webhooks use, so auth,
  allowlist, pairing, and downstream agent dispatch all apply unchanged.
- Dedupes against #66230's persistent inbound GUID cache, so a webhook
  delivery that already succeeded cannot be reprocessed by catchup.
- Never dispatches `isFromMe` records (double-checked before and after
  normalization) so the agent's own sends cannot enter the inbound path.
- Cursor is only advanced on a successful query; a failed fetch leaves
  the prior cursor in place so the next run retries the same window.
- 30s minimum interval between runs prevents churn on rolling restarts.
- Hard ceilings: 12h max lookback, 500 messages per run.

Validation

- New scoped tests in `extensions/bluebubbles/src/catchup.test.ts`
  (14 cases covering cursor round-trip, account scoping, FS-unsafe
  account IDs, firstRunLookback, maxAge clamp, enabled:false, rapid
  restart skip, isFromMe filter, query failure preserving cursor,
  per-message failure isolation, and pre-cursor defense-in-depth).
- Full BlueBubbles suite (403/403) and `pnpm check` green.
- End-to-end verified on macOS with a live BB Server 1.9.x: stop
  gateway, send N messages to monitored threads (verified 3/3
  ECONNREFUSED in BB's `main.log`), restart gateway; catchup queried
  and re-POSTed all N missed messages through `processMessage`, and
  subsequent boots were no-ops (empty delta).

Retires the reference implementation at
`openclaw-agents/lobster/scripts/bb-catchup.sh` in the Lobster
workspace, which has been running this exact shape for ~4 weeks.

Closes #66721.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
omarshahine pushed a commit that referenced this pull request Apr 14, 2026
…kew gate

Addresses Codex review findings on PR #66760:

- P1: Avoid committing catchup cursor after replay failures
  Previously the cursor advanced to nowMs unconditionally, so a
  transient processMessage failure during startup catchup permanently
  dropped the failing record — the next sweep queried only `after
  nowMs` and never retried it. Now we track the earliest timestamp
  where processMessage threw and hold the cursor just before it (still
  clamped to >= the prior cursor and <= nowMs), so retries pick up
  exactly the failed records on the next run. The inbound-dedupe cache
  from #66230 keeps successfully-replayed messages from being
  re-processed on retry. Normalize failures (record didn't yield a
  usable NormalizedWebhookMessage) are still treated as permanent
  skips, so a malformed payload doesn't wedge catchup forever.

- P2: Skip min-interval gate when stored cursor is in the future
  If the host clock jumped backwards (NTP correction, manual adjust),
  `nowMs - existing.lastSeenMs` was negative, which still satisfies
  `< MIN_INTERVAL_MS`, so catchup returned null repeatedly until wall
  time caught up. Added `nowMs >= existing.lastSeenMs` precondition so
  future-dated cursors stop disabling catchup.

Tests: 3 new cases (cursor-held-on-failure, clamp-to-prior-cursor,
clock-skew-bypass-gate); BB suite remains 408/408.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: bluebubbles Channel integration: bluebubbles maintainer Maintainer-authored PR size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant