#[Bug] BlueBubbles inbound image attachments not downloading — v2026.4.5, macOS, SSRF blocking privat

1 messages · Page 1 of 1 (latest)

sterile basalt
#

Environment

  • OpenClaw: 2026.4.5 | macOS 15.4 (arm64, Mac mini)
  • BlueBubbles: connected, Private API disabled
  • Slack: also affected

What's happening
When an image is sent via iMessage (BlueBubbles), the agent receives the message but the attachment is never written to ~/.openclaw/media/inbound/. The agent can't read or describe the image.

Root cause found (two bugs)

Bug 1 (patched locally): downloadBlueBubblesAttachment() is called without allowPrivateNetwork in its opts. Since BlueBubbles runs on a local LAN IP, the SSRF policy silently blocks the download — even though network.dangerouslyAllowPrivateNetwork: true is set in config. Fix: pass allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config) to the download call in channel.runtime.

Bug 2 (still investigating): Even with Bug 1 fixed, attachments still don't arrive. The download loop skips any attachment where attachment.guid is falsy. We believe BlueBubbles webhooks may not include attachment GUIDs without Private API enabled.

Question: Does BlueBubbles include attachment.guid in inbound webhook payloads without Private API? If not, is there an alternative way to get attachment GUIDs via the REST API?

Full details + code analysis: https://github.com/openclaw/openclaw/issues/62248

harsh wyvernBOT
#

To help others find answers, you can mark your question as solved via Right click solution message -> Apps -> ✅ Mark Solution

naive roseBOT
#

Nice root-cause work — both findings line up with what I see in the current plugin code.

Short answer: don’t assume attachment.guid is always present in webhook payloads when Private API is off. I can’t find a BlueBubbles guarantee for that shape, and OpenClaw currently treats missing GUID as “skip.”

What is available as a fallback is REST-side hydration:

  • GET /api/v1/chat/<Chat GUID>/message?... (docs indicate with=attachment support)
  • GET /api/v1/message/<GUID>... (message lookup)
  • then download via GET /api/v1/attachment/<GUID>/download

From BlueBubbles docs/Postman, these read/download endpoints are not marked Private API-only (unlike explicit Private API endpoints), so this should be viable even with Private API disabled.

So for OpenClaw, the robust fix is:

  1. Keep your SSRF/private-network patch (correct).
  2. In inbound processing, if attachments[] exists but guid missing, do a message/chat REST lookup to rehydrate attachment GUIDs.
  3. Download using hydrated GUIDs.
#
  1. Log raw webhook attachment objects once at verbose level to confirm payload variance by server version.

References:

If you want, I can sketch the exact fallback helper flow/pseudocode for monitor-processing.ts next.

sterile basalt
#

Thanks Krill — yes, please sketch the exact fallback helper flow/pseudocode for monitor-processing.ts. We'd like to implement it locally while waiting for the upstream fix.

naive roseBOT
#

Perfect — here’s a concrete monitor-processing.ts fallback shape you can drop in locally.

1) Add a hydration helper (above processMessage)

type BlueBubblesAttachmentLike = {
guid?: string;
mimeType?: string;
transferName?: string;
totalBytes?: number;
width?: number;
height?: number;
originalROWID?: number;
};

function normalizeAttachmentRecord(raw: unknown): BlueBubblesAttachmentLike | null {
#
if (!raw || typeof raw !== "object") return null;
const r = raw as Record<string, unknown>;
const s = (v: unknown) => (typeof v === "string" && v.trim() ? v.trim() : undefined);
const n = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : undefined);

return {
guid: s(r.guid),
mimeType: s(r.mimeType) ?? s(r.mime_type),
transferName: s(r.transferName) ?? s(r.transfer_name) ?? s(r.filename) ?? s(r.name),
totalBytes: n(r.totalBytes) ?? n(r.total_bytes) ?? n(r.byteCount) ?? n(r.bytes),
width: n(r.width),
height: n(r.height),
originalROWID: n(r.originalROWID) ?? n(r.rowid),
};
}
#

function attachmentKey(a: BlueBubblesAttachmentLike, index: number): string {
return [
a.guid ?? "",
a.transferName ?? "",
a.totalBytes ?? "",
a.mimeType ?? "",
String(index),
].join("|");
}

