@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 +1 -1
- package/src/account-resolve.ts +7 -1
- package/src/actions.test.ts +29 -46
- package/src/actions.ts +2 -13
- package/src/attachments.test.ts +156 -32
- package/src/attachments.ts +49 -16
- package/src/chat.test.ts +257 -57
- package/src/chat.ts +74 -124
- package/src/config-schema.ts +1 -0
- 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-shared.ts +3 -13
- package/src/monitor.test.ts +396 -4
- package/src/onboarding.ts +24 -37
- package/src/probe.ts +8 -0
- package/src/reactions.test.ts +27 -47
- package/src/request-url.ts +12 -0
- package/src/runtime.ts +20 -0
- package/src/send.test.ts +72 -31
- 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/types.ts +2 -0
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/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
|
-
|
|
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
|
-
|
|
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
|
*/
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|