11import crypto from "node:crypto" ;
22import path from "node:path" ;
3- import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime" ;
4- import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime" ;
53import {
64 normalizeLowercaseStringOrEmpty ,
75 normalizeOptionalLowercaseString ,
86 normalizeOptionalString ,
97} from "openclaw/plugin-sdk/text-runtime" ;
108import { resolveBlueBubblesServerAccount } from "./account-resolve.js" ;
11- import { extractAttachments } from "./monitor-normalize.js" ;
12- import { assertMultipartActionOk , postMultipartFormData } from "./multipart.js" ;
9+ import {
10+ createBlueBubblesClient ,
11+ createBlueBubblesClientFromParts ,
12+ type BlueBubblesClient ,
13+ } from "./client.js" ;
14+ import { assertMultipartActionOk } from "./multipart.js" ;
1315import {
1416 fetchBlueBubblesServerInfo ,
1517 getCachedBlueBubblesPrivateApiStatus ,
1618 isBlueBubblesPrivateApiStatusEnabled ,
1719} from "./probe.js" ;
18- import { resolveRequestUrl } from "./request-url.js" ;
1920import type { OpenClawConfig } from "./runtime-api.js" ;
20- import { getBlueBubblesRuntime , warnBlueBubbles } from "./runtime.js" ;
21+ import { warnBlueBubbles } from "./runtime.js" ;
2122import { extractBlueBubblesMessageId , resolveBlueBubblesSendTarget } from "./send-helpers.js" ;
2223import { createChatForHandle , resolveChatGuidForTarget } from "./send.js" ;
23- import {
24- blueBubblesFetchWithTimeout ,
25- buildBlueBubblesApiUrl ,
26- type BlueBubblesAttachment ,
27- type SsrFPolicy ,
28- } from "./types.js" ;
29-
30- function blueBubblesPolicy ( allowPrivateNetwork : boolean | undefined ) : SsrFPolicy | undefined {
31- // Pass `undefined` (not `{}`) for the non-private case so the non-SSRF fallback path
32- // is used. An empty `{}` policy routes through the SSRF guard, which blocks the
33- // localhost BB deployments that are the most common self-hosted setup. The opt-in
34- // private-network branch keeps the explicit policy. (#64105, #67510)
35- return allowPrivateNetwork ? { allowPrivateNetwork : true } : undefined ;
36- }
24+ import { type BlueBubblesAttachment } from "./types.js" ;
3725
3826export type BlueBubblesAttachmentOpts = {
3927 serverUrl ?: string ;
@@ -43,7 +31,6 @@ export type BlueBubblesAttachmentOpts = {
4331 cfg ?: OpenClawConfig ;
4432} ;
4533
46- const DEFAULT_ATTACHMENT_MAX_BYTES = 8 * 1024 * 1024 ;
4734const AUDIO_MIME_MP3 = new Set ( [ "audio/mpeg" , "audio/mp3" ] ) ;
4835const AUDIO_MIME_CAF = new Set ( [ "audio/x-caf" , "audio/caf" ] ) ;
4936
@@ -75,29 +62,12 @@ function resolveVoiceInfo(filename: string, contentType?: string) {
7562 return { isAudio, isMp3, isCaf } ;
7663}
7764
78- function resolveAccount ( params : BlueBubblesAttachmentOpts ) {
79- return resolveBlueBubblesServerAccount ( params ) ;
80- }
81-
82- function safeExtractHostname ( url : string ) : string | undefined {
83- try {
84- const hostname = new URL ( url ) . hostname . trim ( ) ;
85- return hostname || undefined ;
86- } catch {
87- return undefined ;
88- }
65+ function clientFromOpts ( params : BlueBubblesAttachmentOpts ) : BlueBubblesClient {
66+ return createBlueBubblesClient ( params ) ;
8967}
9068
91- type MediaFetchErrorCode = "max_bytes" | "http_error" | "fetch_failed" ;
92-
93- function readMediaFetchErrorCode ( error : unknown ) : MediaFetchErrorCode | undefined {
94- if ( ! error || typeof error !== "object" ) {
95- return undefined ;
96- }
97- const code = ( error as { code ?: unknown } ) . code ;
98- return code === "max_bytes" || code === "http_error" || code === "fetch_failed"
99- ? code
100- : undefined ;
69+ function resolveAccount ( params : BlueBubblesAttachmentOpts ) {
70+ return resolveBlueBubblesServerAccount ( params ) ;
10171}
10272
10373/**
@@ -117,82 +87,28 @@ export async function fetchBlueBubblesMessageAttachments(
11787 allowPrivateNetwork ?: boolean ;
11888 } ,
11989) : Promise < BlueBubblesAttachment [ ] > {
120- const url = buildBlueBubblesApiUrl ( {
90+ const client = createBlueBubblesClientFromParts ( {
12191 baseUrl : opts . baseUrl ,
122- path : `/api/v1/message/${ encodeURIComponent ( messageGuid ) } ` ,
12392 password : opts . password ,
93+ allowPrivateNetwork : opts . allowPrivateNetwork === true ,
94+ timeoutMs : opts . timeoutMs ,
12495 } ) ;
125- // Pass undefined (not {}) when private network is not opted-in so the
126- // non-SSRF fallback path is used — an empty {} triggers the SSRF-guarded
127- // path which blocks localhost BB servers by default. (#64105)
128- const policy : SsrFPolicy | undefined = opts . allowPrivateNetwork
129- ? { allowPrivateNetwork : true }
130- : undefined ;
131- const response = await blueBubblesFetchWithTimeout (
132- url ,
133- { method : "GET" } ,
134- opts . timeoutMs ,
135- policy ,
136- ) ;
137- if ( ! response . ok ) {
138- return [ ] ;
139- }
140- const json = ( await response . json ( ) ) as Record < string , unknown > ;
141- const data = json . data as Record < string , unknown > | undefined ;
142- if ( ! data ) {
143- return [ ] ;
144- }
145- return extractAttachments ( data ) ;
96+ return await client . getMessageAttachments ( { messageGuid, timeoutMs : opts . timeoutMs } ) ;
14697}
14798
14899export async function downloadBlueBubblesAttachment (
149100 attachment : BlueBubblesAttachment ,
150101 opts : BlueBubblesAttachmentOpts & { maxBytes ?: number } = { } ,
151102) : Promise < { buffer : Uint8Array ; contentType ?: string } > {
152- const guid = attachment . guid ?. trim ( ) ;
153- if ( ! guid ) {
154- throw new Error ( "BlueBubbles attachment guid is required" ) ;
155- }
156- const { baseUrl, password, allowPrivateNetwork, allowPrivateNetworkConfig } =
157- resolveAccount ( opts ) ;
158- const url = buildBlueBubblesApiUrl ( {
159- baseUrl,
160- path : `/api/v1/attachment/${ encodeURIComponent ( guid ) } /download` ,
161- password,
103+ const client = clientFromOpts ( opts ) ;
104+ // client.downloadAttachment threads this.ssrfPolicy to BOTH fetchRemoteMedia
105+ // and the fetchImpl callback — closing the gap in #34749 where the legacy
106+ // helper silently omitted the policy on the callback path.
107+ return await client . downloadAttachment ( {
108+ attachment,
109+ maxBytes : opts . maxBytes ,
110+ timeoutMs : opts . timeoutMs ,
162111 } ) ;
163- const maxBytes = typeof opts . maxBytes === "number" ? opts . maxBytes : DEFAULT_ATTACHMENT_MAX_BYTES ;
164- const trustedHostname = safeExtractHostname ( baseUrl ) ;
165- const trustedHostnameIsPrivate = trustedHostname ? isBlockedHostnameOrIp ( trustedHostname ) : false ;
166- try {
167- const fetched = await getBlueBubblesRuntime ( ) . channel . media . fetchRemoteMedia ( {
168- url,
169- filePathHint : attachment . transferName ?? attachment . guid ?? "attachment" ,
170- maxBytes,
171- ssrfPolicy : allowPrivateNetwork
172- ? { allowPrivateNetwork : true }
173- : trustedHostname && ( allowPrivateNetworkConfig !== false || ! trustedHostnameIsPrivate )
174- ? { allowedHostnames : [ trustedHostname ] }
175- : undefined ,
176- fetchImpl : async ( input , init ) =>
177- await blueBubblesFetchWithTimeout (
178- resolveRequestUrl ( input ) ,
179- { ...init , method : init ?. method ?? "GET" } ,
180- opts . timeoutMs ,
181- ) ,
182- } ) ;
183- return {
184- buffer : new Uint8Array ( fetched . buffer ) ,
185- contentType : fetched . contentType ?? attachment . mimeType ?? undefined ,
186- } ;
187- } catch ( error ) {
188- if ( readMediaFetchErrorCode ( error ) === "max_bytes" ) {
189- throw new Error ( `BlueBubbles attachment too large (limit ${ maxBytes } bytes)` , {
190- cause : error ,
191- } ) ;
192- }
193- const text = formatErrorMessage ( error ) ;
194- throw new Error ( `BlueBubbles attachment download failed: ${ text } ` , { cause : error } ) ;
195- }
196112}
197113
198114export type SendBlueBubblesAttachmentResult = {
@@ -221,7 +137,13 @@ export async function sendBlueBubblesAttachment(params: {
221137 const fallbackName = wantsVoice ? "Audio Message" : "attachment" ;
222138 filename = sanitizeFilename ( filename , fallbackName ) ;
223139 contentType = normalizeOptionalString ( contentType ) ;
140+ // Resolve account tuple for helpers that still need baseUrl/password
141+ // (createChatForHandle, resolveChatGuidForTarget, fetchBlueBubblesServerInfo).
142+ // These migrate to the client in subsequent passes. For this callsite, the
143+ // client owns the actual attachment POST; the resolved tuple stays alongside
144+ // so chat-guid resolution and Private API probe continue to work.
224145 const { baseUrl, password, accountId, allowPrivateNetwork } = resolveAccount ( opts ) ;
146+ const client = createBlueBubblesClient ( opts ) ;
225147 let privateApiStatus = getCachedBlueBubblesPrivateApiStatus ( accountId ) ;
226148
227149 // Lazy refresh: when the cache has expired and Private API features are needed,
@@ -302,12 +224,6 @@ export async function sendBlueBubblesAttachment(params: {
302224 }
303225 }
304226
305- const url = buildBlueBubblesApiUrl ( {
306- baseUrl,
307- path : "/api/v1/message/attachment" ,
308- password,
309- } ) ;
310-
311227 // Build FormData with the attachment
312228 const boundary = `----BlueBubblesFormBoundary${ crypto . randomUUID ( ) . replace ( / - / g, "" ) } ` ;
313229 const parts : Uint8Array [ ] = [ ] ;
@@ -365,12 +281,11 @@ export async function sendBlueBubblesAttachment(params: {
365281 // Close the multipart body
366282 parts . push ( encoder . encode ( `--${ boundary } --\r\n` ) ) ;
367283
368- const res = await postMultipartFormData ( {
369- url ,
284+ const res = await client . requestMultipart ( {
285+ path : "/api/v1/message/attachment" ,
370286 boundary,
371287 parts,
372288 timeoutMs : opts . timeoutMs ?? 60_000 , // longer timeout for file uploads
373- ssrfPolicy : blueBubblesPolicy ( allowPrivateNetwork ) ,
374289 } ) ;
375290
376291 await assertMultipartActionOk ( res , "attachment send" ) ;
0 commit comments