refactor(test): dedupe bluebubbles webhook helpers

This commit is contained in:
Peter Steinberger
2026-03-22 02:59:39 +00:00
parent eea84bc6ec
commit 6c4eced494
3 changed files with 243 additions and 284 deletions

View File

@@ -12,14 +12,16 @@ import { resetBlueBubblesSelfChatCache } from "./monitor-self-chat-cache.js";
import { handleBlueBubblesWebhookRequest, resolveBlueBubblesMessageId } from "./monitor.js";
import {
createMockAccount,
createMessageReactionPayloadForTest,
createNewMessagePayloadForTest,
createMockRequest,
createMockResponse,
createTimestampedNewMessagePayloadForTest,
createTimestampedMessageReactionPayloadForTest,
dispatchWebhookRequestForTest,
dispatchWebhookPayloadForTest,
flushAsync,
setupWebhookTargetForTest,
setupWebhookTargetsForTest,
trackWebhookRegistrationForTest,
} from "./monitor.webhook.test-helpers.js";
import type { OpenClawConfig, PluginRuntime } from "./runtime-api.js";
@@ -148,13 +150,17 @@ describe("BlueBubbles webhook monitor", () => {
config?: OpenClawConfig;
core?: PluginRuntime;
}) {
const registration = setupWebhookTargetForTest({
createCore: createMockRuntime,
core: params?.core,
account: params?.account,
config: params?.config,
});
unregister = registration.unregister;
const registration = trackWebhookRegistrationForTest(
setupWebhookTargetForTest({
createCore: createMockRuntime,
core: params?.core,
account: params?.account,
config: params?.config,
}),
(nextUnregister) => {
unregister = nextUnregister;
},
);
return { core: registration.core };
}
@@ -163,10 +169,10 @@ describe("BlueBubbles webhook monitor", () => {
}
async function dispatchWebhookPayloadDirect(payload: unknown, url = "/bluebubbles-webhook") {
return handleBlueBubblesWebhookRequest(
const { handled } = await dispatchWebhookRequestForTest(
createMockRequest("POST", url, payload),
createMockResponse(),
);
return handled;
}
beforeEach(() => {
@@ -197,9 +203,8 @@ describe("BlueBubbles webhook monitor", () => {
}),
});
const payload = createNewMessagePayloadForTest({
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello from allowed sender",
date: Date.now(),
});
const res = await dispatchWebhookPayload(payload);
@@ -216,9 +221,8 @@ describe("BlueBubbles webhook monitor", () => {
}),
});
const payload = createNewMessagePayloadForTest({
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello from blocked sender",
date: Date.now(),
});
const res = await dispatchWebhookPayload(payload);
@@ -235,9 +239,8 @@ describe("BlueBubbles webhook monitor", () => {
}),
});
const payload = createNewMessagePayloadForTest({
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello from blocked sender",
date: Date.now(),
});
const res = await dispatchWebhookPayload(payload);
@@ -255,7 +258,7 @@ describe("BlueBubbles webhook monitor", () => {
}),
});
const payload = createNewMessagePayloadForTest({ date: Date.now() });
const payload = createTimestampedNewMessagePayloadForTest();
await dispatchWebhookPayload(payload);
@@ -271,7 +274,7 @@ describe("BlueBubbles webhook monitor", () => {
}),
});
const payload = createNewMessagePayloadForTest({ date: Date.now() });
const payload = createTimestampedNewMessagePayloadForTest();
await dispatchWebhookPayload(payload);
@@ -289,10 +292,9 @@ describe("BlueBubbles webhook monitor", () => {
}),
});
const payload = createNewMessagePayloadForTest({
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello again",
guid: "msg-2",
date: Date.now(),
});
await dispatchWebhookPayload(payload);
@@ -311,10 +313,9 @@ describe("BlueBubbles webhook monitor", () => {
}),
});
const payload = createNewMessagePayloadForTest({
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello from anyone",
handle: { address: "+15559999999" },
date: Date.now(),
});
await dispatchWebhookPayload(payload);
@@ -329,7 +330,7 @@ describe("BlueBubbles webhook monitor", () => {
}),
});
const payload = createNewMessagePayloadForTest({ date: Date.now() });
const payload = createTimestampedNewMessagePayloadForTest();
await dispatchWebhookPayload(payload);
@@ -345,11 +346,10 @@ describe("BlueBubbles webhook monitor", () => {
}),
});
const payload = createNewMessagePayloadForTest({
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello from group",
isGroup: true,
chatGuid: "iMessage;+;chat123456",
date: Date.now(),
});
await dispatchWebhookPayload(payload);
@@ -364,11 +364,10 @@ describe("BlueBubbles webhook monitor", () => {
}),
});
const payload = createNewMessagePayloadForTest({
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello from group",
isGroup: true,
chatGuid: "iMessage;+;chat123456",
date: Date.now(),
});
await dispatchWebhookPayload(payload);
@@ -384,10 +383,9 @@ describe("BlueBubbles webhook monitor", () => {
}),
});
const payload = createNewMessagePayloadForTest({
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello from group",
chatGuid: "iMessage;+;chat123456",
date: Date.now(),
});
await dispatchWebhookPayload(payload);
@@ -403,11 +401,10 @@ describe("BlueBubbles webhook monitor", () => {
}),
});
const payload = createNewMessagePayloadForTest({
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello from allowed group",
isGroup: true,
chatGuid: "iMessage;+;chat123456",
date: Date.now(),
});
await dispatchWebhookPayload(payload);
@@ -421,15 +418,12 @@ describe("BlueBubbles webhook monitor", () => {
mockResolveRequireMention.mockReturnValue(true);
mockMatchesMentionPatterns.mockReturnValue(true);
setupWebhookTarget({
account: createMockAccount({ groupPolicy: "open" }),
});
setupWebhookTarget();
const payload = createNewMessagePayloadForTest({
const payload = createTimestampedNewMessagePayloadForTest({
text: "bert, can you help me?",
isGroup: true,
chatGuid: "iMessage;+;chat123456",
date: Date.now(),
});
await dispatchWebhookPayload(payload);
@@ -443,15 +437,12 @@ describe("BlueBubbles webhook monitor", () => {
mockResolveRequireMention.mockReturnValue(true);
mockMatchesMentionPatterns.mockReturnValue(false);
setupWebhookTarget({
account: createMockAccount({ groupPolicy: "open" }),
});
setupWebhookTarget();
const payload = createNewMessagePayloadForTest({
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello everyone",
isGroup: true,
chatGuid: "iMessage;+;chat123456",
date: Date.now(),
});
await dispatchWebhookPayload(payload);
@@ -462,15 +453,12 @@ describe("BlueBubbles webhook monitor", () => {
it("processes group message without mention when requireMention=false", async () => {
mockResolveRequireMention.mockReturnValue(false);
setupWebhookTarget({
account: createMockAccount({ groupPolicy: "open" }),
});
setupWebhookTarget();
const payload = createNewMessagePayloadForTest({
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello everyone",
isGroup: true,
chatGuid: "iMessage;+;chat123456",
date: Date.now(),
});
await dispatchWebhookPayload(payload);
@@ -481,11 +469,9 @@ describe("BlueBubbles webhook monitor", () => {
describe("group metadata", () => {
it("includes group subject + members in ctx", async () => {
setupWebhookTarget({
account: createMockAccount({ groupPolicy: "open" }),
});
setupWebhookTarget();
const payload = createNewMessagePayloadForTest({
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello group",
isGroup: true,
chatGuid: "iMessage;+;chat123456",
@@ -494,7 +480,6 @@ describe("BlueBubbles webhook monitor", () => {
{ address: "+15551234567", displayName: "Alice" },
{ address: "+15557654321", displayName: "Bob" },
],
date: Date.now(),
});
await dispatchWebhookPayload(payload);
@@ -508,17 +493,14 @@ describe("BlueBubbles webhook monitor", () => {
describe("group sender identity in envelope", () => {
it("includes sender in envelope body and group label as from for group messages", async () => {
setupWebhookTarget({
account: createMockAccount({ groupPolicy: "open" }),
});
setupWebhookTarget();
const payload = createNewMessagePayloadForTest({
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello everyone",
senderName: "Alice",
isGroup: true,
chatGuid: "iMessage;+;chat123456",
chatName: "Family Chat",
date: Date.now(),
});
await dispatchWebhookPayload(payload);
@@ -540,14 +522,11 @@ describe("BlueBubbles webhook monitor", () => {
});
it("falls back to group:peerId when chatName is missing", async () => {
setupWebhookTarget({
account: createMockAccount({ groupPolicy: "open" }),
});
setupWebhookTarget();
const payload = createNewMessagePayloadForTest({
const payload = createTimestampedNewMessagePayloadForTest({
isGroup: true,
chatGuid: "iMessage;+;chat123456",
date: Date.now(),
});
await dispatchWebhookPayload(payload);
@@ -564,9 +543,8 @@ describe("BlueBubbles webhook monitor", () => {
it("uses sender as from label for DM messages", async () => {
setupWebhookTarget();
const payload = createNewMessagePayloadForTest({
const payload = createTimestampedNewMessagePayloadForTest({
senderName: "Alice",
date: Date.now(),
});
await dispatchWebhookPayload(payload);
@@ -645,23 +623,25 @@ describe("BlueBubbles webhook monitor", () => {
};
}) as unknown as PluginRuntime["channel"]["debounce"]["createInboundDebouncer"];
const registration = setupWebhookTargetForTest({
createCore: createMockRuntime,
core,
account: createMockAccount({ dmPolicy: "open" }),
});
unregister = registration.unregister;
const registration = trackWebhookRegistrationForTest(
setupWebhookTargetForTest({
createCore: createMockRuntime,
core,
}),
(nextUnregister) => {
unregister = nextUnregister;
},
);
const messageId = "race-msg-1";
const chatGuid = "iMessage;-;+15551234567";
const payloadA = createNewMessagePayloadForTest({
const payloadA = createTimestampedNewMessagePayloadForTest({
guid: messageId,
chatGuid,
date: Date.now(),
});
const payloadB = createNewMessagePayloadForTest({
const payloadB = createTimestampedNewMessagePayloadForTest({
guid: messageId,
chatGuid,
attachments: [
@@ -671,7 +651,6 @@ describe("BlueBubbles webhook monitor", () => {
totalBytes: 1024,
},
],
date: Date.now(),
});
await dispatchWebhookPayloadDirect(payloadA);
@@ -699,11 +678,9 @@ describe("BlueBubbles webhook monitor", () => {
describe("reply metadata", () => {
it("surfaces reply fields in ctx when provided", async () => {
setupWebhookTarget({
account: createMockAccount({ dmPolicy: "open" }),
});
setupWebhookTarget();
const payload = createNewMessagePayloadForTest({
const payload = createTimestampedNewMessagePayloadForTest({
text: "replying now",
chatGuid: "iMessage;-;+15551234567",
replyTo: {
@@ -711,7 +688,6 @@ describe("BlueBubbles webhook monitor", () => {
text: "original message",
handle: { address: "+15550000000", displayName: "Alice" },
},
date: Date.now(),
});
await dispatchWebhookPayload(payload);
@@ -727,11 +703,9 @@ describe("BlueBubbles webhook monitor", () => {
});
it("preserves part index prefixes in reply tags when short IDs are unavailable", async () => {
setupWebhookTarget({
account: createMockAccount({ dmPolicy: "open" }),
});
setupWebhookTarget();
const payload = createNewMessagePayloadForTest({
const payload = createTimestampedNewMessagePayloadForTest({
text: "replying now",
chatGuid: "iMessage;-;+15551234567",
replyTo: {
@@ -739,7 +713,6 @@ describe("BlueBubbles webhook monitor", () => {
text: "original message",
handle: { address: "+15550000000", displayName: "Alice" },
},
date: Date.now(),
});
await dispatchWebhookPayload(payload);
@@ -752,19 +725,16 @@ describe("BlueBubbles webhook monitor", () => {
});
it("hydrates missing reply sender/body from the recent-message cache", async () => {
setupWebhookTarget({
account: createMockAccount({ dmPolicy: "open", groupPolicy: "open" }),
});
setupWebhookTarget();
const chatGuid = "iMessage;+;chat-reply-cache";
const originalPayload = createNewMessagePayloadForTest({
const originalPayload = createTimestampedNewMessagePayloadForTest({
text: "original message (cached)",
handle: { address: "+15550000000" },
isGroup: true,
guid: "cache-msg-0",
chatGuid,
date: Date.now(),
});
await dispatchWebhookPayload(originalPayload);
@@ -772,14 +742,13 @@ describe("BlueBubbles webhook monitor", () => {
// Only assert the reply message behavior below.
mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
const replyPayload = createNewMessagePayloadForTest({
const replyPayload = createTimestampedNewMessagePayloadForTest({
text: "replying now",
isGroup: true,
guid: "cache-msg-1",
chatGuid,
// Only the GUID is provided; sender/body must be hydrated.
replyToMessageGuid: "cache-msg-0",
date: Date.now(),
});
await dispatchWebhookPayload(replyPayload);
@@ -796,15 +765,12 @@ describe("BlueBubbles webhook monitor", () => {
});
it("falls back to threadOriginatorGuid when reply metadata is absent", async () => {
setupWebhookTarget({
account: createMockAccount({ dmPolicy: "open" }),
});
setupWebhookTarget();
const payload = createNewMessagePayloadForTest({
const payload = createTimestampedNewMessagePayloadForTest({
text: "replying now",
threadOriginatorGuid: "msg-0",
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
});
await dispatchWebhookPayload(payload);
@@ -817,14 +783,11 @@ describe("BlueBubbles webhook monitor", () => {
describe("tapback text parsing", () => {
it("does not rewrite tapback-like text without metadata", async () => {
setupWebhookTarget({
account: createMockAccount({ dmPolicy: "open" }),
});
setupWebhookTarget();
const payload = createNewMessagePayloadForTest({
const payload = createTimestampedNewMessagePayloadForTest({
text: "Loved this idea",
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
});
await dispatchWebhookPayload(payload);
@@ -837,15 +800,12 @@ describe("BlueBubbles webhook monitor", () => {
});
it("parses tapback text with custom emoji when metadata is present", async () => {
setupWebhookTarget({
account: createMockAccount({ dmPolicy: "open" }),
});
setupWebhookTarget();
const payload = createNewMessagePayloadForTest({
const payload = createTimestampedNewMessagePayloadForTest({
text: 'Reacted 😅 to "nice one"',
guid: "msg-2",
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
});
await dispatchWebhookPayload(payload);
@@ -864,7 +824,6 @@ describe("BlueBubbles webhook monitor", () => {
vi.mocked(sendBlueBubblesReaction).mockClear();
setupWebhookTarget({
account: createMockAccount({ dmPolicy: "open" }),
config: {
messages: {
ackReaction: "❤️",
@@ -873,9 +832,8 @@ describe("BlueBubbles webhook monitor", () => {
},
});
const payload = createNewMessagePayloadForTest({
const payload = createTimestampedNewMessagePayloadForTest({
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
});
await dispatchWebhookPayload(payload);
@@ -905,11 +863,10 @@ describe("BlueBubbles webhook monitor", () => {
}),
});
const payload = createNewMessagePayloadForTest({
const payload = createTimestampedNewMessagePayloadForTest({
text: "/status",
isGroup: true,
chatGuid: "iMessage;+;chat123456",
date: Date.now(),
});
await dispatchWebhookPayload(payload);
@@ -929,12 +886,11 @@ describe("BlueBubbles webhook monitor", () => {
}),
});
const payload = createNewMessagePayloadForTest({
const payload = createTimestampedNewMessagePayloadForTest({
text: "/status",
handle: { address: "+15559999999" },
isGroup: true,
chatGuid: "iMessage;+;chat123456",
date: Date.now(),
});
await dispatchWebhookPayload(payload);
@@ -952,11 +908,10 @@ describe("BlueBubbles webhook monitor", () => {
}),
});
const payload = createNewMessagePayloadForTest({
const payload = createTimestampedNewMessagePayloadForTest({
text: "/status",
handle: { address: "+15559999999" },
guid: "msg-dm-open-unauthorized",
date: Date.now(),
});
await dispatchWebhookPayload(payload);
@@ -981,9 +936,8 @@ describe("BlueBubbles webhook monitor", () => {
}),
});
const payload = createNewMessagePayloadForTest({
const payload = createTimestampedNewMessagePayloadForTest({
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
});
await dispatchWebhookPayload(payload);
@@ -1001,9 +955,8 @@ describe("BlueBubbles webhook monitor", () => {
}),
});
const payload = createNewMessagePayloadForTest({
const payload = createTimestampedNewMessagePayloadForTest({
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
});
await dispatchWebhookPayload(payload);
@@ -1017,9 +970,8 @@ describe("BlueBubbles webhook monitor", () => {
setupWebhookTarget();
const payload = createNewMessagePayloadForTest({
const payload = createTimestampedNewMessagePayloadForTest({
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
});
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
@@ -1043,9 +995,8 @@ describe("BlueBubbles webhook monitor", () => {
setupWebhookTarget();
const payload = createNewMessagePayloadForTest({
const payload = createTimestampedNewMessagePayloadForTest({
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
});
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
@@ -1070,9 +1021,8 @@ describe("BlueBubbles webhook monitor", () => {
setupWebhookTarget();
const payload = createNewMessagePayloadForTest({
const payload = createTimestampedNewMessagePayloadForTest({
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
});
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(
@@ -1100,9 +1050,8 @@ describe("BlueBubbles webhook monitor", () => {
setupWebhookTarget();
const payload = createNewMessagePayloadForTest({
const payload = createTimestampedNewMessagePayloadForTest({
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
});
await dispatchWebhookPayload(payload);
@@ -1129,9 +1078,8 @@ describe("BlueBubbles webhook monitor", () => {
setupWebhookTarget();
const inboundPayload = createNewMessagePayloadForTest({
const inboundPayload = createTimestampedNewMessagePayloadForTest({
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
});
await dispatchWebhookPayload(inboundPayload);
@@ -1139,13 +1087,12 @@ describe("BlueBubbles webhook monitor", () => {
// Send response did not include a message id, so nothing should be enqueued yet.
expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
const fromMePayload = createNewMessagePayloadForTest({
const fromMePayload = createTimestampedNewMessagePayloadForTest({
text: "replying now",
handle: { address: "+15557654321" },
isFromMe: true,
guid: "msg-out-456",
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
});
await dispatchWebhookPayload(fromMePayload);
@@ -1171,22 +1118,20 @@ describe("BlueBubbles webhook monitor", () => {
setupWebhookTarget();
const inboundPayload = createNewMessagePayloadForTest({
const inboundPayload = createTimestampedNewMessagePayloadForTest({
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
});
await dispatchWebhookPayload(inboundPayload);
expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
const fromMePayload = createNewMessagePayloadForTest({
const fromMePayload = createTimestampedNewMessagePayloadForTest({
text: "replying now",
handle: { address: "+15557654321" },
isFromMe: true,
guid: "msg-out-789",
chatIdentifier: "+15551234567",
date: Date.now(),
});
await dispatchWebhookPayload(fromMePayload);
@@ -1208,9 +1153,7 @@ describe("BlueBubbles webhook monitor", () => {
account: createMockAccount({ dmPolicy: "pairing", allowFrom: [] }),
});
const payload = createMessageReactionPayloadForTest({
date: Date.now(),
});
const payload = createTimestampedMessageReactionPayloadForTest();
await dispatchWebhookPayload(payload);
@@ -1222,9 +1165,8 @@ describe("BlueBubbles webhook monitor", () => {
setupWebhookTarget();
const payload = createMessageReactionPayloadForTest({
const payload = createTimestampedMessageReactionPayloadForTest({
associatedMessageType: 2000, // Heart reaction added
date: Date.now(),
});
await dispatchWebhookPayload(payload);
@@ -1240,9 +1182,8 @@ describe("BlueBubbles webhook monitor", () => {
setupWebhookTarget();
const payload = createMessageReactionPayloadForTest({
const payload = createTimestampedMessageReactionPayloadForTest({
associatedMessageType: 3000, // Heart reaction removed
date: Date.now(),
});
await dispatchWebhookPayload(payload);
@@ -1258,9 +1199,8 @@ describe("BlueBubbles webhook monitor", () => {
setupWebhookTarget();
const payload = createMessageReactionPayloadForTest({
const payload = createTimestampedMessageReactionPayloadForTest({
isFromMe: true, // From self
date: Date.now(),
});
await dispatchWebhookPayload(payload);
@@ -1274,10 +1214,9 @@ describe("BlueBubbles webhook monitor", () => {
setupWebhookTarget();
// Test thumbs up reaction (2001)
const payload = createMessageReactionPayloadForTest({
const payload = createTimestampedMessageReactionPayloadForTest({
associatedMessageGuid: "msg-123",
associatedMessageType: 2001, // Thumbs up
date: Date.now(),
});
await dispatchWebhookPayload(payload);
@@ -1291,14 +1230,11 @@ describe("BlueBubbles webhook monitor", () => {
describe("short message ID mapping", () => {
it("assigns sequential short IDs to messages", async () => {
setupWebhookTarget({
account: createMockAccount({ dmPolicy: "open" }),
});
setupWebhookTarget();
const payload = createNewMessagePayloadForTest({
const payload = createTimestampedNewMessagePayloadForTest({
guid: "p:1/msg-uuid-12345",
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
});
await dispatchWebhookPayload(payload);
@@ -1311,14 +1247,11 @@ describe("BlueBubbles webhook monitor", () => {
});
it("resolves short ID back to UUID", async () => {
setupWebhookTarget({
account: createMockAccount({ dmPolicy: "open" }),
});
setupWebhookTarget();
const payload = createNewMessagePayloadForTest({
const payload = createTimestampedNewMessagePayloadForTest({
guid: "p:1/msg-uuid-12345",
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
});
await dispatchWebhookPayload(payload);
@@ -1373,29 +1306,31 @@ describe("BlueBubbles webhook monitor", () => {
accountId: "acc-b",
};
const core = createMockRuntime();
const registration = setupWebhookTargetsForTest({
createCore: createMockRuntime,
core,
accounts: [{ account: accountA }, { account: accountB }],
});
unregister = registration.unregister;
trackWebhookRegistrationForTest(
setupWebhookTargetsForTest({
createCore: createMockRuntime,
core,
accounts: [{ account: accountA }, { account: accountB }],
}),
(nextUnregister) => {
unregister = nextUnregister;
},
);
await dispatchWebhookPayload(
createNewMessagePayloadForTest({
createTimestampedNewMessagePayloadForTest({
text: "message for account a",
guid: "a-msg-1",
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
}),
"/bluebubbles-webhook?password=password-a",
);
await dispatchWebhookPayload(
createNewMessagePayloadForTest({
createTimestampedNewMessagePayloadForTest({
text: "message for account b",
guid: "b-msg-1",
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
}),
"/bluebubbles-webhook?password=password-b",
);
@@ -1424,10 +1359,9 @@ describe("BlueBubbles webhook monitor", () => {
});
await dispatchWebhookPayload(
createNewMessagePayloadForTest({
createTimestampedNewMessagePayloadForTest({
text: "current text",
chatGuid: "iMessage;-;+15550002002",
date: Date.now(),
}),
);
@@ -1504,11 +1438,10 @@ describe("BlueBubbles webhook monitor", () => {
});
await dispatchWebhookPayload(
createNewMessagePayloadForTest({
createTimestampedNewMessagePayloadForTest({
text: "latest text",
guid: "msg-bomb-1",
chatGuid: "iMessage;-;+15550004004",
date: Date.now(),
}),
);
@@ -1525,10 +1458,9 @@ describe("BlueBubbles webhook monitor", () => {
it("ignores messages from self (fromMe=true)", async () => {
setupWebhookTarget();
const payload = createNewMessagePayloadForTest({
const payload = createTimestampedNewMessagePayloadForTest({
text: "my own message",
isFromMe: true,
date: Date.now(),
});
await dispatchWebhookPayload(payload);
@@ -1537,9 +1469,7 @@ describe("BlueBubbles webhook monitor", () => {
});
it("drops reflected self-chat duplicates after a confirmed assistant outbound", async () => {
setupWebhookTarget({
account: createMockAccount({ dmPolicy: "open" }),
});
setupWebhookTarget();
const { sendMessageBlueBubbles } = await import("./send.js");
vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce({ messageId: "msg-self-1" });
@@ -1584,15 +1514,12 @@ describe("BlueBubbles webhook monitor", () => {
});
it("does not drop inbound messages when no fromMe self-chat copy was seen", async () => {
setupWebhookTarget({
account: createMockAccount({ dmPolicy: "open" }),
});
setupWebhookTarget();
const inboundPayload = createNewMessagePayloadForTest({
const inboundPayload = createTimestampedNewMessagePayloadForTest({
text: "genuinely new message",
guid: "msg-inbound-1",
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
});
await dispatchWebhookPayload(inboundPayload);
@@ -1604,9 +1531,7 @@ describe("BlueBubbles webhook monitor", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
setupWebhookTarget({
account: createMockAccount({ dmPolicy: "open" }),
});
setupWebhookTarget();
const timestamp = Date.now();
const fromMePayload = createNewMessagePayloadForTest({
@@ -1637,9 +1562,7 @@ describe("BlueBubbles webhook monitor", () => {
});
it("does not cache regular fromMe DMs as self-chat reflections", async () => {
setupWebhookTarget({
account: createMockAccount({ dmPolicy: "open" }),
});
setupWebhookTarget();
const timestamp = Date.now();
const fromMePayload = createNewMessagePayloadForTest({
@@ -1668,9 +1591,7 @@ describe("BlueBubbles webhook monitor", () => {
});
it("does not drop user-authored self-chat prompts without a confirmed assistant outbound", async () => {
setupWebhookTarget({
account: createMockAccount({ dmPolicy: "open" }),
});
setupWebhookTarget();
const timestamp = Date.now();
const fromMePayload = createNewMessagePayloadForTest({
@@ -1698,9 +1619,7 @@ describe("BlueBubbles webhook monitor", () => {
});
it("does not treat a pending text-only match as confirmed assistant outbound", async () => {
setupWebhookTarget({
account: createMockAccount({ dmPolicy: "open" }),
});
setupWebhookTarget();
const { sendMessageBlueBubbles } = await import("./send.js");
vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce({ messageId: "ok" });
@@ -1745,9 +1664,7 @@ describe("BlueBubbles webhook monitor", () => {
});
it("does not treat chatGuid-inferred sender ids as self-chat evidence", async () => {
setupWebhookTarget({
account: createMockAccount({ dmPolicy: "open" }),
});
setupWebhookTarget();
const timestamp = Date.now();
const fromMePayload = createNewMessagePayloadForTest({

View File

@@ -10,19 +10,21 @@ import { fetchBlueBubblesHistory } from "./history.js";
import { handleBlueBubblesWebhookRequest, resolveBlueBubblesMessageId } from "./monitor.js";
import {
LOOPBACK_REMOTE_ADDRESSES_FOR_TEST,
createWebhookDispatchForTest,
createMockAccount,
createHangingWebhookRequestForTest,
createMockResponse,
createLoopbackWebhookRequestParamsForTest,
createNewMessagePayloadForTest,
createPasswordQueryRequestParamsForTest,
createProtectedWebhookAccountForTest,
createRemoteWebhookRequestParamsForTest,
createTimestampedNewMessagePayloadForTest,
dispatchWebhookPayloadForTest,
expectWebhookRequestStatusForTest,
expectWebhookStatusForTest,
setupWebhookTargetForTest,
setupWebhookTargetsForTest,
trackWebhookRegistrationForTest,
type WebhookRequestParams,
} from "./monitor.webhook.test-helpers.js";
import type { OpenClawConfig, PluginRuntime } from "./runtime-api.js";
@@ -162,14 +164,18 @@ describe("BlueBubbles webhook monitor", () => {
core?: PluginRuntime;
statusSink?: (event: unknown) => void;
}) {
const registration = setupWebhookTargetForTest({
createCore: createMockRuntime,
core: params?.core,
account: params?.account,
config: params?.config,
statusSink: params?.statusSink,
});
unregister = registration.unregister;
const registration = trackWebhookRegistrationForTest(
setupWebhookTargetForTest({
createCore: createMockRuntime,
core: params?.core,
account: params?.account,
config: params?.config,
statusSink: params?.statusSink,
}),
(nextUnregister) => {
unregister = nextUnregister;
},
);
return {
account: registration.account,
config: registration.config,
@@ -183,43 +189,72 @@ describe("BlueBubbles webhook monitor", () => {
return account;
}
function createWebhookTarget(
account: ResolvedBlueBubblesAccount,
statusSink: (event: unknown) => void = vi.fn(),
) {
return { account, statusSink };
}
function createProtectedWebhookTarget(password = TEST_WEBHOOK_PASSWORD) {
return createWebhookTarget(createProtectedWebhookAccountForTest(password));
}
async function expectProtectedWebhookRequestStatus(
params: WebhookRequestParams,
expectedStatus: number,
expectedBody?: string,
) {
setupProtectedWebhookTarget();
return expectWebhookRequestStatusForTest(params, expectedStatus, expectedBody);
}
async function expectRegisteredWebhookRequestStatus(
params: WebhookRequestParams,
expectedStatus: number,
expectedBody?: string,
) {
setupWebhookTarget();
return expectWebhookRequestStatusForTest(params, expectedStatus, expectedBody);
}
function registerWebhookTargets(
params: Array<{
account: ResolvedBlueBubblesAccount;
statusSink?: (event: unknown) => void;
}>,
) {
const registration = setupWebhookTargetsForTest({
createCore: createMockRuntime,
accounts: params,
});
unregister = registration.unregister;
trackWebhookRegistrationForTest(
setupWebhookTargetsForTest({
createCore: createMockRuntime,
accounts: params,
}),
(nextUnregister) => {
unregister = nextUnregister;
},
);
}
describe("webhook parsing + auth handling", () => {
it("rejects non-POST requests", async () => {
setupWebhookTarget();
await expectWebhookRequestStatusForTest({ method: "GET" }, 405);
await expectRegisteredWebhookRequestStatus({ method: "GET" }, 405);
});
it("accepts POST requests with valid JSON payload", async () => {
setupWebhookTarget();
const payload = createNewMessagePayloadForTest({ date: Date.now() });
await expectWebhookRequestStatusForTest({ body: payload }, 200, "ok");
const payload = createTimestampedNewMessagePayloadForTest();
await expectRegisteredWebhookRequestStatus({ body: payload }, 200, "ok");
});
it("rejects requests with invalid JSON", async () => {
setupWebhookTarget();
await expectWebhookRequestStatusForTest({ body: "invalid json {{" }, 400);
await expectRegisteredWebhookRequestStatus({ body: "invalid json {{" }, 400);
});
it("accepts URL-encoded payload wrappers", async () => {
setupWebhookTarget();
const payload = createNewMessagePayloadForTest({ date: Date.now() });
const payload = createTimestampedNewMessagePayloadForTest();
const encodedBody = new URLSearchParams({
payload: JSON.stringify(payload),
}).toString();
await expectWebhookRequestStatusForTest({ body: encodedBody }, 200, "ok");
await expectRegisteredWebhookRequestStatus({ body: encodedBody }, 200, "ok");
});
it("returns 408 when request body times out (Slow-Loris protection)", async () => {
@@ -230,9 +265,7 @@ describe("BlueBubbles webhook monitor", () => {
// Create a request that never sends data or ends (simulates slow-loris)
const { req, destroyMock } = createHangingWebhookRequestForTest();
const res = createMockResponse();
const handledPromise = handleBlueBubblesWebhookRequest(req, res);
const { res, handledPromise } = createWebhookDispatchForTest(req);
// Advance past the 30s timeout
await vi.advanceTimersByTimeAsync(31_000);
@@ -257,21 +290,15 @@ describe("BlueBubbles webhook monitor", () => {
});
it("authenticates via password query parameter", async () => {
setupProtectedWebhookTarget();
await expectWebhookRequestStatusForTest(
createPasswordQueryRequestParamsForTest({
body: createNewMessagePayloadForTest(),
password: TEST_WEBHOOK_PASSWORD,
}),
await expectProtectedWebhookRequestStatus(
createPasswordQueryRequestParamsForTest({ password: TEST_WEBHOOK_PASSWORD }),
200,
);
});
it("authenticates via x-password header", async () => {
setupProtectedWebhookTarget();
await expectWebhookRequestStatusForTest(
await expectProtectedWebhookRequestStatus(
createRemoteWebhookRequestParamsForTest({
body: createNewMessagePayloadForTest(),
overrides: {
headers: { "x-password": TEST_WEBHOOK_PASSWORD }, // pragma: allowlist secret
},
@@ -281,65 +308,43 @@ describe("BlueBubbles webhook monitor", () => {
});
it("rejects unauthorized requests with wrong password", async () => {
setupProtectedWebhookTarget();
await expectWebhookRequestStatusForTest(
createPasswordQueryRequestParamsForTest({
body: createNewMessagePayloadForTest(),
password: "wrong-token",
}),
await expectProtectedWebhookRequestStatus(
createPasswordQueryRequestParamsForTest({ password: "wrong-token" }),
401,
);
});
it("rejects ambiguous routing when multiple targets match the same password", async () => {
const accountA = createProtectedWebhookAccountForTest(TEST_WEBHOOK_PASSWORD);
const accountB = createProtectedWebhookAccountForTest(TEST_WEBHOOK_PASSWORD);
const sinkA = vi.fn();
const sinkB = vi.fn();
registerWebhookTargets([
{ account: accountA, statusSink: sinkA },
{ account: accountB, statusSink: sinkB },
]);
const targetA = createProtectedWebhookTarget();
const targetB = createProtectedWebhookTarget();
registerWebhookTargets([targetA, targetB]);
await expectWebhookRequestStatusForTest(
createPasswordQueryRequestParamsForTest({
body: createNewMessagePayloadForTest(),
password: TEST_WEBHOOK_PASSWORD,
}),
createPasswordQueryRequestParamsForTest({ password: TEST_WEBHOOK_PASSWORD }),
401,
);
expect(sinkA).not.toHaveBeenCalled();
expect(sinkB).not.toHaveBeenCalled();
expect(targetA.statusSink).not.toHaveBeenCalled();
expect(targetB.statusSink).not.toHaveBeenCalled();
});
it("ignores targets without passwords when a password-authenticated target matches", async () => {
const accountStrict = createProtectedWebhookAccountForTest(TEST_WEBHOOK_PASSWORD);
const accountWithoutPassword = createMockAccount({ password: undefined });
const sinkStrict = vi.fn();
const sinkWithoutPassword = vi.fn();
registerWebhookTargets([
{ account: accountStrict, statusSink: sinkStrict },
{ account: accountWithoutPassword, statusSink: sinkWithoutPassword },
]);
const strictTarget = createProtectedWebhookTarget();
const passwordlessTarget = createWebhookTarget(createMockAccount({ password: undefined }));
registerWebhookTargets([strictTarget, passwordlessTarget]);
await expectWebhookRequestStatusForTest(
createPasswordQueryRequestParamsForTest({
body: createNewMessagePayloadForTest(),
password: TEST_WEBHOOK_PASSWORD,
}),
createPasswordQueryRequestParamsForTest({ password: TEST_WEBHOOK_PASSWORD }),
200,
);
expect(sinkStrict).toHaveBeenCalledTimes(1);
expect(sinkWithoutPassword).not.toHaveBeenCalled();
expect(strictTarget.statusSink).toHaveBeenCalledTimes(1);
expect(passwordlessTarget.statusSink).not.toHaveBeenCalled();
});
it("requires authentication for loopback requests when password is configured", async () => {
setupProtectedWebhookTarget();
for (const remoteAddress of LOOPBACK_REMOTE_ADDRESSES_FOR_TEST) {
await expectWebhookRequestStatusForTest(
createLoopbackWebhookRequestParamsForTest(remoteAddress, {
body: createNewMessagePayloadForTest(),
}),
createLoopbackWebhookRequestParamsForTest(remoteAddress),
401,
);
}
@@ -357,7 +362,6 @@ describe("BlueBubbles webhook monitor", () => {
for (const headers of headerVariants) {
await expectWebhookRequestStatusForTest(
createLoopbackWebhookRequestParamsForTest("127.0.0.1", {
body: createNewMessagePayloadForTest(),
overrides: { headers },
}),
401,
@@ -377,12 +381,11 @@ describe("BlueBubbles webhook monitor", () => {
const { resolveChatGuidForTarget } = await import("./send.js");
vi.mocked(resolveChatGuidForTarget).mockClear();
setupWebhookTarget({ account: createMockAccount({ groupPolicy: "open" }) });
const payload = createNewMessagePayloadForTest({
setupWebhookTarget();
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello from group",
isGroup: true,
chatId: "123",
date: Date.now(),
});
await dispatchWebhookPayloadForTest({ body: payload });
@@ -404,12 +407,11 @@ describe("BlueBubbles webhook monitor", () => {
return EMPTY_DISPATCH_RESULT;
});
setupWebhookTarget({ account: createMockAccount({ groupPolicy: "open" }) });
const payload = createNewMessagePayloadForTest({
setupWebhookTarget();
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello from group",
isGroup: true,
chat: { chatGuid: "iMessage;+;chat123456" },
date: Date.now(),
});
await dispatchWebhookPayloadForTest({ body: payload });

View File

@@ -54,6 +54,15 @@ export function createNewMessagePayloadForTest(dataOverrides: Record<string, unk
};
}
export function createTimestampedNewMessagePayloadForTest(
dataOverrides: Record<string, unknown> = {},
) {
return createNewMessagePayloadForTest({
...dataOverrides,
date: Date.now(),
});
}
export function createMessageReactionPayloadForTest(dataOverrides: Record<string, unknown> = {}) {
return {
type: "message-reaction",
@@ -68,6 +77,15 @@ export function createMessageReactionPayloadForTest(dataOverrides: Record<string
};
}
export function createTimestampedMessageReactionPayloadForTest(
dataOverrides: Record<string, unknown> = {},
) {
return createMessageReactionPayloadForTest({
...dataOverrides,
date: Date.now(),
});
}
export function createMockRequest(
method: string,
url: string,
@@ -123,7 +141,7 @@ export function createRemoteWebhookRequestParamsForTest(
} = {},
): WebhookRequestParams {
return {
body: params.body ?? {},
body: params.body ?? createNewMessagePayloadForTest(),
remoteAddress: params.remoteAddress ?? "192.168.1.100",
...params.overrides,
};
@@ -155,7 +173,7 @@ export function createLoopbackWebhookRequestParamsForTest(
} = {},
): WebhookRequestParams {
return {
body: params.body ?? {},
body: params.body ?? createNewMessagePayloadForTest(),
remoteAddress,
...params.overrides,
};
@@ -193,12 +211,27 @@ export async function flushAsync() {
}
}
export function createWebhookDispatchForTest(req: IncomingMessage) {
const res = createMockResponse();
const handledPromise = handleBlueBubblesWebhookRequest(req, res);
return { res, handledPromise };
}
export async function dispatchWebhookRequestForTest(
req: IncomingMessage,
options: { flushAsyncAfter?: boolean } = {},
) {
const { res, handledPromise } = createWebhookDispatchForTest(req);
const handled = await handledPromise;
if (options.flushAsyncAfter) {
await flushAsync();
}
return { handled, res };
}
export async function dispatchWebhookPayloadForTest(params: WebhookRequestParams = {}) {
const req = createMockRequestForTest(params);
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
return { handled, res };
return dispatchWebhookRequestForTest(req, { flushAsyncAfter: true });
}
export async function expectWebhookStatusForTest(
@@ -206,8 +239,7 @@ export async function expectWebhookStatusForTest(
expectedStatus: number,
expectedBody?: string,
) {
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
const { res, handled } = await dispatchWebhookRequestForTest(req);
expect(handled).toBe(true);
expect(res.statusCode).toBe(expectedStatus);
if (expectedBody !== undefined) {
@@ -224,6 +256,14 @@ export async function expectWebhookRequestStatusForTest(
return expectWebhookStatusForTest(createMockRequestForTest(params), expectedStatus, expectedBody);
}
export function trackWebhookRegistrationForTest<T extends { unregister: () => void }>(
registration: T,
setUnregister: (unregister: () => void) => void,
) {
setUnregister(registration.unregister);
return registration;
}
export function registerWebhookTargetForTest(params: {
core: PluginRuntime;
account?: ResolvedBlueBubblesAccount;