@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.
@@ -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/onboarding.ts CHANGED
@@ -176,6 +176,28 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
176
176
 
177
177
  let next = cfg;
178
178
  const resolvedAccount = resolveBlueBubblesAccount({ cfg: next, accountId });
179
+ const validateServerUrlInput = (value: unknown): string | undefined => {
180
+ const trimmed = String(value ?? "").trim();
181
+ if (!trimmed) {
182
+ return "Required";
183
+ }
184
+ try {
185
+ const normalized = normalizeBlueBubblesServerUrl(trimmed);
186
+ new URL(normalized);
187
+ return undefined;
188
+ } catch {
189
+ return "Invalid URL format";
190
+ }
191
+ };
192
+ const promptServerUrl = async (initialValue?: string): Promise<string> => {
193
+ const entered = await prompter.text({
194
+ message: "BlueBubbles server URL",
195
+ placeholder: "http://192.168.1.100:1234",
196
+ initialValue,
197
+ validate: validateServerUrlInput,
198
+ });
199
+ return String(entered).trim();
200
+ };
179
201
 
180
202
  // Prompt for server URL
181
203
  let serverUrl = resolvedAccount.config.serverUrl?.trim();
@@ -188,49 +210,14 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
188
210
  ].join("\n"),
189
211
  "BlueBubbles server URL",
190
212
  );
191
- const entered = await prompter.text({
192
- message: "BlueBubbles server URL",
193
- placeholder: "http://192.168.1.100:1234",
194
- validate: (value) => {
195
- const trimmed = String(value ?? "").trim();
196
- if (!trimmed) {
197
- return "Required";
198
- }
199
- try {
200
- const normalized = normalizeBlueBubblesServerUrl(trimmed);
201
- new URL(normalized);
202
- return undefined;
203
- } catch {
204
- return "Invalid URL format";
205
- }
206
- },
207
- });
208
- serverUrl = String(entered).trim();
213
+ serverUrl = await promptServerUrl();
209
214
  } else {
210
215
  const keepUrl = await prompter.confirm({
211
216
  message: `BlueBubbles server URL already set (${serverUrl}). Keep it?`,
212
217
  initialValue: true,
213
218
  });
214
219
  if (!keepUrl) {
215
- const entered = await prompter.text({
216
- message: "BlueBubbles server URL",
217
- placeholder: "http://192.168.1.100:1234",
218
- initialValue: serverUrl,
219
- validate: (value) => {
220
- const trimmed = String(value ?? "").trim();
221
- if (!trimmed) {
222
- return "Required";
223
- }
224
- try {
225
- const normalized = normalizeBlueBubblesServerUrl(trimmed);
226
- new URL(normalized);
227
- return undefined;
228
- } catch {
229
- return "Invalid URL format";
230
- }
231
- },
232
- });
233
- serverUrl = String(entered).trim();
220
+ serverUrl = await promptServerUrl(serverUrl);
234
221
  }
235
222
  }
236
223
 
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
 
@@ -26,6 +19,27 @@ describe("reactions", () => {
26
19
  });
27
20
 
28
21
  describe("sendBlueBubblesReaction", () => {
22
+ async function expectRemovedReaction(emoji: string) {
23
+ mockFetch.mockResolvedValueOnce({
24
+ ok: true,
25
+ text: () => Promise.resolve(""),
26
+ });
27
+
28
+ await sendBlueBubblesReaction({
29
+ chatGuid: "chat-123",
30
+ messageGuid: "msg-123",
31
+ emoji,
32
+ remove: true,
33
+ opts: {
34
+ serverUrl: "http://localhost:1234",
35
+ password: "test",
36
+ },
37
+ });
38
+
39
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
40
+ expect(body.reaction).toBe("-love");
41
+ }
42
+
29
43
  it("throws when chatGuid is empty", async () => {
30
44
  await expect(
31
45
  sendBlueBubblesReaction({
@@ -215,45 +229,11 @@ describe("reactions", () => {
215
229
  });
216
230
 
217
231
  it("sends reaction removal with dash prefix", async () => {
218
- mockFetch.mockResolvedValueOnce({
219
- ok: true,
220
- text: () => Promise.resolve(""),
221
- });
222
-
223
- await sendBlueBubblesReaction({
224
- chatGuid: "chat-123",
225
- messageGuid: "msg-123",
226
- emoji: "love",
227
- remove: true,
228
- opts: {
229
- serverUrl: "http://localhost:1234",
230
- password: "test",
231
- },
232
- });
233
-
234
- const body = JSON.parse(mockFetch.mock.calls[0][1].body);
235
- expect(body.reaction).toBe("-love");
232
+ await expectRemovedReaction("love");
236
233
  });
237
234
 
238
235
  it("strips leading dash from emoji when remove flag is set", async () => {
239
- mockFetch.mockResolvedValueOnce({
240
- ok: true,
241
- text: () => Promise.resolve(""),
242
- });
243
-
244
- await sendBlueBubblesReaction({
245
- chatGuid: "chat-123",
246
- messageGuid: "msg-123",
247
- emoji: "-love",
248
- remove: true,
249
- opts: {
250
- serverUrl: "http://localhost:1234",
251
- password: "test",
252
- },
253
- });
254
-
255
- const body = JSON.parse(mockFetch.mock.calls[0][1].body);
256
- expect(body.reaction).toBe("-love");
236
+ await expectRemovedReaction("-love");
257
237
  });
258
238
 
259
239
  it("uses custom partIndex when provided", async () => {
@@ -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
+ }