diff --git a/extensions/bluebubbles/src/attachments.ts b/extensions/bluebubbles/src/attachments.ts index 83124b61b84..deea76094ba 100644 --- a/extensions/bluebubbles/src/attachments.ts +++ b/extensions/bluebubbles/src/attachments.ts @@ -95,6 +95,79 @@ 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, + }); + const policy = opts.allowPrivateNetwork ? { allowPrivateNetwork: true } : {}; + 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; + const rawAttachments = data?.attachments; + if (!Array.isArray(rawAttachments)) { + return []; + } + const out: BlueBubblesAttachment[] = []; + for (const entry of rawAttachments) { + if (!entry || typeof entry !== "object") { + continue; + } + const record = entry as Record; + const guid = typeof record.guid === "string" ? record.guid.trim() : undefined; + if (!guid) { + continue; + } + out.push({ + guid, + mimeType: + typeof record.mimeType === "string" + ? record.mimeType + : typeof record.mime_type === "string" + ? record.mime_type + : undefined, + transferName: + typeof record.transferName === "string" + ? record.transferName + : typeof record.transfer_name === "string" + ? record.transfer_name + : undefined, + totalBytes: + typeof record.totalBytes === "number" + ? record.totalBytes + : typeof record.total_bytes === "number" + ? record.total_bytes + : undefined, + }); + } + return out; +} + 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..4cd1bc6d194 100644 --- a/extensions/bluebubbles/src/monitor-normalize.ts +++ b/extensions/bluebubbles/src/monitor-normalize.ts @@ -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..dd01a37cfed 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"; @@ -1065,14 +1068,52 @@ async function processMessageAfterDedupe( ? account.config.mediaMaxMb * 1024 * 1024 : 8 * 1024 * 1024; + // 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) + let resolvedAttachments = attachments; + const shouldRetryAttachments = + resolvedAttachments.length === 0 && + message.messageId && + baseUrl && + password && + (text.length === 0 || message.eventType === "updated-message"); + if (shouldRetryAttachments) { + try { + await new Promise((resolve) => setTimeout(resolve, 2_000)); + const fetched = await fetchBlueBubblesMessageAttachments(message.messageId!, { + 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}`, + ); + resolvedAttachments = fetched; + } + } catch (err) { + logVerbose( + core, + runtime, + `attachment retry failed for msgId=${message.messageId}: ${String(err)}`, + ); + } + } + let mediaUrls: string[] = []; let mediaPaths: string[] = []; let mediaTypes: string[] = []; - if (attachments.length > 0) { + if (resolvedAttachments.length > 0) { if (!baseUrl || !password) { logVerbose(core, runtime, "attachment download skipped (missing serverUrl/password)"); } else { - for (const attachment of attachments) { + for (const attachment of resolvedAttachments) { if (!attachment.guid) { continue; } diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index f34f725dddb..2c7d0c908cf 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -249,11 +249,21 @@ export async function handleBlueBubblesWebhookRequest( return true; } const reaction = normalizeWebhookReaction(payload); + // 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 dataRecord = asRecord(asRecord(payload)?.data); + const dataAttachments = dataRecord?.attachments; + const isAttachmentUpdate = + eventType === "updated-message" && + Array.isArray(dataAttachments) && + dataAttachments.length > 0; if ( (eventType === "updated-message" || eventType === "message-reaction" || eventType === "reaction") && - !reaction + !reaction && + !isAttachmentUpdate ) { res.statusCode = 200; res.end("ok"); @@ -266,7 +276,7 @@ export async function handleBlueBubblesWebhookRequest( } return true; } - const message = reaction ? null : normalizeWebhookMessage(payload); + const message = reaction ? null : normalizeWebhookMessage(payload, { eventType }); 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..d5c777fc935 100644 --- a/extensions/bluebubbles/src/types.ts +++ b/extensions/bluebubbles/src/types.ts @@ -175,10 +175,16 @@ 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). (#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); }