@openclaw/bluebubbles 2026.2.21 → 2026.2.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/bluebubbles",
3
- "version": "2026.2.21",
3
+ "version": "2026.2.23",
4
4
  "description": "OpenClaw BlueBubbles channel plugin",
5
5
  "type": "module",
6
6
  "devDependencies": {
@@ -12,6 +12,7 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv
12
12
  baseUrl: string;
13
13
  password: string;
14
14
  accountId: string;
15
+ allowPrivateNetwork: boolean;
15
16
  } {
16
17
  const account = resolveBlueBubblesAccount({
17
18
  cfg: params.cfg ?? {},
@@ -25,5 +26,10 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv
25
26
  if (!password) {
26
27
  throw new Error("BlueBubbles password is required");
27
28
  }
28
- return { baseUrl, password, accountId: account.accountId };
29
+ return {
30
+ baseUrl,
31
+ password,
32
+ accountId: account.accountId,
33
+ allowPrivateNetwork: account.config.allowPrivateNetwork === true,
34
+ };
29
35
  }
@@ -3,17 +3,10 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
3
3
  import { bluebubblesMessageActions } from "./actions.js";
4
4
  import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
5
5
 
6
- vi.mock("./accounts.js", () => ({
7
- resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
8
- const config = cfg?.channels?.bluebubbles ?? {};
9
- return {
10
- accountId: accountId ?? "default",
11
- enabled: config.enabled !== false,
12
- configured: Boolean(config.serverUrl && config.password),
13
- config,
14
- };
15
- }),
16
- }));
6
+ vi.mock("./accounts.js", async () => {
7
+ const { createBlueBubblesAccountsMockModule } = await import("./test-harness.js");
8
+ return createBlueBubblesAccountsMockModule();
9
+ });
17
10
 
