@openclaw/bluebubbles 2026.2.21 → 2026.2.22

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.
@@ -4,6 +4,7 @@ import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
4
4
  import { removeAckReactionAfterReply, shouldAckReaction } from "openclaw/plugin-sdk";
5
5
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
6
  import type { ResolvedBlueBubblesAccount } from "./accounts.js";
7
+ import { fetchBlueBubblesHistory } from "./history.js";
7
8
  import {
8
9
  handleBlueBubblesWebhookRequest,
9
10
  registerBlueBubblesWebhookTarget,
@@ -38,6 +39,10 @@ vi.mock("./reactions.js", async () => {
38
39
  };
39
40
  });
40
41
 
42
+ vi.mock("./history.js", () => ({
43
+ fetchBlueBubblesHistory: vi.fn().mockResolvedValue({ entries: [], resolved: true }),
44
+ }));
45
+
41
46
  // Mock runtime
42
47
  const mockEnqueueSystemEvent = vi.fn();
43
48
  const mockBuildPairingReply = vi.fn(() => "Pairing code: TESTCODE");
@@ -86,6 +91,7 @@ const mockChunkByNewline = vi.fn((text: string) => (text ? [text] : []));
86
91
  const mockChunkTextWithMode = vi.fn((text: string) => (text ? [text] : []));
87
92
  const mockChunkMarkdownTextWithMode = vi.fn((text: string) => (text ? [text] : []));
88
93
  const mockResolveChunkMode = vi.fn(() => "length");
94
+ const mockFetchBlueBubblesHistory = vi.mocked(fetchBlueBubblesHistory);
89
95
 
