mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 12:00:44 +00:00
bluebubbles: consolidate HTTP traffic through typed BlueBubblesClient (#68234)
Merged via squash.
Prepared head SHA: ee72657bc8
Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com>
Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com>
Reviewed-by: @omarshahine
This commit is contained in:
@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Cron/CLI: parse PowerShell-style `--tools` allow-lists the same way as comma-separated input, so `cron add` and `cron edit` no longer persist `exec read write` as one combined tool entry on Windows. (#68858) Thanks @chen-zhang-cs-code.
|
||||
- Browser/user-profile: let existing-session `profile="user"` tool calls auto-route to a connected browser node or use explicit `target="node"`, while still honoring explicit `target="host"` pinning. (#48677)
|
||||
- Discord/slash commands: tolerate partial Discord channel metadata in slash-command and model-picker flows so partial channel objects no longer crash when channel names, topics, or thread parent metadata are unavailable. (#68953) Thanks @dutifulbob.
|
||||
- BlueBubbles: consolidate outbound HTTP through a typed `BlueBubblesClient` that resolves the SSRF policy once at construction so image attachments stop getting blocked on localhost and reactions stop getting blocked on private-IP BB deployments. Fixes #34749 and #59722. (#68234) Thanks @omarshahine.
|
||||
|
||||
## 2026.4.19-beta.2
|
||||
|
||||
|
||||
@@ -341,8 +341,12 @@ describe("downloadBlueBubblesAttachment", () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Default-deny policy via the guard, NOT unguarded fetch. Aisle #68234
|
||||
// flagged the previous `undefined` fallback as a real SSRF bypass because
|
||||
// `blueBubblesFetchWithTimeout` treats `undefined` as "skip the SSRF
|
||||
// guard entirely", exactly when the user asked us to block private nets.
|
||||
const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>;
|
||||
expect(fetchMediaArgs.ssrfPolicy).toBeUndefined();
|
||||
expect(fetchMediaArgs.ssrfPolicy).toEqual({});
|
||||
});
|
||||
|
||||
it("allowlists public serverUrl hostname when allowPrivateNetwork is not set", async () => {
|
||||
|
||||
@@ -1,39 +1,27 @@
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalLowercaseString,
|
||||
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 {
|
||||
createBlueBubblesClient,
|
||||
createBlueBubblesClientFromParts,
|
||||
type BlueBubblesClient,
|
||||
} from "./client.js";
|
||||
import { assertMultipartActionOk } from "./multipart.js";
|
||||
import {
|
||||
fetchBlueBubblesServerInfo,
|
||||
getCachedBlueBubblesPrivateApiStatus,
|
||||
isBlueBubblesPrivateApiStatusEnabled,
|
||||
} from "./probe.js";
|
||||
import { resolveRequestUrl } from "./request-url.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
import { getBlueBubblesRuntime, warnBlueBubbles } from "./runtime.js";
|
||||
import { warnBlueBubbles } from "./runtime.js";
|
||||
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
|
||||
import { createChatForHandle, resolveChatGuidForTarget } from "./send.js";
|
||||
import {
|
||||
blueBubblesFetchWithTimeout,
|
||||
buildBlueBubblesApiUrl,
|
||||
type BlueBubblesAttachment,
|
||||
type SsrFPolicy,
|
||||
} from "./types.js";
|
||||
|
||||
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;
|
||||
}
|
||||
import { type BlueBubblesAttachment } from "./types.js";
|
||||
|
||||
export type BlueBubblesAttachmentOpts = {
|
||||
serverUrl?: string;
|
||||
@@ -43,7 +31,6 @@ export type BlueBubblesAttachmentOpts = {
|
||||
cfg?: OpenClawConfig;
|
||||
};
|
||||
|
||||
const DEFAULT_ATTACHMENT_MAX_BYTES = 8 * 1024 * 1024;
|
||||
const AUDIO_MIME_MP3 = new Set(["audio/mpeg", "audio/mp3"]);
|
||||
const AUDIO_MIME_CAF = new Set(["audio/x-caf", "audio/caf"]);
|
||||
|
||||
@@ -75,31 +62,14 @@ function resolveVoiceInfo(filename: string, contentType?: string) {
|
||||
return { isAudio, isMp3, isCaf };
|
||||
}
|
||||
|
||||
function clientFromOpts(params: BlueBubblesAttachmentOpts): BlueBubblesClient {
|
||||
return createBlueBubblesClient(params);
|
||||
}
|
||||
|
||||
function resolveAccount(params: BlueBubblesAttachmentOpts) {
|
||||
return resolveBlueBubblesServerAccount(params);
|
||||
}
|
||||
|
||||
function safeExtractHostname(url: string): string | undefined {
|
||||
try {
|
||||
const hostname = new URL(url).hostname.trim();
|
||||
return hostname || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
type MediaFetchErrorCode = "max_bytes" | "http_error" | "fetch_failed";
|
||||
|
||||
function readMediaFetchErrorCode(error: unknown): MediaFetchErrorCode | undefined {
|
||||
if (!error || typeof error !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const code = (error as { code?: unknown }).code;
|
||||
return code === "max_bytes" || code === "http_error" || code === "fetch_failed"
|
||||
? code
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch attachment metadata for a message from the BlueBubbles API.
|
||||
*
|
||||
@@ -117,82 +87,28 @@ export async function fetchBlueBubblesMessageAttachments(
|
||||
allowPrivateNetwork?: boolean;
|
||||
},
|
||||
): Promise<BlueBubblesAttachment[]> {
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
const client = createBlueBubblesClientFromParts({
|
||||
baseUrl: opts.baseUrl,
|
||||
path: `/api/v1/message/${encodeURIComponent(messageGuid)}`,
|
||||
password: opts.password,
|
||||
allowPrivateNetwork: opts.allowPrivateNetwork === true,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
// 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<string, unknown>;
|
||||
const data = json.data as Record<string, unknown> | undefined;
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
return extractAttachments(data);
|
||||
return await client.getMessageAttachments({ messageGuid, timeoutMs: opts.timeoutMs });
|
||||
}
|
||||
|
||||
export async function downloadBlueBubblesAttachment(
|
||||
attachment: BlueBubblesAttachment,
|
||||
opts: BlueBubblesAttachmentOpts & { maxBytes?: number } = {},
|
||||
): Promise<{ buffer: Uint8Array; contentType?: string }> {
|
||||
const guid = attachment.guid?.trim();
|
||||
if (!guid) {
|
||||
throw new Error("BlueBubbles attachment guid is required");
|
||||
}
|
||||
const { baseUrl, password, allowPrivateNetwork, allowPrivateNetworkConfig } =
|
||||
resolveAccount(opts);
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
baseUrl,
|
||||
path: `/api/v1/attachment/${encodeURIComponent(guid)}/download`,
|
||||
password,
|
||||
const client = clientFromOpts(opts);
|
||||
// client.downloadAttachment threads this.ssrfPolicy to BOTH fetchRemoteMedia
|
||||
// and the fetchImpl callback — closing the gap in #34749 where the legacy
|
||||
// helper silently omitted the policy on the callback path.
|
||||
return await client.downloadAttachment({
|
||||
attachment,
|
||||
maxBytes: opts.maxBytes,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
const maxBytes = typeof opts.maxBytes === "number" ? opts.maxBytes : DEFAULT_ATTACHMENT_MAX_BYTES;
|
||||
const trustedHostname = safeExtractHostname(baseUrl);
|
||||
const trustedHostnameIsPrivate = trustedHostname ? isBlockedHostnameOrIp(trustedHostname) : false;
|
||||
try {
|
||||
const fetched = await getBlueBubblesRuntime().channel.media.fetchRemoteMedia({
|
||||
url,
|
||||
filePathHint: attachment.transferName ?? attachment.guid ?? "attachment",
|
||||
maxBytes,
|
||||
ssrfPolicy: allowPrivateNetwork
|
||||
? { allowPrivateNetwork: true }
|
||||
: trustedHostname && (allowPrivateNetworkConfig !== false || !trustedHostnameIsPrivate)
|
||||
? { allowedHostnames: [trustedHostname] }
|
||||
: undefined,
|
||||
fetchImpl: async (input, init) =>
|
||||
await blueBubblesFetchWithTimeout(
|
||||
resolveRequestUrl(input),
|
||||
{ ...init, method: init?.method ?? "GET" },
|
||||
opts.timeoutMs,
|
||||
),
|
||||
});
|
||||
return {
|
||||
buffer: new Uint8Array(fetched.buffer),
|
||||
contentType: fetched.contentType ?? attachment.mimeType ?? undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
if (readMediaFetchErrorCode(error) === "max_bytes") {
|
||||
throw new Error(`BlueBubbles attachment too large (limit ${maxBytes} bytes)`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
const text = formatErrorMessage(error);
|
||||
throw new Error(`BlueBubbles attachment download failed: ${text}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
export type SendBlueBubblesAttachmentResult = {
|
||||
@@ -221,7 +137,13 @@ export async function sendBlueBubblesAttachment(params: {
|
||||
const fallbackName = wantsVoice ? "Audio Message" : "attachment";
|
||||
filename = sanitizeFilename(filename, fallbackName);
|
||||
contentType = normalizeOptionalString(contentType);
|
||||
// Resolve account tuple for helpers that still need baseUrl/password
|
||||
// (createChatForHandle, resolveChatGuidForTarget, fetchBlueBubblesServerInfo).
|
||||
// These migrate to the client in subsequent passes. For this callsite, the
|
||||
// client owns the actual attachment POST; the resolved tuple stays alongside
|
||||
// so chat-guid resolution and Private API probe continue to work.
|
||||
const { baseUrl, password, accountId, allowPrivateNetwork } = resolveAccount(opts);
|
||||
const client = createBlueBubblesClient(opts);
|
||||
let privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId);
|
||||
|
||||
// Lazy refresh: when the cache has expired and Private API features are needed,
|
||||
@@ -302,12 +224,6 @@ export async function sendBlueBubblesAttachment(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
baseUrl,
|
||||
path: "/api/v1/message/attachment",
|
||||
password,
|
||||
});
|
||||
|
||||
// Build FormData with the attachment
|
||||
const boundary = `----BlueBubblesFormBoundary${crypto.randomUUID().replace(/-/g, "")}`;
|
||||
const parts: Uint8Array[] = [];
|
||||
@@ -365,12 +281,11 @@ export async function sendBlueBubblesAttachment(params: {
|
||||
// Close the multipart body
|
||||
parts.push(encoder.encode(`--${boundary}--\r\n`));
|
||||
|
||||
const res = await postMultipartFormData({
|
||||
url,
|
||||
const res = await client.requestMultipart({
|
||||
path: "/api/v1/message/attachment",
|
||||
boundary,
|
||||
parts,
|
||||
timeoutMs: opts.timeoutMs ?? 60_000, // longer timeout for file uploads
|
||||
ssrfPolicy: blueBubblesPolicy(allowPrivateNetwork),
|
||||
});
|
||||
|
||||
await assertMultipartActionOk(res, "attachment send");
|
||||
|
||||
@@ -4,11 +4,11 @@ import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plug
|
||||
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
||||
import { createBlueBubblesClientFromParts } from "./client.js";
|
||||
import { warmupBlueBubblesInboundDedupe } from "./inbound-dedupe.js";
|
||||
import { asRecord, normalizeWebhookMessage } from "./monitor-normalize.js";
|
||||
import { processMessage } from "./monitor-processing.js";
|
||||
import type { WebhookTarget } from "./monitor-shared.js";
|
||||
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
||||
|
||||
// When the gateway is down, restarting, or wedged, inbound webhook POSTs from
|
||||
// BB Server fail with ECONNRESET/ECONNREFUSED. BB's WebhookService does not
|
||||
@@ -236,32 +236,27 @@ export async function fetchBlueBubblesMessagesSince(
|
||||
limit: number,
|
||||
opts: FetchOpts,
|
||||
): Promise<BlueBubblesCatchupFetchResult> {
|
||||
const ssrfPolicy = opts.allowPrivateNetwork ? { allowPrivateNetwork: true } : {};
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
const client = createBlueBubblesClientFromParts({
|
||||
baseUrl: opts.baseUrl,
|
||||
path: "/api/v1/message/query",
|
||||
password: opts.password,
|
||||
});
|
||||
const body = JSON.stringify({
|
||||
limit,
|
||||
sort: "ASC",
|
||||
after: sinceMs,
|
||||
// `with` mirrors what bb-catchup.sh uses and what the normal webhook
|
||||
// payload carries, so normalizeWebhookMessage has the same fields to
|
||||
// read during replay as it does on live dispatch.
|
||||
with: ["chat", "chat.participants", "attachment"],
|
||||
allowPrivateNetwork: opts.allowPrivateNetwork,
|
||||
timeoutMs: opts.timeoutMs ?? FETCH_TIMEOUT_MS,
|
||||
});
|
||||
try {
|
||||
const res = await blueBubblesFetchWithTimeout(
|
||||
url,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
const res = await client.request({
|
||||
method: "POST",
|
||||
path: "/api/v1/message/query",
|
||||
body: {
|
||||
limit,
|
||||
sort: "ASC",
|
||||
after: sinceMs,
|
||||
// `with` mirrors what bb-catchup.sh uses and what the normal webhook
|
||||
// payload carries, so normalizeWebhookMessage has the same fields to
|
||||
// read during replay as it does on live dispatch.
|
||||
with: ["chat", "chat.participants", "attachment"],
|
||||
},
|
||||
opts.timeoutMs ?? FETCH_TIMEOUT_MS,
|
||||
ssrfPolicy,
|
||||
);
|
||||
timeoutMs: opts.timeoutMs ?? FETCH_TIMEOUT_MS,
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { resolved: false, messages: [] };
|
||||
}
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
||||
import { assertMultipartActionOk, postMultipartFormData } from "./multipart.js";
|
||||
import { createBlueBubblesClient, type BlueBubblesClient } from "./client.js";
|
||||
import { assertMultipartActionOk } from "./multipart.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
||||
|
||||
function blueBubblesPolicy(allowPrivateNetwork: boolean): SsrFPolicy {
|
||||
return allowPrivateNetwork ? { allowPrivateNetwork: true } : {};
|
||||
}
|
||||
|
||||
export type BlueBubblesChatOpts = {
|
||||
serverUrl?: string;
|
||||
@@ -19,8 +13,8 @@ export type BlueBubblesChatOpts = {
|
||||
cfg?: OpenClawConfig;
|
||||
};
|
||||
|
||||
function resolveAccount(params: BlueBubblesChatOpts) {
|
||||
return resolveBlueBubblesServerAccount(params);
|
||||
function clientFromOpts(params: BlueBubblesChatOpts): BlueBubblesClient {
|
||||
return createBlueBubblesClient(params);
|
||||
}
|
||||
|
||||
function assertPrivateApiEnabled(accountId: string, feature: string): void {
|
||||
@@ -46,21 +40,15 @@ async function sendBlueBubblesChatEndpointRequest(params: {
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
const { baseUrl, password, accountId, allowPrivateNetwork } = resolveAccount(params.opts);
|
||||
if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
|
||||
const client = clientFromOpts(params.opts);
|
||||
if (getCachedBlueBubblesPrivateApiStatus(client.accountId) === false) {
|
||||
return;
|
||||
}
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
baseUrl,
|
||||
const res = await client.request({
|
||||
method: params.method,
|
||||
path: `/api/v1/chat/${encodeURIComponent(trimmed)}/${params.endpoint}`,
|
||||
password,
|
||||
timeoutMs: params.opts.timeoutMs,
|
||||
});
|
||||
const res = await blueBubblesFetchWithTimeout(
|
||||
url,
|
||||
{ method: params.method },
|
||||
params.opts.timeoutMs,
|
||||
blueBubblesPolicy(allowPrivateNetwork),
|
||||
);
|
||||
await assertMultipartActionOk(res, params.action);
|
||||
}
|
||||
|
||||
@@ -72,26 +60,14 @@ async function sendPrivateApiJsonRequest(params: {
|
||||
method: "POST" | "PUT" | "DELETE";
|
||||
payload?: unknown;
|
||||
}): Promise<void> {
|
||||
const { baseUrl, password, accountId, allowPrivateNetwork } = resolveAccount(params.opts);
|
||||
assertPrivateApiEnabled(accountId, params.feature);
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
baseUrl,
|
||||
const client = clientFromOpts(params.opts);
|
||||
assertPrivateApiEnabled(client.accountId, params.feature);
|
||||
const res = await client.request({
|
||||
method: params.method,
|
||||
path: params.path,
|
||||
password,
|
||||
body: params.payload,
|
||||
timeoutMs: params.opts.timeoutMs,
|
||||
});
|
||||
|
||||
const request: RequestInit = { method: params.method };
|
||||
if (params.payload !== undefined) {
|
||||
request.headers = { "Content-Type": "application/json" };
|
||||
request.body = JSON.stringify(params.payload);
|
||||
}
|
||||
|
||||
const res = await blueBubblesFetchWithTimeout(
|
||||
url,
|
||||
request,
|
||||
params.opts.timeoutMs,
|
||||
blueBubblesPolicy(allowPrivateNetwork),
|
||||
);
|
||||
await assertMultipartActionOk(res, params.action);
|
||||
}
|
||||
|
||||
@@ -293,13 +269,8 @@ export async function setGroupIconBlueBubbles(
|
||||
throw new Error("BlueBubbles setGroupIcon requires image buffer");
|
||||
}
|
||||
|
||||
const { baseUrl, password, accountId, allowPrivateNetwork } = resolveAccount(opts);
|
||||
assertPrivateApiEnabled(accountId, "setGroupIcon");
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
baseUrl,
|
||||
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/icon`,
|
||||
password,
|
||||
});
|
||||
const client = clientFromOpts(opts);
|
||||
assertPrivateApiEnabled(client.accountId, "setGroupIcon");
|
||||
|
||||
// Build multipart form-data
|
||||
const boundary = `----BlueBubblesFormBoundary${crypto.randomUUID().replace(/-/g, "")}`;
|
||||
@@ -323,12 +294,11 @@ export async function setGroupIconBlueBubbles(
|
||||
// Close multipart body
|
||||
parts.push(encoder.encode(`--${boundary}--\r\n`));
|
||||
|
||||
const res = await postMultipartFormData({
|
||||
url,
|
||||
const res = await client.requestMultipart({
|
||||
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/icon`,
|
||||
boundary,
|
||||
parts,
|
||||
timeoutMs: opts.timeoutMs ?? 60_000, // longer timeout for file uploads
|
||||
ssrfPolicy: blueBubblesPolicy(allowPrivateNetwork),
|
||||
});
|
||||
|
||||
await assertMultipartActionOk(res, "setGroupIcon");
|
||||
|
||||
613
extensions/bluebubbles/src/client.test.ts
Normal file
613
extensions/bluebubbles/src/client.test.ts
Normal file
@@ -0,0 +1,613 @@
|
||||
import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import "./test-mocks.js";
|
||||
import {
|
||||
blueBubblesHeaderAuth,
|
||||
blueBubblesQueryStringAuth,
|
||||
BlueBubblesClient,
|
||||
clearBlueBubblesClientCache,
|
||||
createBlueBubblesClient,
|
||||
invalidateBlueBubblesClient,
|
||||
resolveBlueBubblesClientSsrfPolicy,
|
||||
} from "./client.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
import type { PluginRuntime } from "./runtime-api.js";
|
||||
import { setBlueBubblesRuntime } from "./runtime.js";
|
||||
import {
|
||||
createBlueBubblesFetchGuardPassthroughInstaller,
|
||||
installBlueBubblesFetchTestHooks,
|
||||
} from "./test-harness.js";
|
||||
import type { BlueBubblesAttachment } from "./types.js";
|
||||
import { _setFetchGuardForTesting } from "./types.js";
|
||||
|
||||
// --- Test infrastructure ---------------------------------------------------
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
const fetchRemoteMediaMock = vi.fn(
|
||||
async (params: {
|
||||
url: string;
|
||||
maxBytes?: number;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
}) => {
|
||||
const fetchFn = params.fetchImpl ?? fetch;
|
||||
const res = await fetchFn(params.url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`media fetch failed: HTTP ${res.status}`);
|
||||
}
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
if (typeof params.maxBytes === "number" && buffer.byteLength > params.maxBytes) {
|
||||
const error = new Error(`payload exceeds maxBytes ${params.maxBytes}`) as Error & {
|
||||
code?: string;
|
||||
};
|
||||
error.code = "max_bytes";
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
buffer,
|
||||
contentType: res.headers.get("content-type") ?? undefined,
|
||||
fileName: undefined,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
installBlueBubblesFetchTestHooks({
|
||||
mockFetch,
|
||||
privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus),
|
||||
});
|
||||
|
||||
const runtimeStub = {
|
||||
channel: {
|
||||
media: {
|
||||
fetchRemoteMedia:
|
||||
fetchRemoteMediaMock as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"],
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchRemoteMediaMock.mockClear();
|
||||
clearBlueBubblesClientCache();
|
||||
setBlueBubblesRuntime(runtimeStub);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearBlueBubblesClientCache();
|
||||
});
|
||||
|
||||
// --- resolveBlueBubblesClientSsrfPolicy ------------------------------------
|
||||
|
||||
describe("resolveBlueBubblesClientSsrfPolicy (3-mode policy)", () => {
|
||||
it("mode 1: user opts in → { allowPrivateNetwork: true } for any hostname", () => {
|
||||
const result = resolveBlueBubblesClientSsrfPolicy({
|
||||
baseUrl: "http://localhost:1234",
|
||||
allowPrivateNetwork: true,
|
||||
});
|
||||
expect(result.ssrfPolicy).toEqual({ allowPrivateNetwork: true });
|
||||
expect(result.trustedHostname).toBe("localhost");
|
||||
expect(result.trustedHostnameIsPrivate).toBe(true);
|
||||
});
|
||||
|
||||
it("mode 2: private hostname + no opt-out → narrow allowlist { allowedHostnames: [host] }", () => {
|
||||
const result = resolveBlueBubblesClientSsrfPolicy({
|
||||
baseUrl: "http://192.168.1.50:1234",
|
||||
allowPrivateNetwork: false,
|
||||
});
|
||||
expect(result.ssrfPolicy).toEqual({ allowedHostnames: ["192.168.1.50"] });
|
||||
expect(result.trustedHostnameIsPrivate).toBe(true);
|
||||
});
|
||||
|
||||
it("mode 2: localhost + no opt-out → narrow allowlist keeps BB reachable without full opt-in", () => {
|
||||
const result = resolveBlueBubblesClientSsrfPolicy({
|
||||
baseUrl: "http://localhost:1234",
|
||||
allowPrivateNetwork: false,
|
||||
});
|
||||
expect(result.ssrfPolicy).toEqual({ allowedHostnames: ["localhost"] });
|
||||
});
|
||||
|
||||
it("mode 2: public hostname + no opt-in → narrow allowlist for the public host", () => {
|
||||
const result = resolveBlueBubblesClientSsrfPolicy({
|
||||
baseUrl: "https://bb.example.com",
|
||||
allowPrivateNetwork: false,
|
||||
});
|
||||
expect(result.ssrfPolicy).toEqual({ allowedHostnames: ["bb.example.com"] });
|
||||
expect(result.trustedHostnameIsPrivate).toBe(false);
|
||||
});
|
||||
|
||||
it("mode 3: private hostname + explicit opt-out → {} (guarded default-deny, honors the opt-out) (aisle #68234)", () => {
|
||||
// Previously returned `undefined`, which routed through the unguarded
|
||||
// fetch fallback and effectively bypassed SSRF protection exactly when
|
||||
// the user had explicitly asked to disable private-network access.
|
||||
const result = resolveBlueBubblesClientSsrfPolicy({
|
||||
baseUrl: "http://192.168.1.50:1234",
|
||||
allowPrivateNetwork: false,
|
||||
allowPrivateNetworkConfig: false,
|
||||
});
|
||||
expect(result.ssrfPolicy).toEqual({});
|
||||
expect(result.trustedHostnameIsPrivate).toBe(true);
|
||||
});
|
||||
|
||||
it("mode 3: unparseable baseUrl → {} (fail-safe guarded, never bypass)", () => {
|
||||
const result = resolveBlueBubblesClientSsrfPolicy({
|
||||
baseUrl: "not a url",
|
||||
allowPrivateNetwork: false,
|
||||
});
|
||||
expect(result.ssrfPolicy).toEqual({});
|
||||
expect(result.trustedHostname).toBeUndefined();
|
||||
});
|
||||
|
||||
it("never returns undefined ssrfPolicy — every mode is guarded (aisle #68234 invariant)", () => {
|
||||
// This invariant is what closes the SSRF bypass aisle flagged. Any
|
||||
// refactor that reintroduces `ssrfPolicy: undefined` should break here.
|
||||
const cases = [
|
||||
{ baseUrl: "http://localhost:1234", allowPrivateNetwork: true },
|
||||
{ baseUrl: "http://localhost:1234", allowPrivateNetwork: false },
|
||||
{
|
||||
baseUrl: "http://192.168.1.50:1234",
|
||||
allowPrivateNetwork: false,
|
||||
allowPrivateNetworkConfig: false,
|
||||
},
|
||||
{ baseUrl: "https://bb.example.com", allowPrivateNetwork: false },
|
||||
{ baseUrl: "not a url", allowPrivateNetwork: false },
|
||||
];
|
||||
for (const c of cases) {
|
||||
const result = resolveBlueBubblesClientSsrfPolicy(c);
|
||||
expect(result.ssrfPolicy).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- Auth strategies -------------------------------------------------------
|
||||
|
||||
describe("auth strategies", () => {
|
||||
it("blueBubblesQueryStringAuth sets ?password= on URL", () => {
|
||||
const strategy = blueBubblesQueryStringAuth("s3cret");
|
||||
const url = new URL("http://localhost:1234/api/v1/ping");
|
||||
const init: RequestInit = {};
|
||||
strategy.decorate({ url, init });
|
||||
expect(url.searchParams.get("password")).toBe("s3cret");
|
||||
expect(init.headers).toBeUndefined();
|
||||
});
|
||||
|
||||
it("blueBubblesHeaderAuth sets the auth header and leaves URL clean", () => {
|
||||
const strategy = blueBubblesHeaderAuth("s3cret");
|
||||
const url = new URL("http://localhost:1234/api/v1/ping");
|
||||
const init: RequestInit = {};
|
||||
strategy.decorate({ url, init });
|
||||
expect(url.searchParams.has("password")).toBe(false);
|
||||
expect(new Headers(init.headers).get("X-BB-Password")).toBe("s3cret");
|
||||
});
|
||||
|
||||
it("blueBubblesHeaderAuth accepts a custom header name", () => {
|
||||
const strategy = blueBubblesHeaderAuth("s3cret", "Authorization");
|
||||
const url = new URL("http://localhost:1234/api/v1/ping");
|
||||
const init: RequestInit = {};
|
||||
strategy.decorate({ url, init });
|
||||
expect(new Headers(init.headers).get("Authorization")).toBe("s3cret");
|
||||
});
|
||||
|
||||
it("auth runs on every request made through the client", async () => {
|
||||
const client = createBlueBubblesClient({
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "s3cret",
|
||||
});
|
||||
mockFetch.mockImplementation(() => Promise.resolve(new Response("", { status: 200 })));
|
||||
await client.ping();
|
||||
await client.getServerInfo();
|
||||
const calls = mockFetch.mock.calls;
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(String(calls[0]?.[0])).toContain("password=s3cret");
|
||||
expect(String(calls[1]?.[0])).toContain("password=s3cret");
|
||||
});
|
||||
|
||||
it("swapping to header auth at factory level keeps URL clean", async () => {
|
||||
const client = createBlueBubblesClient({
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "s3cret",
|
||||
authStrategy: blueBubblesHeaderAuth,
|
||||
});
|
||||
mockFetch.mockResolvedValue(new Response("", { status: 200 }));
|
||||
await client.ping();
|
||||
const [calledUrl, calledInit] = mockFetch.mock.calls[0] ?? [];
|
||||
expect(String(calledUrl)).not.toContain("password=");
|
||||
const headers = new Headers((calledInit as RequestInit | undefined)?.headers);
|
||||
expect(headers.get("X-BB-Password")).toBe("s3cret");
|
||||
});
|
||||
|
||||
it("header-auth headers flow through requestMultipart (Greptile #68234 P1)", async () => {
|
||||
// Before this fix, requestMultipart discarded prepared.init entirely
|
||||
// and postMultipartFormData built its own hardcoded Content-Type header.
|
||||
// Under header-auth that silently omitted the auth header on every
|
||||
// attachment upload and group-icon set.
|
||||
const client = createBlueBubblesClient({
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "s3cret",
|
||||
authStrategy: blueBubblesHeaderAuth,
|
||||
});
|
||||
mockFetch.mockImplementation(() => Promise.resolve(new Response("{}", { status: 200 })));
|
||||
await client.requestMultipart({
|
||||
path: "/api/v1/chat/chat-guid/icon",
|
||||
boundary: "----boundary",
|
||||
parts: [new Uint8Array([1, 2, 3])],
|
||||
});
|
||||
const [, calledInit] = mockFetch.mock.calls[0] ?? [];
|
||||
const headers = new Headers((calledInit as RequestInit | undefined)?.headers);
|
||||
expect(headers.get("X-BB-Password")).toBe("s3cret");
|
||||
// And the multipart Content-Type must still be set correctly.
|
||||
expect(headers.get("Content-Type")).toContain("multipart/form-data; boundary=----boundary");
|
||||
});
|
||||
|
||||
it("header-auth headers flow through downloadAttachment fetchImpl (Greptile #68234 P1)", async () => {
|
||||
// Before this fix, downloadAttachment built prepared.init.headers with
|
||||
// the auth header but never forwarded it to the fetchImpl callback,
|
||||
// so header-auth would silently 401 on attachment downloads.
|
||||
const client = createBlueBubblesClient({
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "s3cret",
|
||||
authStrategy: blueBubblesHeaderAuth,
|
||||
});
|
||||
mockFetch.mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
new Response(Buffer.from([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/png" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
await client.downloadAttachment({ attachment: { guid: "att-1", mimeType: "image/png" } });
|
||||
// fetchRemoteMediaMock delegates to fetchImpl, which calls mockFetch.
|
||||
const [, calledInit] = mockFetch.mock.calls[0] ?? [];
|
||||
const headers = new Headers((calledInit as RequestInit | undefined)?.headers);
|
||||
expect(headers.get("X-BB-Password")).toBe("s3cret");
|
||||
});
|
||||
});
|
||||
|
||||
// --- Core request path -----------------------------------------------------
|
||||
|
||||
describe("client.request — SSRF policy threading", () => {
|
||||
it("threads the same resolved policy to the SSRF guard on every call", async () => {
|
||||
const capturedPolicies: unknown[] = [];
|
||||
const installPassthrough = createBlueBubblesFetchGuardPassthroughInstaller();
|
||||
installPassthrough((policy) => {
|
||||
capturedPolicies.push(policy);
|
||||
});
|
||||
mockFetch.mockImplementation(() => Promise.resolve(new Response("{}", { status: 200 })));
|
||||
|
||||
// Public hostname with no explicit opt-in → mode 2 (narrow allowlist).
|
||||
const client = createBlueBubblesClient({
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "https://bb.example.com",
|
||||
password: "s3cret",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
await client.ping();
|
||||
await client.getServerInfo();
|
||||
|
||||
// Both calls used the same narrow allowlist policy (mode 2).
|
||||
expect(capturedPolicies).toHaveLength(2);
|
||||
expect(capturedPolicies[0]).toEqual({ allowedHostnames: ["bb.example.com"] });
|
||||
expect(capturedPolicies[1]).toEqual({ allowedHostnames: ["bb.example.com"] });
|
||||
});
|
||||
|
||||
it("private hostname auto-allows (mode 1) without explicit opt-in — preserves existing behavior", async () => {
|
||||
const capturedPolicies: unknown[] = [];
|
||||
const installPassthrough = createBlueBubblesFetchGuardPassthroughInstaller();
|
||||
installPassthrough((policy) => {
|
||||
capturedPolicies.push(policy);
|
||||
});
|
||||
mockFetch.mockImplementation(() => Promise.resolve(new Response("{}", { status: 200 })));
|
||||
|
||||
// 192.168/16 hostname with no config → resolveBlueBubblesEffectiveAllowPrivateNetwork
|
||||
// auto-allows (accounts-normalization.ts:98-107) → mode 1.
|
||||
const client = createBlueBubblesClient({
|
||||
serverUrl: "http://192.168.1.50:1234",
|
||||
password: "s3cret",
|
||||
});
|
||||
|
||||
await client.ping();
|
||||
await client.getServerInfo();
|
||||
|
||||
expect(capturedPolicies).toHaveLength(2);
|
||||
expect(capturedPolicies[0]).toEqual({ allowPrivateNetwork: true });
|
||||
expect(capturedPolicies[1]).toEqual({ allowPrivateNetwork: true });
|
||||
});
|
||||
|
||||
it("applies full-open policy when user opts into private networks", async () => {
|
||||
const capturedPolicies: unknown[] = [];
|
||||
const installPassthrough = createBlueBubblesFetchGuardPassthroughInstaller();
|
||||
installPassthrough((policy) => {
|
||||
capturedPolicies.push(policy);
|
||||
});
|
||||
mockFetch.mockResolvedValue(new Response("{}", { status: 200 }));
|
||||
|
||||
const client = createBlueBubblesClient({
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "s3cret",
|
||||
network: { dangerouslyAllowPrivateNetwork: true },
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
await client.ping();
|
||||
expect(capturedPolicies[0]).toEqual({ allowPrivateNetwork: true });
|
||||
});
|
||||
});
|
||||
|
||||
// --- #59722 regression: reactions use same policy as other calls -----------
|
||||
|
||||
describe("client.react (regression for #59722)", () => {
|
||||
it("uses the same SSRF policy as every other client request (no asymmetric {} fallback)", async () => {
|
||||
const capturedPolicies: unknown[] = [];
|
||||
const installPassthrough = createBlueBubblesFetchGuardPassthroughInstaller();
|
||||
installPassthrough((policy) => {
|
||||
capturedPolicies.push(policy);
|
||||
});
|
||||
mockFetch.mockImplementation(() => Promise.resolve(new Response("{}", { status: 200 })));
|
||||
|
||||
const client = createBlueBubblesClient({
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "s3cret",
|
||||
});
|
||||
|
||||
// Both should carry the same mode-2 allowlist — before this client existed,
|
||||
// reactions.ts passed `{}` (empty guard) while attachments.ts passed
|
||||
// `{ allowedHostnames: [...] }`. The asymmetry is what #59722 reported.
|
||||
await client.ping();
|
||||
await client.react({
|
||||
chatGuid: "iMessage;+;+15551234567",
|
||||
selectedMessageGuid: "msg-1",
|
||||
reaction: "like",
|
||||
});
|
||||
|
||||
expect(capturedPolicies).toHaveLength(2);
|
||||
// The critical assertion: both calls resolved the SAME policy, no
|
||||
// `{}` vs `{ allowedHostnames }` asymmetry like before consolidation.
|
||||
expect(capturedPolicies[0]).toEqual(capturedPolicies[1]);
|
||||
// Localhost auto-allows (private hostname, no explicit opt-out).
|
||||
expect(capturedPolicies[1]).toEqual({ allowPrivateNetwork: true });
|
||||
});
|
||||
|
||||
it("sends the reaction payload with the correct shape and method", async () => {
|
||||
mockFetch.mockResolvedValue(new Response("{}", { status: 200 }));
|
||||
const client = createBlueBubblesClient({
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "s3cret",
|
||||
});
|
||||
await client.react({
|
||||
chatGuid: "chat-guid",
|
||||
selectedMessageGuid: "msg-1",
|
||||
reaction: "love",
|
||||
partIndex: 2,
|
||||
});
|
||||
|
||||
const [calledUrl, calledInit] = mockFetch.mock.calls[0] ?? [];
|
||||
expect(String(calledUrl)).toContain("/api/v1/message/react");
|
||||
const init = calledInit as RequestInit;
|
||||
expect(init.method).toBe("POST");
|
||||
const body = JSON.parse(init.body as string) as Record<string, unknown>;
|
||||
expect(body).toEqual({
|
||||
chatGuid: "chat-guid",
|
||||
selectedMessageGuid: "msg-1",
|
||||
reaction: "love",
|
||||
partIndex: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- #34749 regression: downloadAttachment threads policy end-to-end -------
|
||||
|
||||
describe("client.downloadAttachment (regression for #34749)", () => {
|
||||
it("threads the client's ssrfPolicy to fetchRemoteMedia", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(Buffer.from([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/png" },
|
||||
}),
|
||||
);
|
||||
|
||||
const client = createBlueBubblesClient({
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "s3cret",
|
||||
});
|
||||
await client.downloadAttachment({
|
||||
attachment: { guid: "att-1", mimeType: "image/png" },
|
||||
});
|
||||
|
||||
expect(fetchRemoteMediaMock).toHaveBeenCalledTimes(1);
|
||||
const call = fetchRemoteMediaMock.mock.calls[0]?.[0];
|
||||
expect(call?.ssrfPolicy).toEqual({ allowPrivateNetwork: true });
|
||||
expect(call?.url).toContain("/api/v1/attachment/att-1/download");
|
||||
});
|
||||
|
||||
it("threads the client's ssrfPolicy to the fetchImpl callback (closes #34749 gap)", async () => {
|
||||
const capturedPolicies: unknown[] = [];
|
||||
const installPassthrough = createBlueBubblesFetchGuardPassthroughInstaller();
|
||||
installPassthrough((policy) => {
|
||||
capturedPolicies.push(policy);
|
||||
});
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(Buffer.from([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/png" },
|
||||
}),
|
||||
);
|
||||
|
||||
const client = createBlueBubblesClient({
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "s3cret",
|
||||
});
|
||||
await client.downloadAttachment({
|
||||
attachment: { guid: "att-1", mimeType: "image/png" },
|
||||
});
|
||||
|
||||
// fetchImpl ran (the mock runtime delegates to globalThis.fetch via fetchFn),
|
||||
// which means blueBubblesFetchWithTimeout was called WITH the ssrfPolicy.
|
||||
// Before this fix, attachments.ts built its fetchImpl without forwarding
|
||||
// the policy — the guarded path never ran for the actual attachment bytes.
|
||||
expect(capturedPolicies).toHaveLength(1);
|
||||
expect(capturedPolicies[0]).toEqual({ allowPrivateNetwork: true });
|
||||
});
|
||||
|
||||
it("throws when attachment guid is missing", async () => {
|
||||
const client = createBlueBubblesClient({
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "s3cret",
|
||||
});
|
||||
await expect(
|
||||
client.downloadAttachment({ attachment: {} as BlueBubblesAttachment }),
|
||||
).rejects.toThrow("guid is required");
|
||||
});
|
||||
|
||||
it("surfaces max_bytes error with clear message", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(Buffer.alloc(10 * 1024 * 1024), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/octet-stream" },
|
||||
}),
|
||||
);
|
||||
const client = createBlueBubblesClient({
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "s3cret",
|
||||
});
|
||||
await expect(
|
||||
client.downloadAttachment({
|
||||
attachment: { guid: "att-big" },
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
).rejects.toThrow(/too large \(limit 1024 bytes\)/);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Attachment metadata ---------------------------------------------------
|
||||
|
||||
describe("client.getMessageAttachments", () => {
|
||||
it("fetches and extracts attachment metadata", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
attachments: [
|
||||
{ guid: "att-xyz", transferName: "IMG_0001.JPG", mimeType: "image/jpeg" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
const client = createBlueBubblesClient({
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "s3cret",
|
||||
});
|
||||
const result = await client.getMessageAttachments({ messageGuid: "msg-1" });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.guid).toBe("att-xyz");
|
||||
expect(result[0]?.mimeType).toBe("image/jpeg");
|
||||
expect(String(mockFetch.mock.calls[0]?.[0])).toContain("/api/v1/message/msg-1");
|
||||
});
|
||||
|
||||
it("returns [] on non-ok response rather than throwing", async () => {
|
||||
mockFetch.mockResolvedValue(new Response("not found", { status: 404 }));
|
||||
const client = createBlueBubblesClient({
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "s3cret",
|
||||
});
|
||||
const result = await client.getMessageAttachments({ messageGuid: "missing" });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Cache + invalidation --------------------------------------------------
|
||||
|
||||
describe("client cache", () => {
|
||||
it("returns the same instance for the same accountId + baseUrl", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
bluebubbles: { serverUrl: "http://localhost:1234", password: "s3cret" },
|
||||
},
|
||||
} as never;
|
||||
const a = createBlueBubblesClient({ cfg });
|
||||
const b = createBlueBubblesClient({ cfg });
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("returns a different instance after invalidate", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
bluebubbles: { serverUrl: "http://localhost:1234", password: "s3cret" },
|
||||
},
|
||||
} as never;
|
||||
const a = createBlueBubblesClient({ cfg });
|
||||
invalidateBlueBubblesClient(a.accountId);
|
||||
const b = createBlueBubblesClient({ cfg });
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("cache entry is keyed so different serverUrls cannot collide", () => {
|
||||
const a = createBlueBubblesClient({
|
||||
serverUrl: "http://host-a:1234",
|
||||
password: "s3cret",
|
||||
});
|
||||
invalidateBlueBubblesClient(a.accountId);
|
||||
const b = createBlueBubblesClient({
|
||||
serverUrl: "http://host-b:1234",
|
||||
password: "s3cret",
|
||||
});
|
||||
expect(b.baseUrl).toBe("http://host-b:1234");
|
||||
});
|
||||
|
||||
it("different authStrategy for the same account + credential rebuilds the client (Greptile #68234 P2)", () => {
|
||||
// Before this fix the fingerprint keyed only on {baseUrl, password}.
|
||||
// A second call with a different authStrategy would silently return
|
||||
// the cached first strategy's client.
|
||||
const a = createBlueBubblesClient({
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "s3cret",
|
||||
// default: blueBubblesQueryStringAuth
|
||||
});
|
||||
const b = createBlueBubblesClient({
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "s3cret",
|
||||
authStrategy: blueBubblesHeaderAuth,
|
||||
});
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("client construction", () => {
|
||||
it("throws when serverUrl is missing", () => {
|
||||
expect(() => createBlueBubblesClient({ password: "s3cret" })).toThrow(/serverUrl is required/);
|
||||
});
|
||||
|
||||
it("throws when password is missing", () => {
|
||||
expect(() => createBlueBubblesClient({ serverUrl: "http://localhost:1234" })).toThrow(
|
||||
/password is required/,
|
||||
);
|
||||
});
|
||||
|
||||
it("is a BlueBubblesClient instance and exposes read-only policy", () => {
|
||||
const client = createBlueBubblesClient({
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "s3cret",
|
||||
});
|
||||
expect(client).toBeInstanceOf(BlueBubblesClient);
|
||||
// localhost auto-allows (accounts-normalization.ts) → mode 1.
|
||||
expect(client.getSsrfPolicy()).toEqual({ allowPrivateNetwork: true });
|
||||
expect(client.trustedHostname).toBe("localhost");
|
||||
expect(client.trustedHostnameIsPrivate).toBe(true);
|
||||
expect(client.accountId).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// Reference unused import so lint doesn't complain while we keep parity with
|
||||
// the existing test-harness module contract (#68xxx).
|
||||
void _setFetchGuardForTesting;
|
||||
572
extensions/bluebubbles/src/client.ts
Normal file
572
extensions/bluebubbles/src/client.ts
Normal file
@@ -0,0 +1,572 @@
|
||||
// BlueBubblesClient — consolidated BB API client.
|
||||
//
|
||||
// Resolves the BB server URL, auth material, and SSRF policy ONCE at
|
||||
// construction, then exposes typed operations that cannot omit any of them.
|
||||
//
|
||||
// Designed to replace the scattered pattern of each callsite computing its own
|
||||
// SsrFPolicy and passing it to `blueBubblesFetchWithTimeout`. Related issues:
|
||||
// - #34749 image attachments blocked by SSRF guard (localhost)
|
||||
// - #57181 SSRF blocks BB plugin internal API calls
|
||||
// - #59722 SSRF allowlist doesn't cover reactions
|
||||
// - #60715 BB health check fails on LAN/private serverUrl
|
||||
// - #66869 move `?password=` → header auth (future-proofed via AuthStrategy)
|
||||
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { isBlockedHostnameOrIp, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
||||
import { extractAttachments } from "./monitor-normalize.js";
|
||||
import { postMultipartFormData } from "./multipart.js";
|
||||
import { resolveRequestUrl } from "./request-url.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "./runtime-api.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
import { getBlueBubblesRuntime } from "./runtime.js";
|
||||
import {
|
||||
blueBubblesFetchWithTimeout,
|
||||
normalizeBlueBubblesServerUrl,
|
||||
type BlueBubblesAttachment,
|
||||
} from "./types.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_ATTACHMENT_MAX_BYTES = 8 * 1024 * 1024;
|
||||
const DEFAULT_MULTIPART_TIMEOUT_MS = 60_000;
|
||||
|
||||
// --- Auth strategy ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pluggable authentication for BlueBubbles API requests. Mutates the URL/init
|
||||
* pair in place before the request is dispatched.
|
||||
*
|
||||
* Two built-in strategies are provided:
|
||||
* - `blueBubblesQueryStringAuth` — today's `?password=...` pattern (default).
|
||||
* - `blueBubblesHeaderAuth` — header-based auth; flip the default here when
|
||||
* BB Server ships the header-auth change for #66869.
|
||||
*/
|
||||
export interface BlueBubblesAuthStrategy {
|
||||
/**
|
||||
* Stable identifier for this strategy. Used by the client cache fingerprint
|
||||
* so two clients for the same account + credential that differ only in auth
|
||||
* strategy don't silently collapse onto the same cached instance.
|
||||
* (Greptile #68234 P2)
|
||||
*/
|
||||
readonly id: string;
|
||||
decorate(req: { url: URL; init: RequestInit }): void;
|
||||
}
|
||||
|
||||
export function blueBubblesQueryStringAuth(password: string): BlueBubblesAuthStrategy {
|
||||
return {
|
||||
id: "query-string",
|
||||
decorate({ url }) {
|
||||
url.searchParams.set("password", password);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function blueBubblesHeaderAuth(
|
||||
password: string,
|
||||
headerName = "X-BB-Password",
|
||||
): BlueBubblesAuthStrategy {
|
||||
return {
|
||||
id: `header:${headerName}`,
|
||||
decorate({ init }) {
|
||||
const headers = new Headers(init.headers ?? undefined);
|
||||
headers.set(headerName, password);
|
||||
init.headers = headers;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// --- Policy resolution -----------------------------------------------------
|
||||
|
||||
function safeExtractHostname(baseUrl: string): string | undefined {
|
||||
try {
|
||||
const hostname = new URL(normalizeBlueBubblesServerUrl(baseUrl)).hostname.trim();
|
||||
return hostname || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the BB client's SSRF policy at construction time. Three modes —
|
||||
* all of which go through `fetchWithSsrFGuard`; we never hand back a policy
|
||||
* that skips the guard:
|
||||
*
|
||||
* 1. `{ allowPrivateNetwork: true }` — user explicitly opted in
|
||||
* (`network.dangerouslyAllowPrivateNetwork: true`). Private/loopback
|
||||
* addresses are permitted for this client.
|
||||
*
|
||||
* 2. `{ allowedHostnames: [trustedHostname] }` — narrow allowlist. Applied
|
||||
* when we have a parseable hostname AND the user has not explicitly
|
||||
* opted out (or the hostname isn't private anyway). This is the case
|
||||
* that closes #34749, #57181, #59722, #60715 for self-hosted BB on
|
||||
* private/localhost addresses without requiring a full opt-in.
|
||||
*
|
||||
* 3. `{}` — guarded with the default-deny policy. Applied when we can't
|
||||
* produce a valid allowlist (opt-out on a private hostname, or an
|
||||
* unparseable baseUrl). Previously returned `undefined` and skipped
|
||||
* the guard entirely, which was an SSRF bypass when a user explicitly
|
||||
* opted out of private-network access. Aisle #68234 found this.
|
||||
*
|
||||
* Prior to this helper, the logic lived inline in `attachments.ts` and was
|
||||
* inconsistently replicated across 15+ callsites. Resolving once ensures
|
||||
* every request from a client instance uses the same policy.
|
||||
*/
|
||||
export function resolveBlueBubblesClientSsrfPolicy(params: {
|
||||
baseUrl: string;
|
||||
allowPrivateNetwork: boolean;
|
||||
allowPrivateNetworkConfig?: boolean;
|
||||
}): {
|
||||
ssrfPolicy: SsrFPolicy;
|
||||
trustedHostname?: string;
|
||||
trustedHostnameIsPrivate: boolean;
|
||||
} {
|
||||
const trustedHostname = safeExtractHostname(params.baseUrl);
|
||||
const trustedHostnameIsPrivate = trustedHostname ? isBlockedHostnameOrIp(trustedHostname) : false;
|
||||
|
||||
if (params.allowPrivateNetwork) {
|
||||
return {
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
trustedHostname,
|
||||
trustedHostnameIsPrivate,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
trustedHostname &&
|
||||
(params.allowPrivateNetworkConfig !== false || !trustedHostnameIsPrivate)
|
||||
) {
|
||||
return {
|
||||
ssrfPolicy: { allowedHostnames: [trustedHostname] },
|
||||
trustedHostname,
|
||||
trustedHostnameIsPrivate,
|
||||
};
|
||||
}
|
||||
|
||||
// Mode 3: default-deny guard. Honors an explicit opt-out on a private
|
||||
// hostname and fails-safe on unparseable URLs. Never undefined. (aisle #68234)
|
||||
return { ssrfPolicy: {}, trustedHostname, trustedHostnameIsPrivate };
|
||||
}
|
||||
|
||||
// --- Client ----------------------------------------------------------------
|
||||
|
||||
export type BlueBubblesClientOptions = {
|
||||
cfg?: OpenClawConfig;
|
||||
accountId?: string;
|
||||
serverUrl?: string;
|
||||
password?: string;
|
||||
timeoutMs?: number;
|
||||
authStrategy?: (password: string) => BlueBubblesAuthStrategy;
|
||||
};
|
||||
|
||||
type ClientConstructorParams = {
|
||||
accountId: string;
|
||||
baseUrl: string;
|
||||
password: string;
|
||||
ssrfPolicy: SsrFPolicy;
|
||||
trustedHostname: string | undefined;
|
||||
trustedHostnameIsPrivate: boolean;
|
||||
defaultTimeoutMs: number;
|
||||
authStrategy: BlueBubblesAuthStrategy;
|
||||
};
|
||||
|
||||
type MediaFetchErrorCode = "max_bytes" | "http_error" | "fetch_failed";
|
||||
|
||||
function readMediaFetchErrorCode(error: unknown): MediaFetchErrorCode | undefined {
|
||||
if (!error || typeof error !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const code = (error as { code?: unknown }).code;
|
||||
return code === "max_bytes" || code === "http_error" || code === "fetch_failed"
|
||||
? code
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export class BlueBubblesClient {
|
||||
readonly accountId: string;
|
||||
readonly baseUrl: string;
|
||||
readonly trustedHostname: string | undefined;
|
||||
readonly trustedHostnameIsPrivate: boolean;
|
||||
|
||||
private readonly password: string;
|
||||
private readonly ssrfPolicy: SsrFPolicy;
|
||||
private readonly defaultTimeoutMs: number;
|
||||
private readonly authStrategy: BlueBubblesAuthStrategy;
|
||||
|
||||
constructor(params: ClientConstructorParams) {
|
||||
this.accountId = params.accountId;
|
||||
this.baseUrl = params.baseUrl;
|
||||
this.password = params.password;
|
||||
this.ssrfPolicy = params.ssrfPolicy;
|
||||
this.trustedHostname = params.trustedHostname;
|
||||
this.trustedHostnameIsPrivate = params.trustedHostnameIsPrivate;
|
||||
this.defaultTimeoutMs = params.defaultTimeoutMs;
|
||||
this.authStrategy = params.authStrategy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the resolved SSRF policy for this client. Exposed primarily for tests
|
||||
* and diagnostics; production code should never need to inspect it.
|
||||
*/
|
||||
getSsrfPolicy(): SsrFPolicy {
|
||||
return this.ssrfPolicy;
|
||||
}
|
||||
|
||||
// Build an authorized URL+init pair. Auth is applied exactly once per
|
||||
// request; the SSRF policy is attached by `request()` below.
|
||||
private buildAuthorizedRequest(params: { path: string; method: string; init?: RequestInit }): {
|
||||
url: string;
|
||||
init: RequestInit;
|
||||
} {
|
||||
const normalized = normalizeBlueBubblesServerUrl(this.baseUrl);
|
||||
const url = new URL(params.path, `${normalized}/`);
|
||||
const init: RequestInit = { ...params.init, method: params.method };
|
||||
this.authStrategy.decorate({ url, init });
|
||||
return { url: url.toString(), init };
|
||||
}
|
||||
|
||||
/**
|
||||
* Core request method. All typed operations on the client route through
|
||||
* this method, which handles auth decoration, SSRF policy, and timeout.
|
||||
*/
|
||||
async request(params: {
|
||||
method: string;
|
||||
path: string;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
}): Promise<Response> {
|
||||
const init: RequestInit = {};
|
||||
if (params.headers) {
|
||||
init.headers = { ...params.headers };
|
||||
}
|
||||
if (params.body !== undefined) {
|
||||
init.headers = {
|
||||
"Content-Type": "application/json",
|
||||
...(init.headers as Record<string, string> | undefined),
|
||||
};
|
||||
init.body = JSON.stringify(params.body);
|
||||
}
|
||||
const prepared = this.buildAuthorizedRequest({
|
||||
path: params.path,
|
||||
method: params.method,
|
||||
init,
|
||||
});
|
||||
return await blueBubblesFetchWithTimeout(
|
||||
prepared.url,
|
||||
prepared.init,
|
||||
params.timeoutMs ?? this.defaultTimeoutMs,
|
||||
this.ssrfPolicy,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON request helper. Returns both the response (for status/headers) and
|
||||
* parsed body (null on non-ok or parse failure — callers check both).
|
||||
*/
|
||||
async requestJson(params: {
|
||||
method: string;
|
||||
path: string;
|
||||
body?: unknown;
|
||||
timeoutMs?: number;
|
||||
}): Promise<{ response: Response; data: unknown }> {
|
||||
const response = await this.request(params);
|
||||
if (!response.ok) {
|
||||
return { response, data: null };
|
||||
}
|
||||
const raw: unknown = await response.json().catch(() => null);
|
||||
return { response, data: raw };
|
||||
}
|
||||
|
||||
/**
|
||||
* Multipart POST (attachment send, group icon set). The caller supplies the
|
||||
* boundary and body parts; the client handles URL construction, auth, and
|
||||
* SSRF policy. Timeout defaults to 60s because uploads can be large.
|
||||
*
|
||||
* Auth-decorated headers from `prepared.init` are forwarded via `extraHeaders`
|
||||
* so header-auth strategies keep working on multipart paths. (Greptile #68234 P1)
|
||||
*/
|
||||
async requestMultipart(params: {
|
||||
path: string;
|
||||
boundary: string;
|
||||
parts: Uint8Array[];
|
||||
timeoutMs?: number;
|
||||
}): Promise<Response> {
|
||||
const prepared = this.buildAuthorizedRequest({
|
||||
path: params.path,
|
||||
method: "POST",
|
||||
init: {},
|
||||
});
|
||||
return await postMultipartFormData({
|
||||
url: prepared.url,
|
||||
boundary: params.boundary,
|
||||
parts: params.parts,
|
||||
timeoutMs: params.timeoutMs ?? DEFAULT_MULTIPART_TIMEOUT_MS,
|
||||
ssrfPolicy: this.ssrfPolicy,
|
||||
extraHeaders: prepared.init.headers,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Probe operations ----------------------------------------------------
|
||||
|
||||
/** GET /api/v1/ping — health check. Raw response for status inspection. */
|
||||
async ping(params: { timeoutMs?: number } = {}): Promise<Response> {
|
||||
return await this.request({
|
||||
method: "GET",
|
||||
path: "/api/v1/ping",
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
/** GET /api/v1/server/info — server/OS/Private-API metadata. */
|
||||
async getServerInfo(params: { timeoutMs?: number } = {}): Promise<Response> {
|
||||
return await this.request({
|
||||
method: "GET",
|
||||
path: "/api/v1/server/info",
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Reactions (fixes #59722) -------------------------------------------
|
||||
|
||||
/**
|
||||
* POST /api/v1/message/react. Uses the same SSRF policy as every other
|
||||
* operation on this client — closing the gap where `reactions.ts` passed
|
||||
* `{}` (always guarded, always blocks private IPs) while other callsites
|
||||
* used mode-aware policies.
|
||||
*/
|
||||
async react(params: {
|
||||
chatGuid: string;
|
||||
selectedMessageGuid: string;
|
||||
reaction: string;
|
||||
partIndex?: number;
|
||||
timeoutMs?: number;
|
||||
}): Promise<Response> {
|
||||
return await this.request({
|
||||
method: "POST",
|
||||
path: "/api/v1/message/react",
|
||||
body: {
|
||||
chatGuid: params.chatGuid,
|
||||
selectedMessageGuid: params.selectedMessageGuid,
|
||||
reaction: params.reaction,
|
||||
partIndex: typeof params.partIndex === "number" ? params.partIndex : 0,
|
||||
},
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Attachments (fixes #34749) -----------------------------------------
|
||||
|
||||
/**
|
||||
* GET /api/v1/message/{guid} to read attachment metadata. BlueBubbles may
|
||||
* fire `new-message` before attachment indexing completes, so this re-reads
|
||||
* after a delay. (#65430, #67437)
|
||||
*/
|
||||
async getMessageAttachments(params: {
|
||||
messageGuid: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<BlueBubblesAttachment[]> {
|
||||
const { response, data } = await this.requestJson({
|
||||
method: "GET",
|
||||
path: `/api/v1/message/${encodeURIComponent(params.messageGuid)}`,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
if (!response.ok || typeof data !== "object" || data === null) {
|
||||
return [];
|
||||
}
|
||||
const inner = (data as { data?: unknown }).data;
|
||||
if (typeof inner !== "object" || inner === null) {
|
||||
return [];
|
||||
}
|
||||
return extractAttachments(inner as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download an attachment via the channel media fetcher. Unlike the legacy
|
||||
* helper, the SSRF policy is threaded to BOTH `fetchRemoteMedia` AND the
|
||||
* `fetchImpl` callback — closing #34749 where the callback silently fell
|
||||
* back to the unguarded fetch path regardless of the outer policy.
|
||||
*
|
||||
* Note: the actual SSRF check still happens upstream in `fetchRemoteMedia`.
|
||||
* Passing `ssrfPolicy` to `blueBubblesFetchWithTimeout` in the callback
|
||||
* keeps it in the guarded path if the host needs re-validation (e.g. on a
|
||||
* BB Server that issues 302 redirects to a different host).
|
||||
*/
|
||||
async downloadAttachment(params: {
|
||||
attachment: BlueBubblesAttachment;
|
||||
maxBytes?: number;
|
||||
timeoutMs?: number;
|
||||
}): Promise<{ buffer: Uint8Array; contentType?: string }> {
|
||||
const guid = params.attachment.guid?.trim();
|
||||
if (!guid) {
|
||||
throw new Error("BlueBubbles attachment guid is required");
|
||||
}
|
||||
const maxBytes =
|
||||
typeof params.maxBytes === "number" ? params.maxBytes : DEFAULT_ATTACHMENT_MAX_BYTES;
|
||||
const prepared = this.buildAuthorizedRequest({
|
||||
path: `/api/v1/attachment/${encodeURIComponent(guid)}/download`,
|
||||
method: "GET",
|
||||
init: {},
|
||||
});
|
||||
const clientSsrfPolicy = this.ssrfPolicy;
|
||||
const effectiveTimeoutMs = params.timeoutMs ?? this.defaultTimeoutMs;
|
||||
// Auth-decorated headers from buildAuthorizedRequest (for header-auth
|
||||
// strategies) must flow through the fetchImpl callback too, otherwise
|
||||
// the runtime might dispatch with only its own default headers. Merge
|
||||
// prepared.init.headers with any headers the runtime supplies; runtime
|
||||
// headers (typically Range for partial reads) win on conflict.
|
||||
// (Greptile #68234 P1)
|
||||
const preparedHeaders = prepared.init.headers;
|
||||
|
||||
try {
|
||||
const fetched = await getBlueBubblesRuntime().channel.media.fetchRemoteMedia({
|
||||
url: prepared.url,
|
||||
filePathHint: params.attachment.transferName ?? params.attachment.guid ?? "attachment",
|
||||
maxBytes,
|
||||
ssrfPolicy: clientSsrfPolicy,
|
||||
fetchImpl: async (input, init) => {
|
||||
const mergedHeaders = new Headers(preparedHeaders);
|
||||
if (init?.headers) {
|
||||
const runtimeHeaders = new Headers(init.headers);
|
||||
runtimeHeaders.forEach((value, key) => mergedHeaders.set(key, value));
|
||||
}
|
||||
return await blueBubblesFetchWithTimeout(
|
||||
resolveRequestUrl(input),
|
||||
{ ...init, method: init?.method ?? "GET", headers: mergedHeaders },
|
||||
effectiveTimeoutMs,
|
||||
clientSsrfPolicy,
|
||||
);
|
||||
},
|
||||
});
|
||||
return {
|
||||
buffer: new Uint8Array(fetched.buffer),
|
||||
contentType: fetched.contentType ?? params.attachment.mimeType ?? undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
if (readMediaFetchErrorCode(error) === "max_bytes") {
|
||||
throw new Error(`BlueBubbles attachment too large (limit ${maxBytes} bytes)`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw new Error(`BlueBubbles attachment download failed: ${formatErrorMessage(error)}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Factory and cache -----------------------------------------------------
|
||||
|
||||
type CachedClientEntry = {
|
||||
client: BlueBubblesClient;
|
||||
/** Fingerprint of {baseUrl, password, authStrategy.id} — cache hit requires full match. */
|
||||
fingerprint: string;
|
||||
};
|
||||
const clientFingerprints = new Map<string, CachedClientEntry>();
|
||||
|
||||
function buildClientFingerprint(params: {
|
||||
baseUrl: string;
|
||||
password: string;
|
||||
authStrategyId: string;
|
||||
}): string {
|
||||
// authStrategyId is included so two clients for the same account + credential
|
||||
// that differ only in auth strategy do not silently share a cached instance.
|
||||
// (Greptile #68234 P2)
|
||||
return `${params.baseUrl}|${params.password}|${params.authStrategyId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a `BlueBubblesClient` for one BB account. The client is cached
|
||||
* by `accountId` — the next call with the same account AND same {baseUrl,
|
||||
* password} returns the existing instance. Password or URL change rebuilds.
|
||||
* Call `invalidateBlueBubblesClient(accountId)` from account config reload
|
||||
* paths to evict explicitly.
|
||||
*/
|
||||
export function createBlueBubblesClient(opts: BlueBubblesClientOptions = {}): BlueBubblesClient {
|
||||
const resolved = resolveBlueBubblesServerAccount({
|
||||
cfg: opts.cfg,
|
||||
accountId: opts.accountId,
|
||||
serverUrl: opts.serverUrl,
|
||||
password: opts.password,
|
||||
});
|
||||
const cacheKey = resolved.accountId || DEFAULT_ACCOUNT_ID;
|
||||
const authFactory = opts.authStrategy ?? blueBubblesQueryStringAuth;
|
||||
const authStrategy = authFactory(resolved.password);
|
||||
const fingerprint = buildClientFingerprint({
|
||||
baseUrl: resolved.baseUrl,
|
||||
password: resolved.password,
|
||||
authStrategyId: authStrategy.id,
|
||||
});
|
||||
const cached = clientFingerprints.get(cacheKey);
|
||||
if (cached && cached.fingerprint === fingerprint) {
|
||||
return cached.client;
|
||||
}
|
||||
|
||||
const policyResult = resolveBlueBubblesClientSsrfPolicy({
|
||||
baseUrl: resolved.baseUrl,
|
||||
allowPrivateNetwork: resolved.allowPrivateNetwork,
|
||||
allowPrivateNetworkConfig: resolved.allowPrivateNetworkConfig,
|
||||
});
|
||||
|
||||
const client = new BlueBubblesClient({
|
||||
accountId: cacheKey,
|
||||
baseUrl: resolved.baseUrl,
|
||||
password: resolved.password,
|
||||
ssrfPolicy: policyResult.ssrfPolicy,
|
||||
trustedHostname: policyResult.trustedHostname,
|
||||
trustedHostnameIsPrivate: policyResult.trustedHostnameIsPrivate,
|
||||
defaultTimeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
authStrategy,
|
||||
});
|
||||
clientFingerprints.set(cacheKey, { client, fingerprint });
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Evict a cached client by account id. Called from account config reload paths. */
|
||||
export function invalidateBlueBubblesClient(accountId?: string): void {
|
||||
const key = accountId || DEFAULT_ACCOUNT_ID;
|
||||
clientFingerprints.delete(key);
|
||||
}
|
||||
|
||||
/** @internal Clear the whole client cache. Test helper. */
|
||||
export function clearBlueBubblesClientCache(): void {
|
||||
clientFingerprints.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a BlueBubblesClient from a pre-resolved `{baseUrl, password,
|
||||
* allowPrivateNetwork}` tuple, skipping the account/config resolution path.
|
||||
*
|
||||
* Used by low-level helpers (`probe.ts`, `catchup.ts`, `history.ts`, etc.)
|
||||
* that are called with the resolved tuple rather than a full config bag.
|
||||
* Migrated callers pass their existing booleans straight through — the
|
||||
* three-mode policy resolution then runs exactly once here.
|
||||
*
|
||||
* Uncached — intended for short-lived callsites. Prefer `createBlueBubblesClient`
|
||||
* when a `cfg` + `accountId` are available.
|
||||
*/
|
||||
export function createBlueBubblesClientFromParts(params: {
|
||||
baseUrl: string;
|
||||
password: string;
|
||||
allowPrivateNetwork: boolean;
|
||||
allowPrivateNetworkConfig?: boolean;
|
||||
accountId?: string;
|
||||
timeoutMs?: number;
|
||||
authStrategy?: (password: string) => BlueBubblesAuthStrategy;
|
||||
}): BlueBubblesClient {
|
||||
const policyResult = resolveBlueBubblesClientSsrfPolicy({
|
||||
baseUrl: params.baseUrl,
|
||||
allowPrivateNetwork: params.allowPrivateNetwork,
|
||||
allowPrivateNetworkConfig: params.allowPrivateNetworkConfig,
|
||||
});
|
||||
const authFactory = params.authStrategy ?? blueBubblesQueryStringAuth;
|
||||
return new BlueBubblesClient({
|
||||
accountId: params.accountId || DEFAULT_ACCOUNT_ID,
|
||||
baseUrl: params.baseUrl,
|
||||
password: params.password,
|
||||
ssrfPolicy: policyResult.ssrfPolicy,
|
||||
trustedHostname: policyResult.trustedHostname,
|
||||
trustedHostnameIsPrivate: policyResult.trustedHostnameIsPrivate,
|
||||
defaultTimeoutMs: params.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
authStrategy: authFactory(params.password),
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
||||
import { createBlueBubblesClientFromParts } from "./client.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
||||
|
||||
export type BlueBubblesHistoryEntry = {
|
||||
sender: string;
|
||||
@@ -89,7 +89,12 @@ export async function fetchBlueBubblesHistory(
|
||||
} catch {
|
||||
return { entries: [], resolved: false };
|
||||
}
|
||||
const ssrfPolicy = allowPrivateNetwork ? { allowPrivateNetwork: true } : {};
|
||||
const client = createBlueBubblesClientFromParts({
|
||||
baseUrl,
|
||||
password,
|
||||
allowPrivateNetwork,
|
||||
timeoutMs: opts.timeoutMs ?? 10000,
|
||||
});
|
||||
|
||||
// Try different common API patterns for fetching messages
|
||||
const possiblePaths = [
|
||||
@@ -100,13 +105,11 @@ export async function fetchBlueBubblesHistory(
|
||||
|
||||
for (const path of possiblePaths) {
|
||||
try {
|
||||
const url = buildBlueBubblesApiUrl({ baseUrl, path, password });
|
||||
const res = await blueBubblesFetchWithTimeout(
|
||||
url,
|
||||
{ method: "GET" },
|
||||
opts.timeoutMs ?? 10000,
|
||||
ssrfPolicy,
|
||||
);
|
||||
const res = await client.request({
|
||||
method: "GET",
|
||||
path,
|
||||
timeoutMs: opts.timeoutMs ?? 10000,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
continue; // Try next path
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
fetchBlueBubblesMessageAttachments,
|
||||
} from "./attachments.js";
|
||||
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
|
||||
import { createBlueBubblesClientFromParts } from "./client.js";
|
||||
import { resolveBlueBubblesConversationRoute } from "./conversation-route.js";
|
||||
import { fetchBlueBubblesHistory } from "./history.js";
|
||||
import {
|
||||
@@ -79,7 +80,6 @@ import {
|
||||
isAllowedBlueBubblesSender,
|
||||
normalizeBlueBubblesHandle,
|
||||
} from "./targets.js";
|
||||
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
||||
|
||||
const DEFAULT_TEXT_LIMIT = 4000;
|
||||
const invalidAckReactions = new Set<string>();
|
||||
@@ -109,10 +109,6 @@ function normalizeSnippet(value: string): string {
|
||||
|
||||
type BlueBubblesChatRecord = Record<string, unknown>;
|
||||
|
||||
function blueBubblesPolicy(allowPrivateNetwork: boolean | undefined) {
|
||||
return allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined;
|
||||
}
|
||||
|
||||
function extractBlueBubblesChatGuid(chat: BlueBubblesChatRecord): string | undefined {
|
||||
const candidates = [chat.chatGuid, chat.guid, chat.chat_guid];
|
||||
for (const candidate of candidates) {
|
||||
@@ -161,25 +157,22 @@ async function queryBlueBubblesChats(params: {
|
||||
limit: number;
|
||||
allowPrivateNetwork?: boolean;
|
||||
}): Promise<BlueBubblesChatRecord[]> {
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
const client = createBlueBubblesClientFromParts({
|
||||
baseUrl: params.baseUrl,
|
||||
path: "/api/v1/chat/query",
|
||||
password: params.password,
|
||||
allowPrivateNetwork: params.allowPrivateNetwork === true,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
const res = await blueBubblesFetchWithTimeout(
|
||||
url,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
with: ["participants"],
|
||||
}),
|
||||
const res = await client.request({
|
||||
method: "POST",
|
||||
path: "/api/v1/chat/query",
|
||||
body: {
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
with: ["participants"],
|
||||
},
|
||||
params.timeoutMs,
|
||||
blueBubblesPolicy(params.allowPrivateNetwork),
|
||||
);
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
if (!res.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -22,12 +22,14 @@ import {
|
||||
setBlueBubblesParticipantContactDepsForTest,
|
||||
} from "./participant-contact-names.js";
|
||||
import type { OpenClawConfig, PluginRuntime } from "./runtime-api.js";
|
||||
import { createBlueBubblesFetchGuardPassthroughInstaller } from "./test-harness.js";
|
||||
import {
|
||||
createBlueBubblesMonitorTestRuntime,
|
||||
EMPTY_DISPATCH_RESULT,
|
||||
resetBlueBubblesMonitorTestState,
|
||||
type DispatchReplyParams,
|
||||
} from "./test-support/monitor-test-support.js";
|
||||
import { _setFetchGuardForTesting } from "./types.js";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("./send.js", () => ({
|
||||
@@ -255,8 +257,16 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
return handled;
|
||||
}
|
||||
|
||||
const installFetchGuardPassthrough = createBlueBubblesFetchGuardPassthroughInstaller();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
// The BlueBubblesClient now routes every BB API call through the SSRF
|
||||
// guard (mode-2 allowlist for configured hostnames). Install a passthrough
|
||||
// that wraps `globalThis.fetch` (our stubbed mockFetch) in a real Response
|
||||
// so guarded callers get the same mocked behavior the pre-migration
|
||||
// callsites did. (#34749, #59722)
|
||||
installFetchGuardPassthrough();
|
||||
mockFetch.mockReset();
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -284,6 +294,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
setBlueBubblesParticipantContactDepsForTest();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
_setFetchGuardForTesting(null);
|
||||
});
|
||||
|
||||
describe("DM pairing behavior vs allowFrom", () => {
|
||||
|
||||
@@ -20,12 +20,14 @@ import {
|
||||
type WebhookRequestParams,
|
||||
} from "./monitor.webhook.test-helpers.js";
|
||||
import type { OpenClawConfig, PluginRuntime } from "./runtime-api.js";
|
||||
import { createBlueBubblesFetchGuardPassthroughInstaller } from "./test-harness.js";
|
||||
import {
|
||||
createBlueBubblesMonitorTestRuntime,
|
||||
EMPTY_DISPATCH_RESULT,
|
||||
resetBlueBubblesMonitorTestState,
|
||||
type DispatchReplyParams,
|
||||
} from "./test-support/monitor-test-support.js";
|
||||
import { _setFetchGuardForTesting } from "./types.js";
|
||||
|
||||
const { TEST_WEBHOOK_RATE_LIMIT_MAX_REQUESTS } = vi.hoisted(() => ({
|
||||
TEST_WEBHOOK_RATE_LIMIT_MAX_REQUESTS: 3,
|
||||
@@ -168,9 +170,13 @@ function createMockRuntime(): PluginRuntime {
|
||||
|
||||
describe("BlueBubbles webhook monitor", () => {
|
||||
let unregister: () => void;
|
||||
const installFetchGuardPassthrough = createBlueBubblesFetchGuardPassthroughInstaller();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
// See monitor.test.ts for rationale — BlueBubblesClient routes every BB
|
||||
// API call through the SSRF guard now. (#34749, #59722)
|
||||
installFetchGuardPassthrough();
|
||||
mockFetch.mockReset();
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -191,6 +197,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
afterEach(() => {
|
||||
unregister?.();
|
||||
vi.unstubAllGlobals();
|
||||
_setFetchGuardForTesting(null);
|
||||
});
|
||||
|
||||
function setupWebhookTarget(params?: {
|
||||
|
||||
@@ -18,15 +18,29 @@ export async function postMultipartFormData(params: {
|
||||
parts: Uint8Array[];
|
||||
timeoutMs: number;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
/**
|
||||
* Extra headers to merge with the multipart Content-Type. Used to forward
|
||||
* auth-decorated headers from `BlueBubblesClient` (e.g. `X-BB-Password`
|
||||
* under header-auth mode). Per-request Content-Type wins over callers so
|
||||
* the multipart boundary is always authoritative. (Greptile #68234 P1)
|
||||
*/
|
||||
extraHeaders?: HeadersInit;
|
||||
}): Promise<Response> {
|
||||
const body = Buffer.from(concatUint8Arrays(params.parts));
|
||||
const headers: Record<string, string> = {};
|
||||
if (params.extraHeaders) {
|
||||
new Headers(params.extraHeaders).forEach((value, key) => {
|
||||
headers[key] = value;
|
||||
});
|
||||
}
|
||||
// Per-request Content-Type wins over callers so the multipart boundary is
|
||||
// always authoritative.
|
||||
headers["Content-Type"] = `multipart/form-data; boundary=${params.boundary}`;
|
||||
return await blueBubblesFetchWithTimeout(
|
||||
params.url,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": `multipart/form-data; boundary=${params.boundary}`,
|
||||
},
|
||||
headers,
|
||||
body,
|
||||
},
|
||||
params.timeoutMs,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { createBlueBubblesClientFromParts } from "./client.js";
|
||||
import type { BaseProbeResult } from "./runtime-api.js";
|
||||
import { normalizeSecretInputString } from "./secret-input.js";
|
||||
import { buildBlueBubblesApiUrl, blueBubblesFetchWithTimeout } from "./types.js";
|
||||
|
||||
export type BlueBubblesProbe = BaseProbeResult & {
|
||||
status?: number | null;
|
||||
@@ -47,15 +47,14 @@ export async function fetchBlueBubblesServerInfo(params: {
|
||||
return cached.info;
|
||||
}
|
||||
|
||||
const ssrfPolicy = params.allowPrivateNetwork ? { allowPrivateNetwork: true } : {};
|
||||
const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/server/info", password });
|
||||
const client = createBlueBubblesClientFromParts({
|
||||
baseUrl,
|
||||
password,
|
||||
allowPrivateNetwork: params.allowPrivateNetwork === true,
|
||||
timeoutMs: params.timeoutMs ?? 5000,
|
||||
});
|
||||
try {
|
||||
const res = await blueBubblesFetchWithTimeout(
|
||||
url,
|
||||
{ method: "GET" },
|
||||
params.timeoutMs ?? 5000,
|
||||
ssrfPolicy,
|
||||
);
|
||||
const res = await client.getServerInfo({ timeoutMs: params.timeoutMs ?? 5000 });
|
||||
if (!res.ok) {
|
||||
return null;
|
||||
}
|
||||
@@ -153,15 +152,14 @@ export async function probeBlueBubbles(params: {
|
||||
if (!password) {
|
||||
return { ok: false, error: "password not configured" };
|
||||
}
|
||||
const probeSsrfPolicy = params.allowPrivateNetwork ? { allowPrivateNetwork: true } : {};
|
||||
const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/ping", password });
|
||||
const client = createBlueBubblesClientFromParts({
|
||||
baseUrl,
|
||||
password,
|
||||
allowPrivateNetwork: params.allowPrivateNetwork === true,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
try {
|
||||
const res = await blueBubblesFetchWithTimeout(
|
||||
url,
|
||||
{ method: "GET" },
|
||||
params.timeoutMs,
|
||||
probeSsrfPolicy,
|
||||
);
|
||||
const res = await client.ping({ timeoutMs: params.timeoutMs });
|
||||
if (!res.ok) {
|
||||
return { ok: false, status: res.status, error: `HTTP ${res.status}` };
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
||||
import { createBlueBubblesClient } from "./client.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
||||
|
||||
export type BlueBubblesReactionOpts = {
|
||||
serverUrl?: string;
|
||||
@@ -112,10 +111,6 @@ const REACTION_EMOJIS = new Map<string, string>([
|
||||
["?", "question"],
|
||||
]);
|
||||
|
||||
function resolveAccount(params: BlueBubblesReactionOpts) {
|
||||
return resolveBlueBubblesServerAccount(params);
|
||||
}
|
||||
|
||||
export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolean): string {
|
||||
const trimmed = emoji.trim();
|
||||
if (!trimmed) {
|
||||
@@ -150,34 +145,22 @@ export async function sendBlueBubblesReaction(params: {
|
||||
throw new Error("BlueBubbles reaction requires messageGuid.");
|
||||
}
|
||||
const reaction = normalizeBlueBubblesReactionInput(params.emoji, params.remove);
|
||||
const { baseUrl, password, accountId, allowPrivateNetwork } = resolveAccount(params.opts ?? {});
|
||||
if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
|
||||
const client = createBlueBubblesClient(params.opts ?? {});
|
||||
if (getCachedBlueBubblesPrivateApiStatus(client.accountId) === false) {
|
||||
throw new Error(
|
||||
"BlueBubbles reaction requires Private API, but it is disabled on the BlueBubbles server.",
|
||||
);
|
||||
}
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
baseUrl,
|
||||
path: "/api/v1/message/react",
|
||||
password,
|
||||
});
|
||||
const payload = {
|
||||
// Go through the client's typed `react` method — it uses the same SSRF policy
|
||||
// as every other client call, eliminating the asymmetric `{}` vs
|
||||
// `{ allowedHostnames }` path that caused #59722.
|
||||
const res = await client.react({
|
||||
chatGuid,
|
||||
selectedMessageGuid: messageGuid,
|
||||
reaction,
|
||||
partIndex: typeof params.partIndex === "number" ? params.partIndex : 0,
|
||||
};
|
||||
const ssrfPolicy = allowPrivateNetwork ? { allowPrivateNetwork: true } : {};
|
||||
const res = await blueBubblesFetchWithTimeout(
|
||||
url,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
params.opts?.timeoutMs,
|
||||
ssrfPolicy,
|
||||
);
|
||||
timeoutMs: params.opts?.timeoutMs,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
throw new Error(`BlueBubbles reaction failed (${res.status}): ${errorText || "unknown"}`);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
stripMarkdown,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
||||
import { createBlueBubblesClient, createBlueBubblesClientFromParts } from "./client.js";
|
||||
import {
|
||||
fetchBlueBubblesServerInfo,
|
||||
getCachedBlueBubblesPrivateApiStatus,
|
||||
@@ -15,16 +16,7 @@ import type { OpenClawConfig } from "./runtime-api.js";
|
||||
import { warnBlueBubbles } from "./runtime.js";
|
||||
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
|
||||
import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
|
||||
import {
|
||||
blueBubblesFetchWithTimeout,
|
||||
buildBlueBubblesApiUrl,
|
||||
type BlueBubblesSendTarget,
|
||||
type SsrFPolicy,
|
||||
} from "./types.js";
|
||||
|
||||
function blueBubblesPolicy(allowPrivateNetwork: boolean | undefined): SsrFPolicy {
|
||||
return allowPrivateNetwork ? { allowPrivateNetwork: true } : {};
|
||||
}
|
||||
import { type BlueBubblesSendTarget } from "./types.js";
|
||||
|
||||
export type BlueBubblesSendOpts = {
|
||||
serverUrl?: string;
|
||||
@@ -206,25 +198,22 @@ async function queryChats(params: {
|
||||
limit: number;
|
||||
allowPrivateNetwork?: boolean;
|
||||
}): Promise<BlueBubblesChatRecord[]> {
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
const client = createBlueBubblesClientFromParts({
|
||||
baseUrl: params.baseUrl,
|
||||
path: "/api/v1/chat/query",
|
||||
password: params.password,
|
||||
allowPrivateNetwork: params.allowPrivateNetwork === true,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
const res = await blueBubblesFetchWithTimeout(
|
||||
url,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
with: ["participants"],
|
||||
}),
|
||||
const res = await client.request({
|
||||
method: "POST",
|
||||
path: "/api/v1/chat/query",
|
||||
body: {
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
with: ["participants"],
|
||||
},
|
||||
params.timeoutMs,
|
||||
blueBubblesPolicy(params.allowPrivateNetwork),
|
||||
);
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
if (!res.ok) {
|
||||
return [];
|
||||
}
|
||||
@@ -341,26 +330,23 @@ export async function createChatForHandle(params: {
|
||||
timeoutMs?: number;
|
||||
allowPrivateNetwork?: boolean;
|
||||
}): Promise<{ chatGuid: string | null; messageId: string }> {
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
const client = createBlueBubblesClientFromParts({
|
||||
baseUrl: params.baseUrl,
|
||||
path: "/api/v1/chat/new",
|
||||
password: params.password,
|
||||
allowPrivateNetwork: params.allowPrivateNetwork === true,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
const payload = {
|
||||
addresses: [params.address],
|
||||
message: params.message ?? "",
|
||||
tempGuid: `temp-${crypto.randomUUID()}`,
|
||||
};
|
||||
const res = await blueBubblesFetchWithTimeout(
|
||||
url,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
params.timeoutMs,
|
||||
blueBubblesPolicy(params.allowPrivateNetwork),
|
||||
);
|
||||
const res = await client.request({
|
||||
method: "POST",
|
||||
path: "/api/v1/chat/new",
|
||||
body: payload,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
if (
|
||||
@@ -539,21 +525,18 @@ export async function sendMessageBlueBubbles(
|
||||
payload.effectId = effectId;
|
||||
}
|
||||
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
baseUrl,
|
||||
path: "/api/v1/message/text",
|
||||
password,
|
||||
const client = createBlueBubblesClient({
|
||||
cfg: opts.cfg ?? {},
|
||||
accountId: opts.accountId,
|
||||
serverUrl: opts.serverUrl,
|
||||
password: opts.password,
|
||||
});
|
||||
const res = await client.request({
|
||||
method: "POST",
|
||||
path: "/api/v1/message/text",
|
||||
body: payload,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
const res = await blueBubblesFetchWithTimeout(
|
||||
url,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
opts.timeoutMs,
|
||||
blueBubblesPolicy(allowPrivateNetwork),
|
||||
);
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
throw new Error(`BlueBubbles send failed (${res.status}): ${errorText || "unknown"}`);
|
||||
|
||||
Reference in New Issue
Block a user