From 96c985400da175f6b86fb9f2ac6911afb1b94291 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sat, 21 Feb 2026 22:10:19 -0800 Subject: [PATCH] BlueBubbles: accept webhook payloads with missing handles --- CHANGELOG.md | 1 + .../bluebubbles/src/monitor-normalize.test.ts | 78 +++++++++++++++++++ .../bluebubbles/src/monitor-normalize.ts | 53 ++++++++++--- 3 files changed, 120 insertions(+), 12 deletions(-) create mode 100644 extensions/bluebubbles/src/monitor-normalize.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bddc2477135..f68911cb2d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai - Memory/QMD: add optional `memory.qmd.mcporter` search routing so QMD `query/search/vsearch` can run through mcporter keep-alive flows (including multi-collection paths) to reduce cold starts, while keeping searches on agent-scoped QMD state for consistent recall. (#19617) Thanks @nicole-luxe and @vignesh07. - Chat/UI: strip inline reply/audio directive tags (`[[reply_to_current]]`, `[[reply_to:]]`, `[[audio_as_voice]]`) from displayed chat history, live chat event output, and session preview snippets so control tags no longer leak into user-visible surfaces. - BlueBubbles/DM history: restore DM backfill context with account-scoped rolling history, bounded backfill retries, and safer history payload limits. (#20302) Thanks @Ryan-Haines. +- BlueBubbles/Webhooks: accept inbound/reaction webhook payloads when BlueBubbles omits `handle` but provides DM `chatGuid`, and harden payload extraction for array/string-wrapped message bodies so valid webhook events no longer get rejected as unparseable. (#23275) Thanks @toph31. - Security/Config: block prototype-key traversal during config merge patch and legacy migration merge helpers (`__proto__`, `constructor`, `prototype`) to prevent prototype pollution during config mutation flows. (#22968) Thanks @Clawborn. - Security/Shell env: validate login-shell executable paths for shell-env fallback (`/etc/shells` + trusted prefixes) and block `SHELL` in dangerous env override policy paths so untrusted shell-path injection falls back safely to `/bin/sh`. Thanks @athuljayaram for reporting. - Security/Config: make parsed chat allowlist checks fail closed when `allowFrom` is empty, restoring expected DM/pairing gating. diff --git a/extensions/bluebubbles/src/monitor-normalize.test.ts b/extensions/bluebubbles/src/monitor-normalize.test.ts new file mode 100644 index 00000000000..3986909c259 --- /dev/null +++ b/extensions/bluebubbles/src/monitor-normalize.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js"; + +describe("normalizeWebhookMessage", () => { + it("falls back to DM chatGuid handle when sender handle is missing", () => { + const result = normalizeWebhookMessage({ + type: "new-message", + data: { + guid: "msg-1", + text: "hello", + isGroup: false, + isFromMe: false, + handle: null, + chatGuid: "iMessage;-;+15551234567", + }, + }); + + expect(result).not.toBeNull(); + expect(result?.senderId).toBe("+15551234567"); + expect(result?.chatGuid).toBe("iMessage;-;+15551234567"); + }); + + it("does not infer sender from group chatGuid when sender handle is missing", () => { + const result = normalizeWebhookMessage({ + type: "new-message", + data: { + guid: "msg-1", + text: "hello group", + isGroup: true, + isFromMe: false, + handle: null, + chatGuid: "iMessage;+;chat123456", + }, + }); + + expect(result).toBeNull(); + }); + + it("accepts array-wrapped payload data", () => { + const result = normalizeWebhookMessage({ + type: "new-message", + data: [ + { + guid: "msg-1", + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + }, + ], + }); + + expect(result).not.toBeNull(); + expect(result?.senderId).toBe("+15551234567"); + }); +}); + +describe("normalizeWebhookReaction", () => { + it("falls back to DM chatGuid handle when reaction sender handle is missing", () => { + const result = normalizeWebhookReaction({ + type: "updated-message", + data: { + guid: "msg-2", + associatedMessageGuid: "p:0/msg-1", + associatedMessageType: 2000, + isGroup: false, + isFromMe: false, + handle: null, + chatGuid: "iMessage;-;+15551234567", + }, + }); + + expect(result).not.toBeNull(); + expect(result?.senderId).toBe("+15551234567"); + expect(result?.messageId).toBe("p:0/msg-1"); + expect(result?.action).toBe("added"); + }); +}); diff --git a/extensions/bluebubbles/src/monitor-normalize.ts b/extensions/bluebubbles/src/monitor-normalize.ts index 56566f20981..e591f21dfb9 100644 --- a/extensions/bluebubbles/src/monitor-normalize.ts +++ b/extensions/bluebubbles/src/monitor-normalize.ts @@ -1,4 +1,4 @@ -import { normalizeBlueBubblesHandle } from "./targets.js"; +import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js"; import type { BlueBubblesAttachment } from "./types.js"; function asRecord(value: unknown): Record | null { @@ -629,18 +629,42 @@ export function parseTapbackText(params: { } function extractMessagePayload(payload: Record): Record | null { + const parseRecord = (value: unknown): Record | null => { + const record = asRecord(value); + if (record) { + return record; + } + if (Array.isArray(value)) { + for (const entry of value) { + const parsedEntry = parseRecord(entry); + if (parsedEntry) { + return parsedEntry; + } + } + return null; + } + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + try { + return parseRecord(JSON.parse(trimmed)); + } catch { + return null; + } + }; + const dataRaw = payload.data ?? payload.payload ?? payload.event; - const data = - asRecord(dataRaw) ?? - (typeof dataRaw === "string" ? (asRecord(JSON.parse(dataRaw)) ?? null) : null); + const data = parseRecord(dataRaw); const messageRaw = payload.message ?? data?.message ?? data; - const message = - asRecord(messageRaw) ?? - (typeof messageRaw === "string" ? (asRecord(JSON.parse(messageRaw)) ?? null) : null); - if (!message) { - return null; + const message = parseRecord(messageRaw); + if (message) { + return message; } - return message; + return null; } export function normalizeWebhookMessage( @@ -700,7 +724,10 @@ export function normalizeWebhookMessage( : timestampRaw * 1000 : undefined; - const normalizedSender = normalizeBlueBubblesHandle(senderId); + // BlueBubbles may omit `handle` in webhook payloads; for DM chat GUIDs we can still infer sender. + const senderFallbackFromChatGuid = + !senderId && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null; + const normalizedSender = normalizeBlueBubblesHandle(senderId || senderFallbackFromChatGuid || ""); if (!normalizedSender) { return null; } @@ -774,7 +801,9 @@ export function normalizeWebhookReaction( : timestampRaw * 1000 : undefined; - const normalizedSender = normalizeBlueBubblesHandle(senderId); + const senderFallbackFromChatGuid = + !senderId && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null; + const normalizedSender = normalizeBlueBubblesHandle(senderId || senderFallbackFromChatGuid || ""); if (!normalizedSender) { return null; }