Skip to content

Commit e89b41f

Browse files
authored
fix(bluebubbles): configurable sendTimeoutMs, bump send default to 30s (#69193)
Merged via squash. Prepared head SHA: 358204f Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com> Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com> Reviewed-by: @omarshahine
1 parent ba40142 commit e89b41f

11 files changed

Lines changed: 248 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
1212

1313
- 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.
1414
- 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.
15+
- 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.
1516

1617
## 2026.4.20
1718

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
b199851e694368264c24ba3d347a84764a19f632769e049fe94a82787c5e5d93 config-baseline.json
1+
747a24d0acf12f95ec75feabb47dad8f03ff0e3a7173b4d277c648f75d956ce5 config-baseline.json
22
cbb9a6ee1cb69068d5eb63f00f95512ba19778415ea5b2eabe056aaea38978b5 config-baseline.core.json
3-
0982fc3d264047919333a57dfba1ba948e6639fb19659a400f947dfdd8b8d1de config-baseline.channel.json
3+
e239cc20f20f8d0172812bc0ad3ee6df52da88e2e2702e3d03a47e01561132ae config-baseline.channel.json
44
b695cb31b4c0cf1d31f842f2892e99cc3ff8d84263ae72b72977cae844b81d6e config-baseline.plugin.json

docs/channels/bluebubbles.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,7 @@ Provider options:
384384
- `channels.bluebubbles.sendReadReceipts`: Send read receipts (default: `true`).
385385
- `channels.bluebubbles.blockStreaming`: Enable block streaming (default: `false`; required for streaming replies).
386386
- `channels.bluebubbles.textChunkLimit`: Outbound chunk size in chars (default: 4000).
387+
- `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`.
387388
- `channels.bluebubbles.chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on blank lines (paragraph boundaries) before length chunking.
388389
- `channels.bluebubbles.mediaMaxMb`: Inbound/outbound media cap in MB (default: 8).
389390
- `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`.

extensions/bluebubbles/src/account-resolve.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,71 @@ describe("resolveBlueBubblesServerAccount", () => {
7979
allowPrivateNetworkConfig: true,
8080
});
8181
});
82+
83+
describe("sendTimeoutMs", () => {
84+
it("returns channel-level sendTimeoutMs when configured", () => {
85+
expect(
86+
resolveBlueBubblesServerAccount({
87+
serverUrl: "http://localhost:1234",
88+
password: "test-password",
89+
cfg: {
90+
channels: {
91+
bluebubbles: {
92+
sendTimeoutMs: 45_000,
93+
},
94+
},
95+
},
96+
}),
97+
).toMatchObject({ sendTimeoutMs: 45_000 });
98+
});
99+
100+
it("returns per-account sendTimeoutMs when configured", () => {
101+
expect(
102+
resolveBlueBubblesServerAccount({
103+
accountId: "personal",
104+
cfg: {
105+
channels: {
106+
bluebubbles: {
107+
accounts: {
108+
personal: {
109+
serverUrl: "http://localhost:1234",
110+
password: "test-password",
111+
sendTimeoutMs: 60_000,
112+
},
113+
},
114+
},
115+
},
116+
},
117+
}),
118+
).toMatchObject({ sendTimeoutMs: 60_000 });
119+
});
120+
121+
it("returns undefined sendTimeoutMs when unconfigured (use DEFAULT_SEND_TIMEOUT_MS downstream)", () => {
122+
const resolved = resolveBlueBubblesServerAccount({
123+
serverUrl: "http://localhost:1234",
124+
password: "test-password",
125+
cfg: {},
126+
});
127+
expect(resolved.sendTimeoutMs).toBeUndefined();
128+
});
129+
130+
it("ignores non-positive / non-integer sendTimeoutMs values", () => {
131+
for (const bad of [0, -1, 1.5, Number.NaN]) {
132+
const resolved = resolveBlueBubblesServerAccount({
133+
serverUrl: "http://localhost:1234",
134+
password: "test-password",
135+
cfg: {
136+
channels: {
137+
bluebubbles: {
138+
// runtime might receive a malformed value via raw config; the
139+
// resolver must drop it so downstream falls back to the default.
140+
sendTimeoutMs: bad as unknown as number,
141+
},
142+
},
143+
},
144+
});
145+
expect(resolved.sendTimeoutMs).toBeUndefined();
146+
}
147+
});
148+
});
82149
});

extensions/bluebubbles/src/account-resolve.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv
1919
accountId: string;
2020
allowPrivateNetwork: boolean;
2121
allowPrivateNetworkConfig?: boolean;
22+
/**
23+
* Per-account send timeout from `channels.bluebubbles.sendTimeoutMs` (or
24+
* `accounts.<id>.sendTimeoutMs`). Only returned when the caller configured
25+
* a positive integer; `undefined` means "fall back to DEFAULT_SEND_TIMEOUT_MS".
26+
* (#67486)
27+
*/
28+
sendTimeoutMs?: number;
2229
} {
2330
const account = resolveBlueBubblesAccount({
2431
cfg: params.cfg ?? {},
@@ -49,6 +56,13 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv
4956
throw new Error("BlueBubbles password is required");
5057
}
5158

59+
const rawSendTimeoutMs = account.config.sendTimeoutMs;
60+
const sendTimeoutMs =
61+
typeof rawSendTimeoutMs === "number" &&
62+
Number.isInteger(rawSendTimeoutMs) &&
63+
rawSendTimeoutMs > 0
64+
? rawSendTimeoutMs
65+
: undefined;
5266
return {
5367
baseUrl,
5468
password,
@@ -58,5 +72,6 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv
5872
config: account.config,
5973
}),
6074
allowPrivateNetworkConfig: resolveBlueBubblesPrivateNetworkConfigValue(account.config),
75+
sendTimeoutMs,
6176
};
6277
}

extensions/bluebubbles/src/config-schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ const bluebubblesAccountSchema = z
7979
historyLimit: z.number().int().min(0).optional(),
8080
dmHistoryLimit: z.number().int().min(0).optional(),
8181
textChunkLimit: z.number().int().positive().optional(),
82+
sendTimeoutMs: z.number().int().positive().optional(),
8283
chunkMode: z.enum(["length", "newline"]).optional(),
8384
mediaMaxMb: z.number().int().positive().optional(),
8485
mediaLocalRoots: z.array(z.string()).optional(),

extensions/bluebubbles/src/send.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1386,6 +1386,118 @@ describe("send", () => {
13861386
}
13871387
});
13881388
});
1389+
1390+
describe("send timeout (#67486)", () => {
1391+
// Capture the `timeoutMs` that the SSRF guard receives on each call.
1392+
// Index 0 is the `chat/query` preflight; index 1 is the actual
1393+
// `/api/v1/message/text` POST — that's the one we care about.
1394+
function installTimeoutCapture(): (number | undefined)[] {
1395+
const timeouts: (number | undefined)[] = [];
1396+
_setFetchGuardForTesting(async (guardParams) => {
1397+
timeouts.push(guardParams.timeoutMs);
1398+
const raw = await globalThis.fetch(guardParams.url, guardParams.init);
1399+
// Mirrors `createBlueBubblesFetchGuardPassthroughInstaller` so both
1400+
// `.json()`-only chat-query mocks and `.text()`-only send mocks work.
1401+
let body: ArrayBuffer;
1402+
if (
1403+
typeof (raw as { arrayBuffer?: () => Promise<ArrayBuffer> }).arrayBuffer === "function"
1404+
) {
1405+
body = await (raw as { arrayBuffer: () => Promise<ArrayBuffer> }).arrayBuffer();
1406+
} else {
1407+
const text =
1408+
typeof (raw as { text?: () => Promise<string> }).text === "function"
1409+
? await (raw as { text: () => Promise<string> }).text()
1410+
: typeof (raw as { json?: () => Promise<unknown> }).json === "function"
1411+
? JSON.stringify(await (raw as { json: () => Promise<unknown> }).json())
1412+
: "";
1413+
body = new TextEncoder().encode(text).buffer;
1414+
}
1415+
return {
1416+
response: new Response(body, {
1417+
status: (raw as { status?: number }).status ?? 200,
1418+
headers: (raw as { headers?: HeadersInit }).headers,
1419+
}),
1420+
release: async () => {},
1421+
finalUrl: guardParams.url,
1422+
};
1423+
});
1424+
return timeouts;
1425+
}
1426+
1427+
it("defaults the /message/text send to DEFAULT_SEND_TIMEOUT_MS (30s), not 10s", async () => {
1428+
const timeouts = installTimeoutCapture();
1429+
mockResolvedHandleTarget();
1430+
mockSendResponse({ data: { guid: "msg-default-timeout" } });
1431+
1432+
try {
1433+
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
1434+
serverUrl: "http://localhost:1234",
1435+
password: "test",
1436+
});
1437+
expect(result.messageId).toBe("msg-default-timeout");
1438+
// chat/query preflight must stay at the short default; only the send POST rises.
1439+
expect(timeouts[0]).toBe(10_000);
1440+
expect(timeouts[1]).toBe(30_000);
1441+
} finally {
1442+
_setFetchGuardForTesting(null);
1443+
}
1444+
});
1445+
1446+
it("honors channels.bluebubbles.sendTimeoutMs from config for the send POST", async () => {
1447+
const timeouts = installTimeoutCapture();
1448+
mockResolvedHandleTarget();
1449+
mockSendResponse({ data: { guid: "msg-config-timeout" } });
1450+
1451+
try {
1452+
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
1453+
cfg: {
1454+
channels: {
1455+
bluebubbles: {
1456+
serverUrl: "http://localhost:1234",
1457+
password: "test",
1458+
sendTimeoutMs: 45_000,
1459+
},
1460+
},
1461+
},
1462+
});
1463+
expect(result.messageId).toBe("msg-config-timeout");
1464+
// chat/query preflight must stay at the short default; only the send POST rises.
1465+
expect(timeouts[0]).toBe(10_000);
1466+
expect(timeouts[1]).toBe(45_000);
1467+
} finally {
1468+
_setFetchGuardForTesting(null);
1469+
}
1470+
});
1471+
1472+
it("explicit opts.timeoutMs wins over both config and default", async () => {
1473+
const timeouts = installTimeoutCapture();
1474+
mockResolvedHandleTarget();
1475+
mockSendResponse({ data: { guid: "msg-explicit-timeout" } });
1476+
1477+
try {
1478+
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
1479+
cfg: {
1480+
channels: {
1481+
bluebubbles: {
1482+
serverUrl: "http://localhost:1234",
1483+
password: "test",
1484+
sendTimeoutMs: 45_000,
1485+
},
1486+
},
1487+
},
1488+
timeoutMs: 90_000,
1489+
});
1490+
expect(result.messageId).toBe("msg-explicit-timeout");
1491+
// Explicit opts.timeoutMs is forwarded to every call site, including
1492+
// the chat/query preflight — the only override that can push that
1493+
// preflight above the 10s default.
1494+
expect(timeouts[0]).toBe(90_000);
1495+
expect(timeouts[1]).toBe(90_000);
1496+
} finally {
1497+
_setFetchGuardForTesting(null);
1498+
}
1499+
});
1500+
});
13891501
});
13901502

13911503
describe("createChatForHandle", () => {

extensions/bluebubbles/src/send.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import type { OpenClawConfig } from "./runtime-api.js";
1717
import { warnBlueBubbles } from "./runtime.js";
1818
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
1919
import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
20-
import { type BlueBubblesSendTarget } from "./types.js";
20+
import { DEFAULT_SEND_TIMEOUT_MS, type BlueBubblesSendTarget } from "./types.js";
2121

2222
export type BlueBubblesSendOpts = {
2323
serverUrl?: string;
@@ -497,12 +497,18 @@ export async function sendMessageBlueBubbles(
497497
throw new Error("BlueBubbles send requires text (message was empty after markdown removal)");
498498
}
499499

500-
const { baseUrl, password, accountId, allowPrivateNetwork } = resolveBlueBubblesServerAccount({
501-
cfg: opts.cfg ?? {},
502-
accountId: opts.accountId,
503-
serverUrl: opts.serverUrl,
504-
password: opts.password,
505-
});
500+
const { baseUrl, password, accountId, allowPrivateNetwork, sendTimeoutMs } =
501+
resolveBlueBubblesServerAccount({
502+
cfg: opts.cfg ?? {},
503+
accountId: opts.accountId,
504+
serverUrl: opts.serverUrl,
505+
password: opts.password,
506+
});
507+
// Send-path timeout: explicit caller override > per-account config > 30s default.
508+
// Kept separate from the default 10s client timeout so chat lookups, probes,
509+
// and health checks stay snappy while actual sends can ride out macOS 26
510+
// Private API stalls. (#67486)
511+
const effectiveSendTimeoutMs = opts.timeoutMs ?? sendTimeoutMs ?? DEFAULT_SEND_TIMEOUT_MS;
506512
let privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId);
507513

508514
const target = resolveBlueBubblesSendTarget(to);
@@ -522,7 +528,7 @@ export async function sendMessageBlueBubbles(
522528
password,
523529
address: target.address,
524530
message: strippedText,
525-
timeoutMs: opts.timeoutMs,
531+
timeoutMs: effectiveSendTimeoutMs,
526532
allowPrivateNetwork,
527533
});
528534
}
@@ -602,7 +608,7 @@ export async function sendMessageBlueBubbles(
602608
method: "POST",
603609
path: "/api/v1/message/text",
604610
body: payload,
605-
timeoutMs: opts.timeoutMs,
611+
timeoutMs: effectiveSendTimeoutMs,
606612
});
607613
if (!res.ok) {
608614
const errorText = await res.text();

extensions/bluebubbles/src/types.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,19 @@ export type BlueBubblesAccountConfig = {
6262
dms?: Record<string, unknown>;
6363
/** Outbound text chunk size (chars). Default: 4000. */
6464
textChunkLimit?: number;
65+
/**
66+
* Per-request timeout (ms) for outbound text sends via
67+
* `/api/v1/message/text` and the `createNewChatWithMessage` send path.
68+
* Probes, chat lookups, catchup, and history keep the shorter default.
69+
* Raise this on macOS 26 setups where Private API iMessage sends can stall
70+
* for 60+s. Default: 30000.
71+
*
72+
* Reaction and edit paths (`sendBlueBubblesReaction`,
73+
* `editBlueBubblesMessage`, `unsendBlueBubblesMessage`) still honor the
74+
* shorter client default unless the caller passes `opts.timeoutMs` — covering
75+
* those uniformly from config is tracked as a follow-up. (#67486)
76+
*/
77+
sendTimeoutMs?: number;
6578
/** Chunking mode: "newline" (default) splits on every newline; "length" splits by size. */
6679
chunkMode?: "length" | "newline";
6780
blockStreaming?: boolean;
@@ -116,6 +129,16 @@ export type BlueBubblesAttachment = {
116129

117130
const DEFAULT_TIMEOUT_MS = 10_000;
118131

132+
/**
133+
* Default timeout for outbound message sends via `/api/v1/message/text` and
134+
* the `createNewChatWithMessage` flow. Larger than `DEFAULT_TIMEOUT_MS` because
135+
* Private API iMessage sends on macOS 26 (Tahoe) can stall for 60+ seconds
136+
* inside the iMessage framework. Callers can override per-call via
137+
* `opts.timeoutMs` or per-account via `channels.bluebubbles.sendTimeoutMs`.
138+
* (#67486)
139+
*/
140+
export const DEFAULT_SEND_TIMEOUT_MS = 30_000;
141+
119142
export function normalizeBlueBubblesServerUrl(raw: string): string {
120143
const trimmed = raw.trim();
121144
if (!trimmed) {

src/config/bundled-channel-config-metadata.generated.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,11 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
214214
exclusiveMinimum: 0,
215215
maximum: 9007199254740991,
216216
},
217+
sendTimeoutMs: {
218+
type: "integer",
219+
exclusiveMinimum: 0,
220+
maximum: 9007199254740991,
221+
},
217222
chunkMode: {
218223
type: "string",
219224
enum: ["length", "newline"],
@@ -520,6 +525,11 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
520525
exclusiveMinimum: 0,
521526
maximum: 9007199254740991,
522527
},
528+
sendTimeoutMs: {
529+
type: "integer",
530+
exclusiveMinimum: 0,
531+
maximum: 9007199254740991,
532+
},
523533
chunkMode: {
524534
type: "string",
525535
enum: ["length", "newline"],

0 commit comments

Comments
 (0)