diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ab1ae9321c..7c9c525b87a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai - Cron/announce delivery: keep isolated announce `NO_REPLY` stripping case-insensitive across direct and text delivery, preserve structured media-only sends when a caption strips silent, and derive main-session awareness from the cleaned payloads so silent captions no longer leak stale `NO_REPLY` text. (#65016) Thanks @BKF-Gitty. - Sessions/Codex: skip redundant `delivery-mirror` transcript appends only when the latest assistant message has the same visible text, preventing duplicate visible replies on Codex-backed turns without suppressing repeated answers across turns. (#67185) Thanks @andyylin. - Auto-reply/prompt-cache: keep volatile inbound chat IDs out of the stable system prompt so task-scoped adapters can reuse prompt caches across runs, while preserving conversation metadata for the user turn and media-only messages. (#65071) Thanks @MonkeyLeeT. +- BlueBubbles/inbound: restore inbound image attachment downloads on Node 22+ by stripping incompatible bundled-undici dispatchers from the non-SSRF fetch path, accept `updated-message` webhooks carrying attachments, use event-type-aware dedup keys so attachment follow-ups are not rejected as duplicates, and retry attachment fetch from the BB API when the initial webhook arrives with an empty array. (#64105, #61861, #65430, #67510) Thanks @omarshahine. ## 2026.4.15-beta.1 diff --git a/extensions/bluebubbles/src/attachments.test.ts b/extensions/bluebubbles/src/attachments.test.ts index 735e832cc13..48330ee9286 100644 --- a/extensions/bluebubbles/src/attachments.test.ts +++ b/extensions/bluebubbles/src/attachments.test.ts @@ -1,6 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import "./test-mocks.js"; -import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js"; +import { + downloadBlueBubblesAttachment, + fetchBlueBubblesMessageAttachments, + sendBlueBubblesAttachment, +} from "./attachments.js"; import { fetchBlueBubblesServerInfo, getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; import type { PluginRuntime } from "./runtime-api.js"; import { setBlueBubblesRuntime } from "./runtime.js"; @@ -769,3 +773,86 @@ describe("sendBlueBubblesAttachment", () => { ).rejects.toThrow("chatGuid not found"); }); }); + +describe("fetchBlueBubblesMessageAttachments", () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + it("returns attachments from the BB API response", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: { + attachments: [ + { + guid: "att-1", + mimeType: "image/jpeg", + transferName: "photo.jpg", + totalBytes: 1024, + }, + { + guid: "att-2", + mime_type: "image/png", + transfer_name: "screenshot.png", + total_bytes: 2048, + }, + ], + }, + }), + }); + const result = await fetchBlueBubblesMessageAttachments("msg-guid", { + baseUrl: "http://localhost:1234", + password: "test", + }); + expect(result).toHaveLength(2); + expect(result[0].guid).toBe("att-1"); + expect(result[0].mimeType).toBe("image/jpeg"); + expect(result[1].guid).toBe("att-2"); + expect(result[1].mimeType).toBe("image/png"); + }); + + it("returns empty array on non-ok HTTP response", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + const result = await fetchBlueBubblesMessageAttachments("msg-guid", { + baseUrl: "http://localhost:1234", + password: "test", + }); + expect(result).toEqual([]); + }); + + it("returns empty array when data has no attachments", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: {} }), + }); + const result = await fetchBlueBubblesMessageAttachments("msg-guid", { + baseUrl: "http://localhost:1234", + password: "test", + }); + expect(result).toEqual([]); + }); + + it("includes entries without a guid (downstream download handles filtering)", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: { + attachments: [{ mimeType: "image/jpeg" }, { guid: "att-valid", mimeType: "image/png" }], + }, + }), + }); + const result = await fetchBlueBubblesMessageAttachments("msg-guid", { + baseUrl: "http://localhost:1234", + password: "test", + }); + expect(result).toHaveLength(2); + expect(result[0].guid).toBeUndefined(); + expect(result[1].guid).toBe("att-valid"); + }); +}); diff --git a/extensions/bluebubbles/src/attachments.ts b/extensions/bluebubbles/src/attachments.ts index 83124b61b84..f5c3e4acddd 100644 --- a/extensions/bluebubbles/src/attachments.ts +++ b/extensions/bluebubbles/src/attachments.ts @@ -8,6 +8,7 @@ import { normalizeOptionalString, } from "openclaw/plugin-sdk/text-runtime"; import { resolveBlueBubblesServerAccount } from "./account-resolve.js"; +import { extractAttachments } from "./monitor-normalize.js"; import { assertMultipartActionOk, postMultipartFormData } from "./multipart.js"; import { fetchBlueBubblesServerInfo, @@ -26,8 +27,12 @@ import { type SsrFPolicy, } from "./types.js"; -function blueBubblesPolicy(allowPrivateNetwork: boolean | undefined): SsrFPolicy { - return allowPrivateNetwork ? { allowPrivateNetwork: true } : {}; +function blueBubblesPolicy(allowPrivateNetwork: boolean | undefined): SsrFPolicy | undefined { + // Pass `undefined` (not `{}`) for the non-private case so the non-SSRF fallback path + // is used. An empty `{}` policy routes through the SSRF guard, which blocks the + // localhost BB deployments that are the most common self-hosted setup. The opt-in + // private-network branch keeps the explicit policy. (#64105, #67510) + return allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined; } export type BlueBubblesAttachmentOpts = { @@ -95,6 +100,51 @@ function readMediaFetchErrorCode(error: unknown): MediaFetchErrorCode | undefine : undefined; } +/** + * Fetch attachment metadata for a message from the BlueBubbles API. + * + * BlueBubbles sometimes fires the `new-message` webhook before attachment + * indexing is complete, so `attachments` arrives as `[]`. This function + * GETs the message by GUID and returns whatever attachments the server + * has indexed by now. (#65430, #67437) + */ +export async function fetchBlueBubblesMessageAttachments( + messageGuid: string, + opts: { + baseUrl: string; + password: string; + timeoutMs?: number; + allowPrivateNetwork?: boolean; + }, +): Promise { + const url = buildBlueBubblesApiUrl({ + baseUrl: opts.baseUrl, + path: `/api/v1/message/${encodeURIComponent(messageGuid)}`, + password: opts.password, + }); + // Pass undefined (not {}) when private network is not opted-in so the + // non-SSRF fallback path is used — an empty {} triggers the SSRF-guarded + // path which blocks localhost BB servers by default. (#64105) + const policy: SsrFPolicy | undefined = opts.allowPrivateNetwork + ? { allowPrivateNetwork: true } + : undefined; + const response = await blueBubblesFetchWithTimeout( + url, + { method: "GET" }, + opts.timeoutMs, + policy, + ); + if (!response.ok) { + return []; + } + const json = (await response.json()) as Record; + const data = json.data as Record | undefined; + if (!data) { + return []; + } + return extractAttachments(data); +} + export async function downloadBlueBubblesAttachment( attachment: BlueBubblesAttachment, opts: BlueBubblesAttachmentOpts & { maxBytes?: number } = {}, diff --git a/extensions/bluebubbles/src/inbound-dedupe.test.ts b/extensions/bluebubbles/src/inbound-dedupe.test.ts index cd78be834bc..46034ef8f20 100644 --- a/extensions/bluebubbles/src/inbound-dedupe.test.ts +++ b/extensions/bluebubbles/src/inbound-dedupe.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { _resetBlueBubblesInboundDedupForTest, claimBlueBubblesInboundMessage, + resolveBlueBubblesInboundDedupeKey, } from "./inbound-dedupe.js"; async function claimAndFinalize(guid: string | undefined, accountId: string): Promise { @@ -56,3 +57,38 @@ describe("claimBlueBubblesInboundMessage", () => { expect(await claimAndFinalize("g1", "acc")).toBe("claimed"); }); }); + +describe("resolveBlueBubblesInboundDedupeKey", () => { + it("returns messageId for new-message events", () => { + expect(resolveBlueBubblesInboundDedupeKey({ messageId: "msg-1" })).toBe("msg-1"); + }); + + it("returns associatedMessageGuid for balloon events", () => { + expect( + resolveBlueBubblesInboundDedupeKey({ + messageId: "balloon-1", + balloonBundleId: "com.apple.messages.URLBalloonProvider", + associatedMessageGuid: "msg-1", + }), + ).toBe("msg-1"); + }); + + it("suffixes key with :updated for updated-message events", () => { + expect( + resolveBlueBubblesInboundDedupeKey({ messageId: "msg-1", eventType: "updated-message" }), + ).toBe("msg-1:updated"); + }); + + it("updated-message and new-message for same GUID produce distinct keys", () => { + const newKey = resolveBlueBubblesInboundDedupeKey({ messageId: "msg-1" }); + const updatedKey = resolveBlueBubblesInboundDedupeKey({ + messageId: "msg-1", + eventType: "updated-message", + }); + expect(newKey).not.toBe(updatedKey); + }); + + it("returns undefined when messageId is missing", () => { + expect(resolveBlueBubblesInboundDedupeKey({})).toBeUndefined(); + }); +}); diff --git a/extensions/bluebubbles/src/inbound-dedupe.ts b/extensions/bluebubbles/src/inbound-dedupe.ts index deffa49685f..1be0dadedb9 100644 --- a/extensions/bluebubbles/src/inbound-dedupe.ts +++ b/extensions/bluebubbles/src/inbound-dedupe.ts @@ -136,15 +136,27 @@ function sanitizeGuid(guid: string | undefined | null): string | null { export function resolveBlueBubblesInboundDedupeKey( message: Pick< NormalizedWebhookMessage, - "messageId" | "balloonBundleId" | "associatedMessageGuid" + "messageId" | "balloonBundleId" | "associatedMessageGuid" | "eventType" >, ): string | undefined { const balloonBundleId = message.balloonBundleId?.trim(); const associatedMessageGuid = message.associatedMessageGuid?.trim(); + let base: string | undefined; if (balloonBundleId && associatedMessageGuid) { - return associatedMessageGuid; + base = associatedMessageGuid; + } else { + base = message.messageId?.trim() || undefined; } - return message.messageId?.trim() || undefined; + if (!base) { + return undefined; + } + // `updated-message` events get a distinct key so they are not rejected as + // duplicates of the already-committed `new-message` for the same GUID. + // This lets attachment-carrying follow-up webhooks through. (#65430, #52277) + if (message.eventType === "updated-message") { + return `${base}:updated`; + } + return base; } export type InboundDedupeClaim = diff --git a/extensions/bluebubbles/src/monitor-normalize.ts b/extensions/bluebubbles/src/monitor-normalize.ts index 463e1939b1b..5df7edb6cde 100644 --- a/extensions/bluebubbles/src/monitor-normalize.ts +++ b/extensions/bluebubbles/src/monitor-normalize.ts @@ -34,7 +34,7 @@ function readNumberLike(record: Record | null, key: string): nu return parseFiniteNumber(record[key]); } -function extractAttachments(message: Record): BlueBubblesAttachment[] { +export function extractAttachments(message: Record): BlueBubblesAttachment[] { const raw = message["attachments"]; if (!Array.isArray(raw)) { return []; @@ -477,6 +477,8 @@ export type NormalizedWebhookMessage = { replyToId?: string; replyToBody?: string; replyToSender?: string; + /** Webhook event type preserved for dedup key differentiation. */ + eventType?: string; }; export type NormalizedWebhookReaction = { @@ -687,6 +689,7 @@ function extractMessagePayload(payload: Record): Record, + options?: { eventType?: string }, ): NormalizedWebhookMessage | null { const message = extractMessagePayload(payload); if (!message) { @@ -774,6 +777,7 @@ export function normalizeWebhookMessage( replyToId: replyMetadata.replyToId, replyToBody: replyMetadata.replyToBody, replyToSender: replyMetadata.replyToSender, + eventType: options?.eventType, }; } diff --git a/extensions/bluebubbles/src/monitor-processing.ts b/extensions/bluebubbles/src/monitor-processing.ts index e2ef93c070f..0e9016d73de 100644 --- a/extensions/bluebubbles/src/monitor-processing.ts +++ b/extensions/bluebubbles/src/monitor-processing.ts @@ -9,7 +9,10 @@ import { normalizeOptionalLowercaseString, normalizeOptionalString, } from "openclaw/plugin-sdk/text-runtime"; -import { downloadBlueBubblesAttachment } from "./attachments.js"; +import { + downloadBlueBubblesAttachment, + fetchBlueBubblesMessageAttachments, +} from "./attachments.js"; import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js"; import { resolveBlueBubblesConversationRoute } from "./conversation-route.js"; import { fetchBlueBubblesHistory } from "./history.js"; @@ -692,8 +695,52 @@ async function processMessageAfterDedupe( const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup; const text = message.text.trim(); - const attachments = message.attachments ?? []; - const placeholder = buildMessagePlaceholder(message); + let attachments = message.attachments ?? []; + const baseUrl = normalizeSecretInputString(account.config.serverUrl); + const password = normalizeSecretInputString(account.config.password); + + // BlueBubbles may fire the webhook before attachment indexing is complete, + // so the initial `attachments` array can be empty for messages that actually + // have media. When the message text is empty (image-only) or this is an + // `updated-message` event, wait briefly and re-fetch from the BB API as a + // fallback for cases where BB doesn't send a follow-up webhook. (#65430, #67437) + // This must run before the !rawBody guard below, otherwise image-only messages + // with empty attachments are dropped before the retry can fire. + const retryMessageId = message.messageId?.trim(); + const shouldRetryAttachments = + attachments.length === 0 && + retryMessageId && + baseUrl && + password && + (text.length === 0 || message.eventType === "updated-message"); + if (shouldRetryAttachments) { + try { + await new Promise((resolve) => setTimeout(resolve, 2_000)); + const fetched = await fetchBlueBubblesMessageAttachments(retryMessageId, { + baseUrl, + password, + timeoutMs: 10_000, + allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config), + }); + if (fetched.length > 0) { + logVerbose( + core, + runtime, + `attachment retry found ${fetched.length} attachment(s) for msgId=${message.messageId}`, + ); + attachments = fetched; + } + } catch (err) { + logVerbose( + core, + runtime, + `attachment retry failed for msgId=${message.messageId}: ${String(err)}`, + ); + } + } + + // Recompute placeholder from resolved attachments (may have been updated by retry). + const placeholder = buildMessagePlaceholder({ ...message, attachments }); // Check if text is a tapback pattern (e.g., 'Loved "hello"') and transform to emoji format // For tapbacks, we'll append [[reply_to:N]] at the end; for regular messages, prepend it const tapbackContext = resolveTapbackContext(message); @@ -1019,9 +1066,6 @@ async function processMessageAfterDedupe( return; } - const baseUrl = normalizeSecretInputString(account.config.serverUrl); - const password = normalizeSecretInputString(account.config.password); - if (isGroup && !message.participants?.length && baseUrl && password) { try { const fetchedParticipants = await fetchBlueBubblesParticipantsForInboundMessage({ diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index f34f725dddb..79d74bb3db4 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -249,11 +249,22 @@ export async function handleBlueBubblesWebhookRequest( return true; } const reaction = normalizeWebhookReaction(payload); + // Normalize the webhook message early so the attachment-update detection + // below sees attachments under any supported wrapper format (`payload.data`, + // `payload.message`, `payload.data.message`, JSON-string payloads), not just + // raw `payload.data.attachments`. (#65430, #67510) + const message = reaction ? null : normalizeWebhookMessage(payload, { eventType }); + // BlueBubbles fires `updated-message` when attachments are indexed after the + // initial `new-message` (which may arrive with attachments: []). Let those + // through so the agent can ingest the image. (#65430) + const isAttachmentUpdate = + eventType === "updated-message" && (message?.attachments?.length ?? 0) > 0; if ( (eventType === "updated-message" || eventType === "message-reaction" || eventType === "reaction") && - !reaction + !reaction && + !isAttachmentUpdate ) { res.statusCode = 200; res.end("ok"); @@ -261,12 +272,11 @@ export async function handleBlueBubblesWebhookRequest( logVerbose( firstTarget.core, firstTarget.runtime, - `webhook ignored ${eventType || "event"} without reaction`, + `webhook ignored ${eventType || "event"} (no reaction or attachment update)`, ); } return true; } - const message = reaction ? null : normalizeWebhookMessage(payload); if (!message && !reaction) { res.statusCode = 400; res.end("invalid payload"); diff --git a/extensions/bluebubbles/src/types.ts b/extensions/bluebubbles/src/types.ts index 90a4dbafc0a..307fe2b1809 100644 --- a/extensions/bluebubbles/src/types.ts +++ b/extensions/bluebubbles/src/types.ts @@ -175,10 +175,18 @@ export async function blueBubblesFetchWithTimeout( await release(); } } + // Strip `dispatcher` from init — the SSRF guard may have attached a bundled-undici + // dispatcher that is incompatible with Node 22+'s built-in undici backing globalThis.fetch(). + // Passing it through causes a silent TypeError (invalid onRequestStart method). + // The SSRF validation already completed upstream in fetchWithSsrFGuard before calling + // this function as fetchImpl, so stripping the dispatcher does not weaken security. (#64105) + const { dispatcher: _dispatcher, ...safeInit } = (init ?? {}) as RequestInit & { + dispatcher?: unknown; + }; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { - return await fetch(url, { ...init, signal: controller.signal }); + return await fetch(url, { ...safeInit, signal: controller.signal }); } finally { clearTimeout(timer); } diff --git a/scripts/check-no-raw-channel-fetch.mjs b/scripts/check-no-raw-channel-fetch.mjs index 0d019d8eda4..ff5724257e0 100644 --- a/scripts/check-no-raw-channel-fetch.mjs +++ b/scripts/check-no-raw-channel-fetch.mjs @@ -15,7 +15,7 @@ const sourceRoots = ["src/channels", "src/routing", "src/line", "extensions"]; // code should be rejected and migrated to fetchWithSsrFGuard/shared channel helpers. const allowedRawFetchCallsites = new Set([ bundledPluginCallsite("bluebubbles", "src/test-harness.ts", 130), - bundledPluginCallsite("bluebubbles", "src/types.ts", 181), + bundledPluginCallsite("bluebubbles", "src/types.ts", 189), bundledPluginCallsite("browser", "src/browser/cdp.helpers.ts", 268), bundledPluginCallsite("browser", "src/browser/client-fetch.ts", 192), bundledPluginCallsite("browser", "src/browser/test-fetch.ts", 24),