fix(bluebubbles): configurable sendTimeoutMs, bump send default to 30s (#69193)

Merged via squash.

Prepared head SHA: 358204f963
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:
Omar Shahine
2026-04-20 10:04:52 -07:00
committed by GitHub
parent ba40142f71
commit e89b41fce7
11 changed files with 248 additions and 11 deletions

View File

@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
- Cron/delivery: treat explicit `delivery.mode: "none"` runs as not requested even if the runner reports `delivered: false`, so no-delivery cron jobs no longer persist false delivery failures or errors. (#69285) Thanks @matsuri1987.
- Plugins/install: repair active and default-enabled bundled plugin runtime dependencies before import in packaged installs, so bundled Discord, WhatsApp, Slack, Telegram, and provider plugins work without putting their dependency trees in core.
- BlueBubbles: raise the outbound `/api/v1/message/text` send timeout default from 10s to 30s, and add a configurable `channels.bluebubbles.sendTimeoutMs` (also per-account) so macOS 26 setups where Private API iMessage sends stall for 60+ seconds no longer silently lose messages at the 10s abort. Probes, chat lookups, and health checks keep the shorter 10s default. Fixes #67486. (#69193) Thanks @omarshahine.
## 2026.4.20

View File

@@ -1,4 +1,4 @@
b199851e694368264c24ba3d347a84764a19f632769e049fe94a82787c5e5d93 config-baseline.json
747a24d0acf12f95ec75feabb47dad8f03ff0e3a7173b4d277c648f75d956ce5 config-baseline.json
cbb9a6ee1cb69068d5eb63f00f95512ba19778415ea5b2eabe056aaea38978b5 config-baseline.core.json
0982fc3d264047919333a57dfba1ba948e6639fb19659a400f947dfdd8b8d1de config-baseline.channel.json
e239cc20f20f8d0172812bc0ad3ee6df52da88e2e2702e3d03a47e01561132ae config-baseline.channel.json
b695cb31b4c0cf1d31f842f2892e99cc3ff8d84263ae72b72977cae844b81d6e config-baseline.plugin.json

View File

@@ -384,6 +384,7 @@ Provider options:
- `channels.bluebubbles.sendReadReceipts`: Send read receipts (default: `true`).
- `channels.bluebubbles.blockStreaming`: Enable block streaming (default: `false`; required for streaming replies).
- `channels.bluebubbles.textChunkLimit`: Outbound chunk size in chars (default: 4000).
- `channels.bluebubbles.sendTimeoutMs`: Per-request timeout in ms for outbound text sends via `/api/v1/message/text` (default: 30000). Raise on macOS 26 setups where Private API iMessage sends can stall for 60+ seconds inside the iMessage framework; for example `45000` or `60000`. Probes, chat lookups, reactions, edits, and health checks currently keep the shorter 10s default; broadening coverage to reactions and edits is planned as a follow-up. Per-account override: `channels.bluebubbles.accounts.<accountId>.sendTimeoutMs`.
- `channels.bluebubbles.chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on blank lines (paragraph boundaries) before length chunking.
- `channels.bluebubbles.mediaMaxMb`: Inbound/outbound media cap in MB (default: 8).
- `channels.bluebubbles.mediaLocalRoots`: Explicit allowlist of absolute local directories permitted for outbound local media paths. Local path sends are denied by default unless this is configured. Per-account override: `channels.bluebubbles.accounts.<accountId>.mediaLocalRoots`.

View File

@@ -79,4 +79,71 @@ describe("resolveBlueBubblesServerAccount", () => {
allowPrivateNetworkConfig: true,
});
});
describe("sendTimeoutMs", () => {
it("returns channel-level sendTimeoutMs when configured", () => {
expect(
resolveBlueBubblesServerAccount({
serverUrl: "http://localhost:1234",
password: "test-password",
cfg: {
channels: {
bluebubbles: {
sendTimeoutMs: 45_000,
},
},
},
}),
).toMatchObject({ sendTimeoutMs: 45_000 });
});
it("returns per-account sendTimeoutMs when configured", () => {
expect(
resolveBlueBubblesServerAccount({
accountId: "personal",
cfg: {
channels: {
bluebubbles: {
accounts: {
personal: {
serverUrl: "http://localhost:1234",
password: "test-password",
sendTimeoutMs: 60_000,
},
},
},
},
},
}),
).toMatchObject({ sendTimeoutMs: 60_000 });
});
it("returns undefined sendTimeoutMs when unconfigured (use DEFAULT_SEND_TIMEOUT_MS downstream)", () => {
const resolved = resolveBlueBubblesServerAccount({
serverUrl: "http://localhost:1234",
password: "test-password",
cfg: {},
});
expect(resolved.sendTimeoutMs).toBeUndefined();
});
it("ignores non-positive / non-integer sendTimeoutMs values", () => {
for (const bad of [0, -1, 1.5, Number.NaN]) {
const resolved = resolveBlueBubblesServerAccount({
serverUrl: "http://localhost:1234",
password: "test-password",
cfg: {
channels: {
bluebubbles: {
// runtime might receive a malformed value via raw config; the
// resolver must drop it so downstream falls back to the default.
sendTimeoutMs: bad as unknown as number,
},
},
},
});
expect(resolved.sendTimeoutMs).toBeUndefined();
}
});
});
});

View File

@@ -19,6 +19,13 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv
accountId: string;
allowPrivateNetwork: boolean;
allowPrivateNetworkConfig?: boolean;
/**
* Per-account send timeout from `channels.bluebubbles.sendTimeoutMs` (or
* `accounts.<id>.sendTimeoutMs`). Only returned when the caller configured
* a positive integer; `undefined` means "fall back to DEFAULT_SEND_TIMEOUT_MS".
* (#67486)
*/
sendTimeoutMs?: number;
} {
const account = resolveBlueBubblesAccount({
cfg: params.cfg ?? {},
@@ -49,6 +56,13 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv
throw new Error("BlueBubbles password is required");
}
const rawSendTimeoutMs = account.config.sendTimeoutMs;
const sendTimeoutMs =
typeof rawSendTimeoutMs === "number" &&
Number.isInteger(rawSendTimeoutMs) &&
rawSendTimeoutMs > 0
? rawSendTimeoutMs
: undefined;
return {
baseUrl,
password,
@@ -58,5 +72,6 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv
config: account.config,
}),
allowPrivateNetworkConfig: resolveBlueBubblesPrivateNetworkConfigValue(account.config),
sendTimeoutMs,
};
}

View File

@@ -79,6 +79,7 @@ const bluebubblesAccountSchema = z
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
textChunkLimit: z.number().int().positive().optional(),
sendTimeoutMs: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
mediaMaxMb: z.number().int().positive().optional(),
mediaLocalRoots: z.array(z.string()).optional(),

View File

@@ -1386,6 +1386,118 @@ describe("send", () => {
}
});
});
describe("send timeout (#67486)", () => {
// Capture the `timeoutMs` that the SSRF guard receives on each call.
// Index 0 is the `chat/query` preflight; index 1 is the actual
// `/api/v1/message/text` POST — that's the one we care about.
function installTimeoutCapture(): (number | undefined)[] {
const timeouts: (number | undefined)[] = [];
_setFetchGuardForTesting(async (guardParams) => {
timeouts.push(guardParams.timeoutMs);
const raw = await globalThis.fetch(guardParams.url, guardParams.init);
// Mirrors `createBlueBubblesFetchGuardPassthroughInstaller` so both
// `.json()`-only chat-query mocks and `.text()`-only send mocks work.
let body: ArrayBuffer;
if (
typeof (raw as { arrayBuffer?: () => Promise<ArrayBuffer> }).arrayBuffer === "function"
) {
body = await (raw as { arrayBuffer: () => Promise<ArrayBuffer> }).arrayBuffer();
} else {
const text =
typeof (raw as { text?: () => Promise<string> }).text === "function"
? await (raw as { text: () => Promise<string> }).text()
: typeof (raw as { json?: () => Promise<unknown> }).json === "function"
? JSON.stringify(await (raw as { json: () => Promise<unknown> }).json())
: "";
body = new TextEncoder().encode(text).buffer;
}
return {
response: new Response(body, {
status: (raw as { status?: number }).status ?? 200,
headers: (raw as { headers?: HeadersInit }).headers,
}),
release: async () => {},
finalUrl: guardParams.url,
};
});
return timeouts;
}
it("defaults the /message/text send to DEFAULT_SEND_TIMEOUT_MS (30s), not 10s", async () => {
const timeouts = installTimeoutCapture();
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-default-timeout" } });
try {
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(result.messageId).toBe("msg-default-timeout");
// chat/query preflight must stay at the short default; only the send POST rises.
expect(timeouts[0]).toBe(10_000);
expect(timeouts[1]).toBe(30_000);
} finally {
_setFetchGuardForTesting(null);
}
});
it("honors channels.bluebubbles.sendTimeoutMs from config for the send POST", async () => {
const timeouts = installTimeoutCapture();
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-config-timeout" } });
try {
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
cfg: {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test",
sendTimeoutMs: 45_000,
},
},
},
});
expect(result.messageId).toBe("msg-config-timeout");
// chat/query preflight must stay at the short default; only the send POST rises.
expect(timeouts[0]).toBe(10_000);
expect(timeouts[1]).toBe(45_000);
} finally {
_setFetchGuardForTesting(null);
}
});
it("explicit opts.timeoutMs wins over both config and default", async () => {
const timeouts = installTimeoutCapture();
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-explicit-timeout" } });
try {
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
cfg: {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test",
sendTimeoutMs: 45_000,
},
},
},
timeoutMs: 90_000,
});
expect(result.messageId).toBe("msg-explicit-timeout");
// Explicit opts.timeoutMs is forwarded to every call site, including
// the chat/query preflight — the only override that can push that
// preflight above the 10s default.
expect(timeouts[0]).toBe(90_000);
expect(timeouts[1]).toBe(90_000);
} finally {
_setFetchGuardForTesting(null);
}
});
});
});
describe("createChatForHandle", () => {

View File

@@ -17,7 +17,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 { type BlueBubblesSendTarget } from "./types.js";
import { DEFAULT_SEND_TIMEOUT_MS, type BlueBubblesSendTarget } from "./types.js";
export type BlueBubblesSendOpts = {
serverUrl?: string;
@@ -497,12 +497,18 @@ export async function sendMessageBlueBubbles(
throw new Error("BlueBubbles send requires text (message was empty after markdown removal)");
}
const { baseUrl, password, accountId, allowPrivateNetwork } = resolveBlueBubblesServerAccount({
cfg: opts.cfg ?? {},
accountId: opts.accountId,
serverUrl: opts.serverUrl,
password: opts.password,
});
const { baseUrl, password, accountId, allowPrivateNetwork, sendTimeoutMs } =
resolveBlueBubblesServerAccount({
cfg: opts.cfg ?? {},
accountId: opts.accountId,
serverUrl: opts.serverUrl,
password: opts.password,
});
// Send-path timeout: explicit caller override > per-account config > 30s default.
// Kept separate from the default 10s client timeout so chat lookups, probes,
// and health checks stay snappy while actual sends can ride out macOS 26
// Private API stalls. (#67486)
const effectiveSendTimeoutMs = opts.timeoutMs ?? sendTimeoutMs ?? DEFAULT_SEND_TIMEOUT_MS;
let privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId);
const target = resolveBlueBubblesSendTarget(to);
@@ -522,7 +528,7 @@ export async function sendMessageBlueBubbles(
password,
address: target.address,
message: strippedText,
timeoutMs: opts.timeoutMs,
timeoutMs: effectiveSendTimeoutMs,
allowPrivateNetwork,
});
}
@@ -602,7 +608,7 @@ export async function sendMessageBlueBubbles(
method: "POST",
path: "/api/v1/message/text",
body: payload,
timeoutMs: opts.timeoutMs,
timeoutMs: effectiveSendTimeoutMs,
});
if (!res.ok) {
const errorText = await res.text();

View File

@@ -62,6 +62,19 @@ export type BlueBubblesAccountConfig = {
dms?: Record<string, unknown>;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/**
* Per-request timeout (ms) for outbound text sends via
* `/api/v1/message/text` and the `createNewChatWithMessage` send path.
* Probes, chat lookups, catchup, and history keep the shorter default.
* Raise this on macOS 26 setups where Private API iMessage sends can stall
* for 60+s. Default: 30000.
*
* Reaction and edit paths (`sendBlueBubblesReaction`,
* `editBlueBubblesMessage`, `unsendBlueBubblesMessage`) still honor the
* shorter client default unless the caller passes `opts.timeoutMs` — covering
* those uniformly from config is tracked as a follow-up. (#67486)
*/
sendTimeoutMs?: number;
/** Chunking mode: "newline" (default) splits on every newline; "length" splits by size. */
chunkMode?: "length" | "newline";
blockStreaming?: boolean;
@@ -116,6 +129,16 @@ export type BlueBubblesAttachment = {
const DEFAULT_TIMEOUT_MS = 10_000;
/**
* Default timeout for outbound message sends via `/api/v1/message/text` and
* the `createNewChatWithMessage` flow. Larger than `DEFAULT_TIMEOUT_MS` because
* Private API iMessage sends on macOS 26 (Tahoe) can stall for 60+ seconds
* inside the iMessage framework. Callers can override per-call via
* `opts.timeoutMs` or per-account via `channels.bluebubbles.sendTimeoutMs`.
* (#67486)
*/
export const DEFAULT_SEND_TIMEOUT_MS = 30_000;
export function normalizeBlueBubblesServerUrl(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {

View File

@@ -214,6 +214,11 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
sendTimeoutMs: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
chunkMode: {
type: "string",
enum: ["length", "newline"],
@@ -520,6 +525,11 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
sendTimeoutMs: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
chunkMode: {
type: "string",
enum: ["length", "newline"],

View File

@@ -1441,6 +1441,7 @@ export const BlueBubblesAccountSchemaBase = z
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
sendTimeoutMs: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
mediaMaxMb: z.number().int().positive().optional(),
mediaLocalRoots: z.array(z.string()).optional(),