90
96
  function createMockRuntime(): PluginRuntime {
91
97
  return {
@@ -355,6 +361,7 @@ describe("BlueBubbles webhook monitor", () => {
355
361
  vi.clearAllMocks();
356
362
  // Reset short ID state between tests for predictable behavior
357
363
  _resetBlueBubblesShortIdState();
364
+ mockFetchBlueBubblesHistory.mockResolvedValue({ entries: [], resolved: true });
358
365
  mockReadAllowFromStore.mockResolvedValue([]);
359
366
  mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: true });
360
367
  mockResolveRequireMention.mockReturnValue(false);
@@ -1017,9 +1024,86 @@ describe("BlueBubbles webhook monitor", () => {
1017
1024
  expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
1018
1025
  });
1019
1026
 
1027
+ it("blocks DM when dmPolicy=allowlist and allowFrom is empty", async () => {
1028
+ const account = createMockAccount({
1029
+ dmPolicy: "allowlist",
1030
+ allowFrom: [],
1031
+ });
1032
+ const config: OpenClawConfig = {};
1033
+ const core = createMockRuntime();
1034
+ setBlueBubblesRuntime(core);
1035
+
1036
+ unregister = registerBlueBubblesWebhookTarget({
1037
+ account,
1038
+ config,
1039
+ runtime: { log: vi.fn(), error: vi.fn() },
1040
+ core,
1041
+ path: "/bluebubbles-webhook",
1042
+ });
1043
+
1044
+ const payload = {
1045
+ type: "new-message",
1046
+ data: {
1047
+ text: "hello from blocked sender",
1048
+ handle: { address: "+15551234567" },
1049
+ isGroup: false,
1050
+ isFromMe: false,
1051
+ guid: "msg-1",
1052
+ date: Date.now(),
1053
+ },
1054
+ };
1055
+
1056
+ const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
1057
+ const res = createMockResponse();
1058
+
1059
+ await handleBlueBubblesWebhookRequest(req, res);
1060
+ await flushAsync();
1061
+
1062
+ expect(res.statusCode).toBe(200);
1063
+ expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
1064
+ expect(mockUpsertPairingRequest).not.toHaveBeenCalled();
1065
+ });
1066
+
1067
+ it("triggers pairing flow for unknown sender when dmPolicy=pairing and allowFrom is empty", async () => {
1068
+ const account = createMockAccount({
1069
+ dmPolicy: "pairing",
1070
+ allowFrom: [],
1071
+ });
1072
+ const config: OpenClawConfig = {};
1073
+ const core = createMockRuntime();
1074
+ setBlueBubblesRuntime(core);
1075
+
1076
+ unregister = registerBlueBubblesWebhookTarget({
1077
+ account,
1078
+ config,
1079
+ runtime: { log: vi.fn(), error: vi.fn() },
1080
+ core,
1081
+ path: "/bluebubbles-webhook",
1082
+ });
1083
+
1084
+ const payload = {
1085
+ type: "new-message",
1086
+ data: {
1087
+ text: "hello",
1088
+ handle: { address: "+15551234567" },
1089
+ isGroup: false,
1090
+ isFromMe: false,
1091
+ guid: "msg-1",
1092
+ date: Date.now(),
1093
+ },
1094
+ };
1095
+
1096
+ const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
1097
+ const res = createMockResponse();
1098
+
1099
+ await handleBlueBubblesWebhookRequest(req, res);
1100
+ await flushAsync();
1101
+
1102
+ expect(mockUpsertPairingRequest).toHaveBeenCalled();
1103
+ expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
1104
+ });
1105
+
1020
1106
  it("triggers pairing flow for unknown sender when dmPolicy=pairing", async () => {
1021
- // Note: empty allowFrom = allow all. To trigger pairing, we need a non-empty
1022
- // allowlist that doesn't include the sender
1023
1107
  const account = createMockAccount({
1024
1108
  dmPolicy: "pairing",
1025
1109
  allowFrom: ["+15559999999"], // Different number than sender
@@ -1061,8 +1145,6 @@ describe("BlueBubbles webhook monitor", () => {
1061
1145
  it("does not resend pairing reply when request already exists", async () => {
1062
1146
  mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: false });
1063
1147
 
1064
- // Note: empty allowFrom = allow all. To trigger pairing, we need a non-empty
1065
- // allowlist that doesn't include the sender
1066
1148
  const account = createMockAccount({
1067
1149
  dmPolicy: "pairing",
1068
1150
  allowFrom: ["+15559999999"], // Different number than sender
@@ -2627,6 +2709,43 @@ describe("BlueBubbles webhook monitor", () => {
2627
2709
  });
2628
2710
 
2629
2711
  describe("reaction events", () => {
2712
+ it("drops DM reactions when dmPolicy=pairing and allowFrom is empty", async () => {
2713
+ mockEnqueueSystemEvent.mockClear();
2714
+
2715
+ const account = createMockAccount({ dmPolicy: "pairing", allowFrom: [] });
2716
+ const config: OpenClawConfig = {};
2717
+ const core = createMockRuntime();
2718
+ setBlueBubblesRuntime(core);
2719
+
2720
+ unregister = registerBlueBubblesWebhookTarget({
2721
+ account,
2722
+ config,
2723
+ runtime: { log: vi.fn(), error: vi.fn() },
2724
+ core,
2725
+ path: "/bluebubbles-webhook",
2726
+ });
2727
+
2728
+ const payload = {
2729
+ type: "message-reaction",
2730
+ data: {
2731
+ handle: { address: "+15551234567" },
2732
+ isGroup: false,
2733
+ isFromMe: false,
2734
+ associatedMessageGuid: "msg-original-123",
2735
+ associatedMessageType: 2000,
2736
+ date: Date.now(),
2737
+ },
2738
+ };
2739
+
2740
+ const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
2741
+ const res = createMockResponse();
2742
+
2743
+ await handleBlueBubblesWebhookRequest(req, res);
2744
+ await flushAsync();
2745
+
2746
+ expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
2747
+ });
2748
+
2630
2749
  it("enqueues system event for reaction added", async () => {
2631
2750
  mockEnqueueSystemEvent.mockClear();
2632
2751
 
@@ -2879,6 +2998,279 @@ describe("BlueBubbles webhook monitor", () => {
2879
2998
  });
2880
2999
  });
2881
3000
 
3001
+ describe("history backfill", () => {
3002
+ it("scopes in-memory history by account to avoid cross-account leakage", async () => {
3003
+ mockFetchBlueBubblesHistory.mockImplementation(async (_chatIdentifier, _limit, opts) => {
3004
+ if (opts?.accountId === "acc-a") {
3005
+ return {
3006
+ resolved: true,
3007
+ entries: [
3008
+ { sender: "A", body: "a-history", messageId: "a-history-1", timestamp: 1000 },
3009
+ ],
3010
+ };
3011
+ }
3012
+ if (opts?.accountId === "acc-b") {
3013
+ return {
3014
+ resolved: true,
3015
+ entries: [
3016
+ { sender: "B", body: "b-history", messageId: "b-history-1", timestamp: 1000 },
3017
+ ],
3018
+ };
3019
+ }
3020
+ return { resolved: true, entries: [] };
3021
+ });
3022
+
3023
+ const accountA: ResolvedBlueBubblesAccount = {
3024
+ ...createMockAccount({ dmHistoryLimit: 3, password: "password-a" }),
3025
+ accountId: "acc-a",
3026
+ };
3027
+ const accountB: ResolvedBlueBubblesAccount = {
3028
+ ...createMockAccount({ dmHistoryLimit: 3, password: "password-b" }),
3029
+ accountId: "acc-b",
3030
+ };
3031
+ const config: OpenClawConfig = {};
3032
+ const core = createMockRuntime();
3033
+ setBlueBubblesRuntime(core);
3034
+
3035
+ const unregisterA = registerBlueBubblesWebhookTarget({
3036
+ account: accountA,
3037
+ config,
3038
+ runtime: { log: vi.fn(), error: vi.fn() },
3039
+ core,
3040
+ path: "/bluebubbles-webhook",
3041
+ });
3042
+ const unregisterB = registerBlueBubblesWebhookTarget({
3043
+ account: accountB,
3044
+ config,
3045
+ runtime: { log: vi.fn(), error: vi.fn() },
3046
+ core,
3047
+ path: "/bluebubbles-webhook",
3048
+ });
3049
+ unregister = () => {
3050
+ unregisterA();
3051
+ unregisterB();
3052
+ };
3053
+
3054
+ await handleBlueBubblesWebhookRequest(
3055
+ createMockRequest("POST", "/bluebubbles-webhook?password=password-a", {
3056
+ type: "new-message",
3057
+ data: {
3058
+ text: "message for account a",
3059
+ handle: { address: "+15551234567" },
3060
+ isGroup: false,
3061
+ isFromMe: false,
3062
+ guid: "a-msg-1",
3063
+ chatGuid: "iMessage;-;+15551234567",
3064
+ date: Date.now(),
3065
+ },
3066
+ }),
3067
+ createMockResponse(),
3068
+ );
3069
+ await flushAsync();
3070
+
3071
+ await handleBlueBubblesWebhookRequest(
3072
+ createMockRequest("POST", "/bluebubbles-webhook?password=password-b", {
3073
+ type: "new-message",
3074
+ data: {
3075
+ text: "message for account b",
3076
+ handle: { address: "+15551234567" },
3077
+ isGroup: false,
3078
+ isFromMe: false,
3079
+ guid: "b-msg-1",
3080
+ chatGuid: "iMessage;-;+15551234567",
3081
+ date: Date.now(),
3082
+ },
3083
+ }),
3084
+ createMockResponse(),
3085
+ );
3086
+ await flushAsync();
3087
+
3088
+ expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(2);
3089
+ const firstCall = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
3090
+ const secondCall = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[1]?.[0];
3091
+ const firstHistory = (firstCall?.ctx.InboundHistory ?? []) as Array<{ body: string }>;
3092
+ const secondHistory = (secondCall?.ctx.InboundHistory ?? []) as Array<{ body: string }>;
3093
+ expect(firstHistory.map((entry) => entry.body)).toContain("a-history");
3094
+ expect(secondHistory.map((entry) => entry.body)).toContain("b-history");
3095
+ expect(secondHistory.map((entry) => entry.body)).not.toContain("a-history");
3096
+ });
3097
+
3098
+ it("dedupes and caps merged history to dmHistoryLimit", async () => {
3099
+ mockFetchBlueBubblesHistory.mockResolvedValueOnce({
3100
+ resolved: true,
3101
+ entries: [
3102
+ { sender: "Friend", body: "older context", messageId: "hist-1", timestamp: 1000 },
3103
+ { sender: "Friend", body: "current text", messageId: "msg-1", timestamp: 2000 },
3104
+ ],
3105
+ });
3106
+
3107
+ const account = createMockAccount({ dmHistoryLimit: 2 });
3108
+ const config: OpenClawConfig = {};
3109
+ const core = createMockRuntime();
3110
+ setBlueBubblesRuntime(core);
3111
+
3112
+ unregister = registerBlueBubblesWebhookTarget({
3113
+ account,
3114
+ config,
3115
+ runtime: { log: vi.fn(), error: vi.fn() },
3116
+ core,
3117
+ path: "/bluebubbles-webhook",
3118
+ });
3119
+
3120
+ const req = createMockRequest("POST", "/bluebubbles-webhook", {
3121
+ type: "new-message",
3122
+ data: {
3123
+ text: "current text",
3124
+ handle: { address: "+15551234567" },
3125
+ isGroup: false,
3126
+ isFromMe: false,
3127
+ guid: "msg-1",
3128
+ chatGuid: "iMessage;-;+15550002002",
3129
+ date: Date.now(),
3130
+ },
3131
+ });
3132
+ const res = createMockResponse();
3133
+
3134
+ await handleBlueBubblesWebhookRequest(req, res);
3135
+ await flushAsync();
3136
+
3137
+ const callArgs = getFirstDispatchCall();
3138
+ const inboundHistory = (callArgs.ctx.InboundHistory ?? []) as Array<{ body: string }>;
3139
+ expect(inboundHistory).toHaveLength(2);
3140
+ expect(inboundHistory.map((entry) => entry.body)).toEqual(["older context", "current text"]);
3141
+ expect(inboundHistory.filter((entry) => entry.body === "current text")).toHaveLength(1);
3142
+ });
3143
+
3144
+ it("uses exponential backoff for unresolved backfill and stops after resolve", async () => {
3145
+ mockFetchBlueBubblesHistory
3146
+ .mockResolvedValueOnce({ resolved: false, entries: [] })
3147
+ .mockResolvedValueOnce({
3148
+ resolved: true,
3149
+ entries: [
3150
+ { sender: "Friend", body: "older context", messageId: "hist-1", timestamp: 1000 },
3151
+ ],
3152
+ });
3153
+
3154
+ const account = createMockAccount({ dmHistoryLimit: 4 });
3155
+ const config: OpenClawConfig = {};
3156
+ const core = createMockRuntime();
3157
+ setBlueBubblesRuntime(core);
3158
+
3159
+ unregister = registerBlueBubblesWebhookTarget({
3160
+ account,
3161
+ config,
3162
+ runtime: { log: vi.fn(), error: vi.fn() },
3163
+ core,
3164
+ path: "/bluebubbles-webhook",
3165
+ });
3166
+
3167
+ const mkPayload = (guid: string, text: string, now: number) => ({
3168
+ type: "new-message",
3169
+ data: {
3170
+ text,
3171
+ handle: { address: "+15551234567" },
3172
+ isGroup: false,
3173
+ isFromMe: false,
3174
+ guid,
3175
+ chatGuid: "iMessage;-;+15550003003",
3176
+ date: now,
3177
+ },
3178
+ });
3179
+
3180
+ let now = 1_700_000_000_000;
3181
+ const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now);
3182
+ try {
3183
+ await handleBlueBubblesWebhookRequest(
3184
+ createMockRequest("POST", "/bluebubbles-webhook", mkPayload("msg-1", "first text", now)),
3185
+ createMockResponse(),
3186
+ );
3187
+ await flushAsync();
3188
+ expect(mockFetchBlueBubblesHistory).toHaveBeenCalledTimes(1);
3189
+
3190
+ now += 1_000;
3191
+ await handleBlueBubblesWebhookRequest(
3192
+ createMockRequest("POST", "/bluebubbles-webhook", mkPayload("msg-2", "second text", now)),
3193
+ createMockResponse(),
3194
+ );
3195
+ await flushAsync();
3196
+ expect(mockFetchBlueBubblesHistory).toHaveBeenCalledTimes(1);
3197
+
3198
+ now += 6_000;
3199
+ await handleBlueBubblesWebhookRequest(
3200
+ createMockRequest("POST", "/bluebubbles-webhook", mkPayload("msg-3", "third text", now)),
3201
+ createMockResponse(),
3202
+ );
3203
+ await flushAsync();
3204
+ expect(mockFetchBlueBubblesHistory).toHaveBeenCalledTimes(2);
3205
+
3206
+ const thirdCall = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[2]?.[0];
3207
+ const thirdHistory = (thirdCall?.ctx.InboundHistory ?? []) as Array<{ body: string }>;
3208
+ expect(thirdHistory.map((entry) => entry.body)).toContain("older context");
3209
+ expect(thirdHistory.map((entry) => entry.body)).toContain("third text");
3210
+
3211
+ now += 10_000;
3212
+ await handleBlueBubblesWebhookRequest(
3213
+ createMockRequest("POST", "/bluebubbles-webhook", mkPayload("msg-4", "fourth text", now)),
3214
+ createMockResponse(),
3215
+ );
3216
+ await flushAsync();
3217
+ expect(mockFetchBlueBubblesHistory).toHaveBeenCalledTimes(2);
3218
+ } finally {
3219
+ nowSpy.mockRestore();
3220
+ }
3221
+ });
3222
+
3223
+ it("caps inbound history payload size to reduce prompt-bomb risk", async () => {
3224
+ const huge = "x".repeat(8_000);
3225
+ mockFetchBlueBubblesHistory.mockResolvedValueOnce({
3226
+ resolved: true,
3227
+ entries: Array.from({ length: 20 }, (_, idx) => ({
3228
+ sender: `Friend ${idx}`,
3229
+ body: `${huge} ${idx}`,
3230
+ messageId: `hist-${idx}`,
3231
+ timestamp: idx + 1,
3232
+ })),
3233
+ });
3234
+
3235
+ const account = createMockAccount({ dmHistoryLimit: 20 });
3236
+ const config: OpenClawConfig = {};
3237
+ const core = createMockRuntime();
3238
+ setBlueBubblesRuntime(core);
3239
+
3240
+ unregister = registerBlueBubblesWebhookTarget({
3241
+ account,
3242
+ config,
3243
+ runtime: { log: vi.fn(), error: vi.fn() },
3244
+ core,
3245
+ path: "/bluebubbles-webhook",
3246
+ });
3247
+
3248
+ await handleBlueBubblesWebhookRequest(
3249
+ createMockRequest("POST", "/bluebubbles-webhook", {
3250
+ type: "new-message",
3251
+ data: {
3252
+ text: "latest text",
3253
+ handle: { address: "+15551234567" },
3254
+ isGroup: false,
3255
+ isFromMe: false,
3256
+ guid: "msg-bomb-1",
3257
+ chatGuid: "iMessage;-;+15550004004",
3258
+ date: Date.now(),
3259
+ },
3260
+ }),
3261
+ createMockResponse(),
3262
+ );
3263
+ await flushAsync();
3264
+
3265
+ const callArgs = getFirstDispatchCall();
3266
+ const inboundHistory = (callArgs.ctx.InboundHistory ?? []) as Array<{ body: string }>;
3267
+ const totalChars = inboundHistory.reduce((sum, entry) => sum + entry.body.length, 0);
3268
+ expect(inboundHistory.length).toBeLessThan(20);
3269
+ expect(totalChars).toBeLessThanOrEqual(12_000);
3270
+ expect(inboundHistory.every((entry) => entry.body.length <= 1_203)).toBe(true);
3271
+ });
3272
+ });
3273
+
2882
3274
  describe("fromMe messages", () => {
2883
3275
  it("ignores messages from self (fromMe=true)", async () => {
2884
3276
  const account = createMockAccount();
package/src/probe.ts CHANGED
@@ -96,6 +96,14 @@ export function getCachedBlueBubblesPrivateApiStatus(accountId?: string): boolea
96
96
  return info.private_api;
97
97
  }
98
98
 
99
+ export function isBlueBubblesPrivateApiStatusEnabled(status: boolean | null): boolean {
100
+ return status === true;
101
+ }
102
+
103
+ export function isBlueBubblesPrivateApiEnabled(accountId?: string): boolean {
104
+ return isBlueBubblesPrivateApiStatusEnabled(getCachedBlueBubblesPrivateApiStatus(accountId));
105
+ }
106
+
99
107
  /**
100
108
  * Parse macOS version string (e.g., "15.0.1" or "26.0") into major version number.
101
109
  */
@@ -1,17 +1,10 @@
1
1
  import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2
2
  import { sendBlueBubblesReaction } from "./reactions.js";
3
3
 
4
- vi.mock("./accounts.js", () => ({
5
- resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
6
- const config = cfg?.channels?.bluebubbles ?? {};
7
- return {
8
- accountId: accountId ?? "default",
9
- enabled: config.enabled !== false,
10
- configured: Boolean(config.serverUrl && config.password),
11
- config,
12
- };
13
- }),
14
- }));
4
+ vi.mock("./accounts.js", async () => {
5
+ const { createBlueBubblesAccountsMockModule } = await import("./test-harness.js");
6
+ return createBlueBubblesAccountsMockModule();
7
+ });
15
8
 
16
9
  const mockFetch = vi.fn();
17
10
 
@@ -0,0 +1,12 @@
1
+ export function resolveRequestUrl(input: RequestInfo | URL): string {
2
+ if (typeof input === "string") {
3
+ return input;
4
+ }
5
+ if (input instanceof URL) {
6
+ return input.toString();
7
+ }
8
+ if (typeof input === "object" && input && "url" in input && typeof input.url === "string") {
9
+ return input.url;
10
+ }
11
+ return String(input);
12
+ }
package/src/runtime.ts CHANGED
@@ -1,14 +1,34 @@
1
1
  import type { PluginRuntime } from "openclaw/plugin-sdk";
2
2
 
3
3
  let runtime: PluginRuntime | null = null;
4
+ type LegacyRuntimeLogShape = { log?: (message: string) => void };
4
5
 
5
6
  export function setBlueBubblesRuntime(next: PluginRuntime): void {
6
7
  runtime = next;
7
8
  }
8
9
 
10
+ export function clearBlueBubblesRuntime(): void {
11
+ runtime = null;
12
+ }
13
+
14
+ export function tryGetBlueBubblesRuntime(): PluginRuntime | null {
15
+ return runtime;
16
+ }
17
+
9
18
  export function getBlueBubblesRuntime(): PluginRuntime {
10
19
  if (!runtime) {
11
20
  throw new Error("BlueBubbles runtime not initialized");
12
21
  }
13
22
  return runtime;
14
23
  }
24
+
25
+ export function warnBlueBubbles(message: string): void {
26
+ const formatted = `[bluebubbles] ${message}`;
27
+ // Backward-compatible with tests/legacy injections that pass { log }.
28
+ const log = (runtime as unknown as LegacyRuntimeLogShape | null)?.log;
29
+ if (typeof log === "function") {
30
+ log(formatted);
31
+ return;
32
+ }
33
+ console.warn(formatted);
34
+ }
package/src/send.test.ts CHANGED
@@ -1,15 +1,22 @@
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk";
1
2
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
3
  import "./test-mocks.js";
3
4
  import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
5
+ import { clearBlueBubblesRuntime, setBlueBubblesRuntime } from "./runtime.js";
4
6
  import { sendMessageBlueBubbles, resolveChatGuidForTarget } from "./send.js";
5
- import { installBlueBubblesFetchTestHooks } from "./test-harness.js";
7
+ import {
8
+ BLUE_BUBBLES_PRIVATE_API_STATUS,
9
+ installBlueBubblesFetchTestHooks,
10
+ mockBlueBubblesPrivateApiStatusOnce,
11
+ } from "./test-harness.js";
6
12
  import type { BlueBubblesSendTarget } from "./types.js";
7
13
 
8
14
  const mockFetch = vi.fn();
15
+ const privateApiStatusMock = vi.mocked(getCachedBlueBubblesPrivateApiStatus);
9
16
 
10
17
  installBlueBubblesFetchTestHooks({
11
18
  mockFetch,
12
- privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus),
19
+ privateApiStatusMock,
13
20
  });
14
21
 
15
22
  function mockResolvedHandleTarget(
@@ -527,6 +534,10 @@ describe("send", () => {
527
534
  });
528
535
 
529
536
  it("uses private-api when reply metadata is present", async () => {
537
+ mockBlueBubblesPrivateApiStatusOnce(
538
+ privateApiStatusMock,
539
+ BLUE_BUBBLES_PRIVATE_API_STATUS.enabled,
540
+ );
530
541
  mockResolvedHandleTarget();
531
542
  mockSendResponse({ data: { guid: "msg-uuid-124" } });
532
543
 
@@ -548,7 +559,10 @@ describe("send", () => {
548
559
  });
549
560
 
550
561
  it("downgrades threaded reply to plain send when private API is disabled", async () => {
551
- vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
562
+ mockBlueBubblesPrivateApiStatusOnce(
563
+ privateApiStatusMock,
564
+ BLUE_BUBBLES_PRIVATE_API_STATUS.disabled,
565
+ );
552
566
  mockResolvedHandleTarget();
553
567
  mockSendResponse({ data: { guid: "msg-uuid-plain" } });
554
568
 
@@ -568,6 +582,10 @@ describe("send", () => {
568
582
  });
569
583
 
570
584
  it("normalizes effect names and uses private-api for effects", async () => {
585
+ mockBlueBubblesPrivateApiStatusOnce(
586
+ privateApiStatusMock,
587
+ BLUE_BUBBLES_PRIVATE_API_STATUS.enabled,
588
+ );
571
589
  mockResolvedHandleTarget();
572
590
  mockSendResponse({ data: { guid: "msg-uuid-125" } });
573
591
 
@@ -586,6 +604,38 @@ describe("send", () => {
586
604
  expect(body.effectId).toBe("com.apple.MobileSMS.expressivesend.invisibleink");
587
605
  });
588
606
 
607
+ it("warns and downgrades private-api features when status is unknown", async () => {
608
+ const runtimeLog = vi.fn();
609
+ setBlueBubblesRuntime({ log: runtimeLog } as unknown as PluginRuntime);
610
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
611
+ mockResolvedHandleTarget();
612
+ mockSendResponse({ data: { guid: "msg-uuid-unknown" } });
613
+
614
+ try {
615
+ const result = await sendMessageBlueBubbles("+15551234567", "Reply fallback", {
616
+ serverUrl: "http://localhost:1234",
617
+ password: "test",
618
+ replyToMessageGuid: "reply-guid-123",
619
+ effectId: "invisible ink",
620
+ });
621
+
622
+ expect(result.messageId).toBe("msg-uuid-unknown");
623
+ expect(runtimeLog).toHaveBeenCalledTimes(1);
624
+ expect(runtimeLog.mock.calls[0]?.[0]).toContain("Private API status unknown");
625
+ expect(warnSpy).not.toHaveBeenCalled();
626
+
627
+ const sendCall = mockFetch.mock.calls[1];
628
+ const body = JSON.parse(sendCall[1].body);
629
+ expect(body.method).toBeUndefined();
630
+ expect(body.selectedMessageGuid).toBeUndefined();
631
+ expect(body.partIndex).toBeUndefined();
632
+ expect(body.effectId).toBeUndefined();
633
+ } finally {
634
+ clearBlueBubblesRuntime();
635
+ warnSpy.mockRestore();
636
+ }
637
+ });
638
+
589
639
  it("sends message with chat_guid target directly", async () => {
590
640
  mockFetch.mockResolvedValueOnce({
591
641
  ok: true,