function extractAttachmentsFromMessageLike(messageLike: unknown): BlueBubblesAttachmentLike[] {
if (!messageLike || typeof messageLike !== "object") return [];
const r = messageLike as Record<string, unknown>;
const arr = Array.isArray(r.attachments) ? r.attachments : [];
#
const out: BlueBubblesAttachmentLike[] = [];
for (const x of arr) {
const n = normalizeAttachmentRecord(x);
if (n) out.push(n);
}
return out;
}

async function fetchJsonWithBlueBubblesAuth(params: {
baseUrl: string;
password: string;
path: string;
timeoutMs?: number;
allowPrivateNetwork?: boolean;
}): Promise<unknown | null> {
#
const url = buildBlueBubblesApiUrl({
baseUrl: params.baseUrl,
path: params.path,
password: params.password,
});

const res = await blueBubblesFetchWithTimeout(
url,
{ method: "GET" },
params.timeoutMs ?? 10_000,
blueBubblesPolicy(params.allowPrivateNetwork),
);

if (!res.ok) return null;
return await res.json().catch(() => null);
#
}

2) Message-level fallback resolver

async function hydrateMissingAttachmentGuids(params: {
webhookAttachments: BlueBubblesAttachmentLike[];
messageId?: string;
chatGuid?: string;
chatIdentifier?: string;
chatId?: number;
baseUrl: string;
#
password: string;
allowPrivateNetwork?: boolean;
timeoutMs?: number;
core: BlueBubblesCoreRuntime;
runtime: BlueBubblesRuntimeEnv;
}): Promise<BlueBubblesAttachmentLike[]> {
const initial = params.webhookAttachments;
if (initial.length === 0) return initial;

const hasAnyGuid = initial.some((a) => Boolean(a.guid));
if (hasAnyGuid) return initial; // no fallback needed

let hydrated: BlueBubblesAttachmentLike[] = [];

// Attempt A: direct message lookup by message GUID
#
if (params.messageId) {
const payload = await fetchJsonWithBlueBubblesAuth({
baseUrl: params.baseUrl,
password: params.password,
// try "attachments" and singular "attachment" to be tolerant
path: `/api/v1/message/${encodeURIComponent(params.messageId)}?with=attachments,attachment,chats,participants`,
timeoutMs: params.timeoutMs,
allowPrivateNetwork: params.allowPrivateNetwork,
});

const data =
payload && typeof payload === "object" && "data" in (payload as Record<string, unknown>)
? (payload as Record<string, unknown>).data
: payload;
#
// data may be object or array depending on server/version
if (Array.isArray(data)) {
for (const item of data) hydrated.push(...extractAttachmentsFromMessageLike(item));
} else {
hydrated.push(...extractAttachmentsFromMessageLike(data));
}
}

// Attempt B: chat message scan if still no GUIDs
if (!hydrated.some((a) => a.guid)) {
const chatToken =
params.chatGuid ??
params.chatIdentifier ??
(typeof params.chatId === "number" ? String(params.chatId) : undefined);
#
if (chatToken) {
const payload = await fetchJsonWithBlueBubblesAuth({
baseUrl: params.baseUrl,
password: params.password,
path: `/api/v1/chat/${encodeURIComponent(chatToken)}/message?limit=25&with=attachment`,
timeoutMs: params.timeoutMs,
allowPrivateNetwork: params.allowPrivateNetwork,
});

const data =
payload && typeof payload === "object" && "data" in (payload as Record<string, unknown>)
? (payload as Record<string, unknown>).data
: payload;

const rows = Array.isArray(data) ? data : [];
#
// prefer exact messageId match
const matched =
(params.messageId &&
rows.find((r) => {
const rr = (r && typeof r === "object" ? (r as Record<string, unknown>) : null);
return typeof rr?.guid === "string" && rr.guid.trim() === params.messageId;
})) ||
rows[0];

hydrated = extractAttachmentsFromMessageLike(matched);
}
}

// Merge webhook + hydrated, prefer hydrated GUID when available
const merged = new Map<string, BlueBubblesAttachmentLike>();
#
initial.forEach((a, i) => merged.set(`w:${attachmentKey(a, i)}`, a));
hydrated.forEach((a, i) => merged.set(`h:${attachmentKey(a, i)}`, a));

const out = Array.from(merged.values());
const finalHasGuid = out.some((a) => Boolean(a.guid));

if (!finalHasGuid) {
logVerbose(
params.core,
params.runtime,
`attachment guid hydration failed messageId=${params.messageId ?? ""} chatGuid=${params.chatGuid ?? ""}`,
);
} else {
logVerbose(
params.core,
#
params.runtime,
`attachment guid hydration success messageId=${params.messageId ?? ""} hydrated=${hydrated.length}`,
);
}

return out;
}