18
11
  vi.mock("./reactions.js", () => ({
19
12
  sendBlueBubblesReaction: vi.fn().mockResolvedValue(undefined),
@@ -54,6 +47,22 @@ describe("bluebubblesMessageActions", () => {
54
47
  const handleAction = bluebubblesMessageActions.handleAction!;
55
48
  const callHandleAction = (ctx: Omit<Parameters<typeof handleAction>[0], "channel">) =>
56
49
  handleAction({ channel: "bluebubbles", ...ctx });
50
+ const blueBubblesConfig = (): OpenClawConfig => ({
51
+ channels: {
52
+ bluebubbles: {
53
+ serverUrl: "http://localhost:1234",
54
+ password: "test-password",
55
+ },
56
+ },
57
+ });
58
+ const runReactAction = async (params: Record<string, unknown>) => {
59
+ return await callHandleAction({
60
+ action: "react",
61
+ params,
62
+ cfg: blueBubblesConfig(),
63
+ accountId: null,
64
+ });
65
+ };
57
66
 
58
67
  beforeEach(() => {
59
68
  vi.clearAllMocks();
@@ -292,23 +301,10 @@ describe("bluebubblesMessageActions", () => {
292
301
  it("sends reaction successfully with chatGuid", async () => {
293
302
  const { sendBlueBubblesReaction } = await import("./reactions.js");
294
303
 
295
- const cfg: OpenClawConfig = {
296
- channels: {
297
- bluebubbles: {
298
- serverUrl: "http://localhost:1234",
299
- password: "test-password",
300
- },
301
- },
302
- };
303
- const result = await callHandleAction({
304
- action: "react",
305
- params: {
306
- emoji: "❤️",
307
- messageId: "msg-123",
308
- chatGuid: "iMessage;-;+15551234567",
309
- },
310
- cfg,
311
- accountId: null,
304
+ const result = await runReactAction({
305
+ emoji: "❤️",
306
+ messageId: "msg-123",
307
+ chatGuid: "iMessage;-;+15551234567",
312
308
  });
313
309
 
314
310
  expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
@@ -327,24 +323,11 @@ describe("bluebubblesMessageActions", () => {
327
323
  it("sends reaction removal successfully", async () => {
328
324
  const { sendBlueBubblesReaction } = await import("./reactions.js");
329
325
 
330
- const cfg: OpenClawConfig = {
331
- channels: {
332
- bluebubbles: {
333
- serverUrl: "http://localhost:1234",
334
- password: "test-password",
335
- },
336
- },
337
- };
338
- const result = await callHandleAction({
339
- action: "react",
340
- params: {
341
- emoji: "❤️",
342
- messageId: "msg-123",
343
- chatGuid: "iMessage;-;+15551234567",
344
- remove: true,
345
- },
346
- cfg,
347
- accountId: null,
326
+ const result = await runReactAction({
327
+ emoji: "❤️",
328
+ messageId: "msg-123",
329
+ chatGuid: "iMessage;-;+15551234567",
330
+ remove: true,
348
331
  });
349
332
 
350
333
  expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
package/src/actions.ts CHANGED
@@ -2,13 +2,13 @@ import {
2
2
  BLUEBUBBLES_ACTION_NAMES,
3
3
  BLUEBUBBLES_ACTIONS,
4
4
  createActionGate,
5
+ extractToolSend,
5
6
  jsonResult,
6
7
  readNumberParam,
7
8
  readReactionParams,
8
9
  readStringParam,
9
10
  type ChannelMessageActionAdapter,
10
11
  type ChannelMessageActionName,
11
- type ChannelToolSend,
12
12
  } from "openclaw/plugin-sdk";
13
13
  import { resolveBlueBubblesAccount } from "./accounts.js";
14
14
  import { sendBlueBubblesAttachment } from "./attachments.js";
@@ -112,18 +112,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
112
112
  return Array.from(actions);
113
113
  },
114
114
  supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
115
- extractToolSend: ({ args }): ChannelToolSend | null => {
116
- const action = typeof args.action === "string" ? args.action.trim() : "";
117
- if (action !== "sendMessage") {
118
- return null;
119
- }
120
- const to = typeof args.to === "string" ? args.to : undefined;
121
- if (!to) {
122
- return null;
123
- }
124
- const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
125
- return { to, accountId };
126
- },
115
+ extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
127
116
  handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
128
117
  const account = resolveBlueBubblesAccount({
129
118
  cfg: cfg,
@@ -1,18 +1,87 @@
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk";
1
2
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
3
  import "./test-mocks.js";
3
4
  import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
4
5
  import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
5
- import { installBlueBubblesFetchTestHooks } from "./test-harness.js";
6
+ import { setBlueBubblesRuntime } from "./runtime.js";
7
+ import {
8
+ BLUE_BUBBLES_PRIVATE_API_STATUS,
9
+ installBlueBubblesFetchTestHooks,
10
+ mockBlueBubblesPrivateApiStatus,
11
+ mockBlueBubblesPrivateApiStatusOnce,
12
+ } from "./test-harness.js";
6
13
  import type { BlueBubblesAttachment } from "./types.js";
7
14
 
8
15
  const mockFetch = vi.fn();
16
+ const fetchRemoteMediaMock = vi.fn(
17
+ async (params: {
18
+ url: string;
19
+ maxBytes?: number;
20
+ fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
21
+ }) => {
22
+ const fetchFn = params.fetchImpl ?? fetch;
23
+ const res = await fetchFn(params.url);
24
+ if (!res.ok) {
25
+ const text = await res.text().catch(() => "unknown");
26
+ throw new Error(
27
+ `Failed to fetch media from ${params.url}: HTTP ${res.status}; body: ${text}`,
28
+ );
29
+ }
30
+ const buffer = Buffer.from(await res.arrayBuffer());
31
+ if (typeof params.maxBytes === "number" && buffer.byteLength > params.maxBytes) {
32
+ const error = new Error(`payload exceeds maxBytes ${params.maxBytes}`) as Error & {
33
+ code?: string;
34
+ };
35
+ error.code = "max_bytes";
36
+ throw error;
37
+ }
38
+ return {
39
+ buffer,
40
+ contentType: res.headers.get("content-type") ?? undefined,
41
+ fileName: undefined,
42
+ };
43
+ },
44
+ );
9
45
 
10
46
  installBlueBubblesFetchTestHooks({
11
47
  mockFetch,
12
48
  privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus),
13
49
  });
14
50
 
51
+ const runtimeStub = {
52
+ channel: {
53
+ media: {
54
+ fetchRemoteMedia:
55
+ fetchRemoteMediaMock as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"],
56
+ },
57
+ },
58
+ } as unknown as PluginRuntime;
59
+
15
60
  describe("downloadBlueBubblesAttachment", () => {
61
+ beforeEach(() => {
62
+ fetchRemoteMediaMock.mockClear();
63
+ mockFetch.mockReset();
64
+ setBlueBubblesRuntime(runtimeStub);
65
+ });
66
+
67
+ async function expectAttachmentTooLarge(params: { bufferBytes: number; maxBytes?: number }) {
68
+ const largeBuffer = new Uint8Array(params.bufferBytes);
69
+ mockFetch.mockResolvedValueOnce({
70
+ ok: true,
71
+ headers: new Headers(),
72
+ arrayBuffer: () => Promise.resolve(largeBuffer.buffer),
73
+ });
74
+
75
+ const attachment: BlueBubblesAttachment = { guid: "att-large" };
76
+ await expect(
77
+ downloadBlueBubblesAttachment(attachment, {
78
+ serverUrl: "http://localhost:1234",
79
+ password: "test",
80
+ ...(params.maxBytes === undefined ? {} : { maxBytes: params.maxBytes }),
81
+ }),
82
+ ).rejects.toThrow("too large");
83
+ }
84
+
16
85
  it("throws when guid is missing", async () => {
17
86
  const attachment: BlueBubblesAttachment = {};
18
87
  await expect(
@@ -120,42 +189,18 @@ describe("downloadBlueBubblesAttachment", () => {
120
189
  serverUrl: "http://localhost:1234",
121
190
  password: "test",
122
191
  }),
123
- ).rejects.toThrow("download failed (404): Attachment not found");
192
+ ).rejects.toThrow("Attachment not found");
124
193
  });
125
194
 
126
195
  it("throws when attachment exceeds max bytes", async () => {
127
- const largeBuffer = new Uint8Array(10 * 1024 * 1024);
128
- mockFetch.mockResolvedValueOnce({
129
- ok: true,
130
- headers: new Headers(),
131
- arrayBuffer: () => Promise.resolve(largeBuffer.buffer),
196
+ await expectAttachmentTooLarge({
197
+ bufferBytes: 10 * 1024 * 1024,
198
+ maxBytes: 5 * 1024 * 1024,
132
199
  });
133
-
134
- const attachment: BlueBubblesAttachment = { guid: "att-large" };
135
- await expect(
136
- downloadBlueBubblesAttachment(attachment, {
137
- serverUrl: "http://localhost:1234",
138
- password: "test",
139
- maxBytes: 5 * 1024 * 1024,
140
- }),
141
- ).rejects.toThrow("too large");
142
200
  });
143
201
 
144
202
  it("uses default max bytes when not specified", async () => {
145
- const largeBuffer = new Uint8Array(9 * 1024 * 1024);
146
- mockFetch.mockResolvedValueOnce({
147
- ok: true,
148
- headers: new Headers(),
149
- arrayBuffer: () => Promise.resolve(largeBuffer.buffer),
150
- });
151
-
152
- const attachment: BlueBubblesAttachment = { guid: "att-large" };
153
- await expect(
154
- downloadBlueBubblesAttachment(attachment, {
155
- serverUrl: "http://localhost:1234",
156
- password: "test",
157
- }),
158
- ).rejects.toThrow("too large");
203
+ await expectAttachmentTooLarge({ bufferBytes: 9 * 1024 * 1024 });
159
204
  });
160
205
 
161
206
  it("uses attachment mimeType as fallback when response has no content-type", async () => {
@@ -223,14 +268,62 @@ describe("downloadBlueBubblesAttachment", () => {
223
268
  expect(calledUrl).toContain("password=config-password");
224
269
  expect(result.buffer).toEqual(new Uint8Array([1]));
225
270
  });
271
+
272
+ it("passes ssrfPolicy with allowPrivateNetwork when config enables it", async () => {
273
+ const mockBuffer = new Uint8Array([1]);
274
+ mockFetch.mockResolvedValueOnce({
275
+ ok: true,
276
+ headers: new Headers(),
277
+ arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
278
+ });
279
+
280
+ const attachment: BlueBubblesAttachment = { guid: "att-ssrf" };
281
+ await downloadBlueBubblesAttachment(attachment, {
282
+ cfg: {
283
+ channels: {
284
+ bluebubbles: {
285
+ serverUrl: "http://localhost:1234",
286
+ password: "test",
287
+ allowPrivateNetwork: true,
288
+ },
289
+ },
290
+ },
291
+ });
292
+
293
+ const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>;
294
+ expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowPrivateNetwork: true });
295
+ });
296
+
297
+ it("does not pass ssrfPolicy when allowPrivateNetwork is not set", async () => {
298
+ const mockBuffer = new Uint8Array([1]);
299
+ mockFetch.mockResolvedValueOnce({
300
+ ok: true,
301
+ headers: new Headers(),
302
+ arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
303
+ });
304
+
305
+ const attachment: BlueBubblesAttachment = { guid: "att-no-ssrf" };
306
+ await downloadBlueBubblesAttachment(attachment, {
307
+ serverUrl: "http://localhost:1234",
308
+ password: "test",
309
+ });
310
+
311
+ const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>;
312
+ expect(fetchMediaArgs.ssrfPolicy).toBeUndefined();
313
+ });
226
314
  });
227
315
 
228
316
  describe("sendBlueBubblesAttachment", () => {
229
317
  beforeEach(() => {
230
318
  vi.stubGlobal("fetch", mockFetch);
231
319
  mockFetch.mockReset();
320
+ fetchRemoteMediaMock.mockClear();
321
+ setBlueBubblesRuntime(runtimeStub);
232
322
  vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
233
- vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
323
+ mockBlueBubblesPrivateApiStatus(
324
+ vi.mocked(getCachedBlueBubblesPrivateApiStatus),
325
+ BLUE_BUBBLES_PRIVATE_API_STATUS.unknown,
326
+ );
234
327
  });
235
328
 
236
329
  afterEach(() => {
@@ -333,7 +426,10 @@ describe("sendBlueBubblesAttachment", () => {
333
426
  });
334
427
 
335
428
  it("downgrades attachment reply threading when private API is disabled", async () => {
336
- vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
429
+ mockBlueBubblesPrivateApiStatusOnce(
430
+ vi.mocked(getCachedBlueBubblesPrivateApiStatus),
431
+ BLUE_BUBBLES_PRIVATE_API_STATUS.disabled,
432
+ );
337
433
  mockFetch.mockResolvedValueOnce({
338
434
  ok: true,
339
435
  text: () => Promise.resolve(JSON.stringify({ messageId: "msg-4" })),
@@ -354,4 +450,32 @@ describe("sendBlueBubblesAttachment", () => {
354
450
  expect(bodyText).not.toContain('name="selectedMessageGuid"');
355
451
  expect(bodyText).not.toContain('name="partIndex"');
356
452
  });
453
+
454
+ it("warns and downgrades attachment reply threading when private API status is unknown", async () => {
455
+ const runtimeLog = vi.fn();
456
+ setBlueBubblesRuntime({
457
+ ...runtimeStub,
458
+ log: runtimeLog,
459
+ } as unknown as PluginRuntime);
460
+ mockFetch.mockResolvedValueOnce({
461
+ ok: true,
462
+ text: () => Promise.resolve(JSON.stringify({ messageId: "msg-5" })),
463
+ });
464
+
465
+ await sendBlueBubblesAttachment({
466
+ to: "chat_guid:iMessage;-;+15551234567",
467
+ buffer: new Uint8Array([1, 2, 3]),
468
+ filename: "photo.jpg",
469
+ contentType: "image/jpeg",
470
+ replyToMessageGuid: "reply-guid-unknown",
471
+ opts: { serverUrl: "http://localhost:1234", password: "test" },
472
+ });
473
+
474
+ expect(runtimeLog).toHaveBeenCalledTimes(1);
475
+ expect(runtimeLog.mock.calls[0]?.[0]).toContain("Private API status unknown");
476
+ const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
477
+ const bodyText = decodeBody(body);
478
+ expect(bodyText).not.toContain('name="selectedMessageGuid"');
479
+ expect(bodyText).not.toContain('name="partIndex"');
480
+ });
357
481
  });
@@ -3,7 +3,12 @@ import path from "node:path";
3
3
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
4
4
  import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
5
5
  import { postMultipartFormData } from "./multipart.js";
6
- import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
6
+ import {
7
+ getCachedBlueBubblesPrivateApiStatus,
8
+ isBlueBubblesPrivateApiStatusEnabled,
9
+ } from "./probe.js";
10
+ import { resolveRequestUrl } from "./request-url.js";
11
+ import { getBlueBubblesRuntime, warnBlueBubbles } from "./runtime.js";
7
12
  import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
8
13
  import { resolveChatGuidForTarget } from "./send.js";
9
14
  import {
@@ -57,6 +62,18 @@ function resolveAccount(params: BlueBubblesAttachmentOpts) {
57
62
  return resolveBlueBubblesServerAccount(params);
58
63
  }
59
64
 
65
+ type MediaFetchErrorCode = "max_bytes" | "http_error" | "fetch_failed";
66
+
67
+ function readMediaFetchErrorCode(error: unknown): MediaFetchErrorCode | undefined {
68
+ if (!error || typeof error !== "object") {
69
+ return undefined;
70
+ }
71
+ const code = (error as { code?: unknown }).code;
72
+ return code === "max_bytes" || code === "http_error" || code === "fetch_failed"
73
+ ? code
74
+ : undefined;
75
+ }
76
+
60
77
  export async function downloadBlueBubblesAttachment(
61
78
  attachment: BlueBubblesAttachment,
62
79
  opts: BlueBubblesAttachmentOpts & { maxBytes?: number } = {},
@@ -65,26 +82,37 @@ export async function downloadBlueBubblesAttachment(
65
82
  if (!guid) {
66
83
  throw new Error("BlueBubbles attachment guid is required");
67
84
  }
68
- const { baseUrl, password } = resolveAccount(opts);
85
+ const { baseUrl, password, allowPrivateNetwork } = resolveAccount(opts);
69
86
  const url = buildBlueBubblesApiUrl({
70
87
  baseUrl,
71
88
  path: `/api/v1/attachment/${encodeURIComponent(guid)}/download`,
72
89
  password,
73
90
  });
74
- const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, opts.timeoutMs);
75
- if (!res.ok) {
76
- const errorText = await res.text().catch(() => "");
77
- throw new Error(
78
- `BlueBubbles attachment download failed (${res.status}): ${errorText || "unknown"}`,
79
- );
80
- }
81
- const contentType = res.headers.get("content-type") ?? undefined;
82
- const buf = new Uint8Array(await res.arrayBuffer());
83
91
  const maxBytes = typeof opts.maxBytes === "number" ? opts.maxBytes : DEFAULT_ATTACHMENT_MAX_BYTES;
84
- if (buf.byteLength > maxBytes) {
85
- throw new Error(`BlueBubbles attachment too large (${buf.byteLength} bytes)`);
92
+ try {
93
+ const fetched = await getBlueBubblesRuntime().channel.media.fetchRemoteMedia({
94
+ url,
95
+ filePathHint: attachment.transferName ?? attachment.guid ?? "attachment",
96
+ maxBytes,
97
+ ssrfPolicy: allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined,
98
+ fetchImpl: async (input, init) =>
99
+ await blueBubblesFetchWithTimeout(
100
+ resolveRequestUrl(input),
101
+ { ...init, method: init?.method ?? "GET" },
102
+ opts.timeoutMs,
103
+ ),
104
+ });
105
+ return {
106
+ buffer: new Uint8Array(fetched.buffer),
107
+ contentType: fetched.contentType ?? attachment.mimeType ?? undefined,
108
+ };
109
+ } catch (error) {
110
+ if (readMediaFetchErrorCode(error) === "max_bytes") {
111
+ throw new Error(`BlueBubbles attachment too large (limit ${maxBytes} bytes)`);
112
+ }
113
+ const text = error instanceof Error ? error.message : String(error);
114
+ throw new Error(`BlueBubbles attachment download failed: ${text}`);
86
115
  }
87
- return { buffer: buf, contentType: contentType ?? attachment.mimeType ?? undefined };
88
116
  }
89
117
 
90
118
  export type SendBlueBubblesAttachmentResult = {
@@ -115,6 +143,7 @@ export async function sendBlueBubblesAttachment(params: {
115
143
  contentType = contentType?.trim() || undefined;
116
144
  const { baseUrl, password, accountId } = resolveAccount(opts);
117
145
  const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId);
146
+ const privateApiEnabled = isBlueBubblesPrivateApiStatusEnabled(privateApiStatus);
118
147
 
119
148
  // Validate voice memo format when requested (BlueBubbles converts MP3 -> CAF when isAudioMessage).
120
149
  const isAudioMessage = wantsVoice;
@@ -183,7 +212,7 @@ export async function sendBlueBubblesAttachment(params: {
183
212
  addField("chatGuid", chatGuid);
184
213
  addField("name", filename);
185
214
  addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`);
186
- if (privateApiStatus !== false) {
215
+ if (privateApiEnabled) {
187
216
  addField("method", "private-api");
188
217
  }
189
218
 
@@ -193,9 +222,13 @@ export async function sendBlueBubblesAttachment(params: {
193
222
  }
194
223
 
195
224
  const trimmedReplyTo = replyToMessageGuid?.trim();
196
- if (trimmedReplyTo && privateApiStatus !== false) {
225
+ if (trimmedReplyTo && privateApiEnabled) {
197
226
  addField("selectedMessageGuid", trimmedReplyTo);
198
227
  addField("partIndex", typeof replyToPartIndex === "number" ? String(replyToPartIndex) : "0");
228
+ } else if (trimmedReplyTo && privateApiStatus === null) {
229
+ warnBlueBubbles(
230
+ "Private API status unknown; sending attachment without reply threading metadata. Run a status probe to restore private-api reply features.",
231
+ );
199
232
  }
200
233
 
201
234
  // Add optional caption