@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.
- package/package.json +1 -1
- package/src/actions.test.ts +4 -11
- package/src/attachments.test.ts +91 -4
- package/src/attachments.ts +47 -15
- package/src/chat.test.ts +193 -1
- package/src/chat.ts +74 -124
- package/src/history.ts +177 -0
- package/src/monitor-normalize.test.ts +78 -0
- package/src/monitor-normalize.ts +41 -12
- package/src/monitor-processing.ts +383 -127
- package/src/monitor.test.ts +396 -4
- package/src/probe.ts +8 -0
- package/src/reactions.test.ts +4 -11
- package/src/request-url.ts +12 -0
- package/src/runtime.ts +20 -0
- package/src/send.test.ts +53 -3
- package/src/send.ts +49 -7
- package/src/targets.test.ts +19 -0
- package/src/targets.ts +46 -37
- package/src/test-harness.ts +31 -2
package/src/monitor.test.ts
CHANGED
|
@@ -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
|
*/
|
package/src/reactions.test.ts
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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,
|