3) Wire it into existing attachment download block

Replace:

#
for (const attachment of attachments) {
if (!attachment.guid) continue;
...
}

with:

let resolvedAttachments = attachments as BlueBubblesAttachmentLike[];

if (attachments.length > 0 && baseUrl && password) {
const missingGuidCount = attachments.filter((a) => !a.guid).length;
if (missingGuidCount > 0) {
resolvedAttachments = await hydrateMissingAttachmentGuids({
#
webhookAttachments: attachments,
messageId: message.messageId,
chatGuid: message.chatGuid,
chatIdentifier: message.chatIdentifier,
chatId: message.chatId,
baseUrl,
password,
allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config),
core,
runtime,
});
}
}

for (const attachment of resolvedAttachments) {
#
if (!attachment.guid) {
logVerbose(core, runtime, `attachment skipped (no guid after hydration) name=${attachment.transferName ?? ""}`);
continue;
}

if (attachment.totalBytes && attachment.totalBytes > maxBytes) {
logVerbose(core, runtime, `attachment too large guid=${attachment.guid} bytes=${attachment.totalBytes}`);
continue;
}

const downloaded = await downloadBlueBubblesAttachment(attachment, {
cfg: config,
accountId: account.accountId,
maxBytes,
});
#

// existing saveMediaBuffer code unchanged...
}

4) Practical notes

  • Keep fallback conditional (only when GUID is missing) so you don’t add latency for normal cases.
  • Keep this behind verbose logs first; once stable, reduce log noise.
  • If you still get zero GUIDs from both webhook + REST, that’s likely BlueBubbles-side behavior/version-specific and worth escalating upstream in parallel.

If you want, I can also sketch a minimal diff-style patch plan by exact insertion points (line neighborhoods) against current monitor-processing.ts.

sterile basalt
#

Update from further testing — Bug 2 is more specific than we thought.

BlueBubbles webhook payloads for inbound images DO include an attachments[] array with a GUID — so the GUID isn't missing. But the GUID is stale/wrong. It points to an old cached attachment (low originalROWID, e.g. 54) from the local iMessage DB, not the current inbound message. The message GUID field is also empty.

Result: the agent downloads and describes a completely different old image.

Conclusion: without Private API, BlueBubbles can't reliably identify which attachment belongs to the current inbound message. Looks like a BlueBubbles-side limitation.

Full details added to the GitHub issue: https://github.com/openclaw/openclaw/issues/62248

Is there any way to correlate the correct attachment to the inbound message without Private API? E.g. by timestamp or sender?

naive roseBOT
#

Yep — with that new finding, your conclusion is solid:

  • Deterministic mapping without Private API: probably no.
  • Best-effort correlation: yes, using POST /api/v1/message/query (non-private API) with time/chat/sender filters.

The key change: do not trust webhook attachments[].guid when message.guid is empty (or when you detect stale rowid behavior).

Practical fallback strategy

  1. On inbound webhook, capture:
  • chatGuid (or chatIdentifier)
  • senderId
  • webhook receive timestamp (ms)
  • text (if any)
  1. Query recent messages in that chat:
#
POST /api/v1/message/query?password=...
{
"limit": 25,
"offset": 0,
"chatGuid": "<chatGuid>",
"with": ["attachment", "handle", "chat"],
"after": <(webhookTsSec - 20)>,
"before": <(webhookTsSec + 20)>,
"sort": "DESC",
"where": [
{ "statement": "message.is_from_me = :me", "args": { "me": false } }
]
}
  1. Score candidates:
#
  • sender handle match (handle.address vs normalized senderId)
  • closest dateCreated to webhook timestamp
  • text match (if text exists)
  • has attachments
  1. Only accept if unique/high-confidence (e.g. top score clearly above runner-up).
  • If ambiguous: skip media (fail closed) instead of risking wrong old image.
  1. Use attachment GUIDs from the matched message-query result (ignore webhook GUID).

Important guardrail

Given your stale GUID symptom, safest policy is:

  • If message.messageId is empty and match confidence is low/ambiguous → no image download.