mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:00:43 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user