BlueBubbles: accept webhook payloads with missing handles

This commit is contained in:
Vignesh Natarajan 2026-02-21 22:10:19 -08:00
parent 4f700e96af
commit 96c985400d
3 changed files with 120 additions and 12 deletions

View File

@ -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:<id>]]`, `[[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.

View File

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

View File

@ -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<string, unknown> | null {
@ -629,18 +629,42 @@ export function parseTapbackText(params: {
}
function extractMessagePayload(payload: Record<string, unknown>): Record<string, unknown> | null {
const parseRecord = (value: unknown): Record<string, unknown> | 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;
}