Files
openclaw/extensions/bluebubbles/src/monitor.test.ts
2026-05-06 01:46:42 +01:00

2792 lines
93 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
import { fetchBlueBubblesHistory } from "./history.js";
import { createBlueBubblesDebounceRegistry } from "./monitor-debounce.js";
import type { NormalizedWebhookMessage } from "./monitor-normalize.js";
import { resetBlueBubblesSelfChatCache } from "./monitor-self-chat-cache.js";
import { resolveBlueBubblesMessageId } from "./monitor.js";
import {
createMockAccount,
createMockRequest,
createNewMessagePayloadForTest,
createTimestampedMessageReactionPayloadForTest,
createTimestampedNewMessagePayloadForTest,
dispatchWebhookPayloadForTest,
dispatchWebhookRequestForTest,
setupWebhookTargetForTest,
setupWebhookTargetsForTest,
trackWebhookRegistrationForTest,
} from "./monitor.webhook.test-helpers.js";
import {
resetBlueBubblesParticipantContactNameCacheForTest,
setBlueBubblesParticipantContactDepsForTest,
} from "./participant-contact-names.js";
import type { OpenClawConfig, PluginRuntime } from "./runtime-api.js";
import { createBlueBubblesFetchGuardPassthroughInstaller } from "./test-harness.js";
import {
createBlueBubblesMonitorTestRuntime,
EMPTY_DISPATCH_RESULT,
resetBlueBubblesMonitorTestState,
type DispatchReplyParams,
} from "./test-support/monitor-test-support.js";
import { _setFetchGuardForTesting } from "./types.js";
// Mock dependencies
vi.mock("./send.js", () => ({
resolveChatGuidForTarget: vi.fn().mockResolvedValue("iMessage;-;+15551234567"),
sendMessageBlueBubbles: vi.fn().mockResolvedValue({
messageId: "msg-123",
receipt: {
primaryPlatformMessageId: "msg-123",
platformMessageIds: ["msg-123"],
parts: [],
sentAt: 0,
raw: [],
},
}),
}));
vi.mock("./chat.js", () => ({
markBlueBubblesChatRead: vi.fn().mockResolvedValue(undefined),
sendBlueBubblesTyping: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("./attachments.js", () => ({
downloadBlueBubblesAttachment: vi.fn().mockResolvedValue({
buffer: Buffer.from("test"),
contentType: "image/jpeg",
}),
}));
vi.mock("./reactions.js", async () => {
const actual = await vi.importActual<typeof import("./reactions.js")>("./reactions.js");
return {
...actual,
sendBlueBubblesReaction: vi.fn().mockResolvedValue(undefined),
};
});
vi.mock("./history.js", () => ({
fetchBlueBubblesHistory: vi.fn().mockResolvedValue({ entries: [], resolved: true }),
}));
// Mock runtime
const mockEnqueueSystemEvent = vi.fn();
const mockBuildPairingReply = vi.fn(() => "Pairing code: TESTCODE");
const mockReadAllowFromStore = vi.fn().mockResolvedValue([]);
const mockUpsertPairingRequest = vi.fn().mockResolvedValue({ code: "TESTCODE", created: true });
const DEFAULT_RESOLVED_AGENT_ROUTE: ReturnType<
PluginRuntime["channel"]["routing"]["resolveAgentRoute"]
> = {
agentId: "main",
channel: "bluebubbles",
accountId: "default",
sessionKey: "agent:main:bluebubbles:dm:+15551234567",
mainSessionKey: "agent:main:main",
lastRoutePolicy: "main",
matchedBy: "default",
};
const mockResolveAgentRoute = vi.fn(() => DEFAULT_RESOLVED_AGENT_ROUTE);
function blueBubblesTestSendResult(messageId: string) {
const hasPlatformId = messageId && messageId !== "ok" && messageId !== "unknown";
return {
messageId,
receipt: {
...(hasPlatformId ? { primaryPlatformMessageId: messageId } : {}),
platformMessageIds: hasPlatformId ? [messageId] : [],
parts: [],
sentAt: 0,
raw: [],
},
};
}
const mockBuildMentionRegexes = vi.fn(() => [/\bbert\b/i]);
const mockMatchesMentionPatterns = vi.fn((text: string, regexes: RegExp[]) =>
regexes.some((r) => r.test(text)),
);
const mockMatchesMentionWithExplicit = vi.fn(
(params: { text: string; mentionRegexes: RegExp[]; explicitWasMentioned?: boolean }) => {
if (params.explicitWasMentioned) {
return true;
}
return params.mentionRegexes.some((regex) => regex.test(params.text));
},
);
const mockResolveRequireMention = vi.fn(() => false);
const mockResolveGroupPolicy = vi.fn(() => ({
allowlistEnabled: false,
allowed: true,
}));
const mockDispatchReplyWithBufferedBlockDispatcher = vi.fn(
async (_params: DispatchReplyParams) => EMPTY_DISPATCH_RESULT,
);
const mockHasControlCommand = vi.fn(() => false);
const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false);
const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
id: "test-media.jpg",
path: "/tmp/test-media.jpg",
size: Buffer.byteLength("test"),
contentType: "image/jpeg",
});
const mockResolveStorePath = vi.fn(() => "/tmp/sessions.json");
const mockReadSessionUpdatedAt = vi.fn(() => undefined);
const mockResolveEnvelopeFormatOptions = vi.fn(() => ({}));
const mockFormatAgentEnvelope = vi.fn((opts: { body: string }) => opts.body);
const mockFormatInboundEnvelope = vi.fn((opts: { body: string }) => opts.body);
const mockChunkMarkdownText = vi.fn((text: string) => [text]);
const mockChunkByNewline = vi.fn((text: string) => (text ? [text] : []));
const mockChunkTextWithMode = vi.fn((text: string) => (text ? [text] : []));
const mockChunkMarkdownTextWithMode = vi.fn((text: string) => (text ? [text] : []));
const mockResolveChunkMode = vi.fn(() => "length" as const);
const mockFetchBlueBubblesHistory = vi.mocked(fetchBlueBubblesHistory);
const mockFetch = vi.fn();
function createMockRuntime(): PluginRuntime {
return createBlueBubblesMonitorTestRuntime({
enqueueSystemEvent: mockEnqueueSystemEvent,
chunkMarkdownText: mockChunkMarkdownText,
chunkByNewline: mockChunkByNewline,
chunkMarkdownTextWithMode: mockChunkMarkdownTextWithMode,
chunkTextWithMode: mockChunkTextWithMode,
resolveChunkMode: mockResolveChunkMode,
hasControlCommand: mockHasControlCommand,
dispatchReplyWithBufferedBlockDispatcher: mockDispatchReplyWithBufferedBlockDispatcher,
formatAgentEnvelope: mockFormatAgentEnvelope,
formatInboundEnvelope: mockFormatInboundEnvelope,
resolveEnvelopeFormatOptions: mockResolveEnvelopeFormatOptions,
resolveAgentRoute: mockResolveAgentRoute,
buildPairingReply: mockBuildPairingReply,
readAllowFromStore: mockReadAllowFromStore,
upsertPairingRequest: mockUpsertPairingRequest,
saveMediaBuffer: mockSaveMediaBuffer,
resolveStorePath: mockResolveStorePath,
readSessionUpdatedAt: mockReadSessionUpdatedAt,
buildMentionRegexes: mockBuildMentionRegexes,
matchesMentionPatterns: mockMatchesMentionPatterns,
matchesMentionWithExplicit: mockMatchesMentionWithExplicit,
resolveGroupPolicy: mockResolveGroupPolicy,
resolveRequireMention: mockResolveRequireMention,
resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers,
});
}
function getFirstDispatchCall(): DispatchReplyParams {
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
if (!callArgs) {
throw new Error("expected dispatch call arguments");
}
return callArgs;
}
function installTimingAwareInboundDebouncer(core: PluginRuntime) {
// Use a timing-aware debouncer test double that respects debounceMs/buildKey/shouldDebounce.
core.channel.debounce.createInboundDebouncer = vi.fn((params: any) => {
type Item = any;
const buckets = new Map<
string,
{ items: Item[]; timer: ReturnType<typeof setTimeout> | null }
>();
const flush = async (key: string) => {
const bucket = buckets.get(key);
if (!bucket) {
return;
}
if (bucket.timer) {
clearTimeout(bucket.timer);
bucket.timer = null;
}
const items = bucket.items;
bucket.items = [];
if (items.length > 0) {
try {
await params.onFlush(items);
} catch (err) {
params.onError?.(err);
throw err;
}
}
};
return {
enqueue: async (item: Item) => {
if (params.shouldDebounce && !params.shouldDebounce(item)) {
await params.onFlush([item]);
return;
}
const key = params.buildKey(item);
const existing = buckets.get(key);
const bucket = existing ?? { items: [], timer: null };
bucket.items.push(item);
if (bucket.timer) {
clearTimeout(bucket.timer);
}
bucket.timer = setTimeout(async () => {
await flush(key);
}, params.debounceMs);
buckets.set(key, bucket);
},
flushKey: vi.fn(async (key: string) => {
await flush(key);
}),
};
}) as unknown as PluginRuntime["channel"]["debounce"]["createInboundDebouncer"];
}
function createDebounceTestMessage(
overrides: Partial<NormalizedWebhookMessage> = {},
): NormalizedWebhookMessage {
return {
text: "hello",
senderId: "+15551234567",
senderIdExplicit: true,
isGroup: false,
...overrides,
};
}
describe("BlueBubbles webhook monitor", () => {
let unregister: () => void;
function setupWebhookTarget(params?: {
account?: ReturnType<typeof createMockAccount>;
config?: OpenClawConfig;
core?: PluginRuntime;
}) {
const registration = trackWebhookRegistrationForTest(
setupWebhookTargetForTest({
createCore: createMockRuntime,
core: params?.core,
account: params?.account,
config: params?.config,
}),
(nextUnregister) => {
unregister = nextUnregister;
},
);
return { core: registration.core };
}
async function dispatchWebhookPayload(payload: unknown, url = "/bluebubbles-webhook") {
return (await dispatchWebhookPayloadForTest({ body: payload, url })).res;
}
async function dispatchWebhookPayloadDirect(payload: unknown, url = "/bluebubbles-webhook") {
const { handled } = await dispatchWebhookRequestForTest(
createMockRequest("POST", url, payload),
);
return handled;
}
const installFetchGuardPassthrough = createBlueBubblesFetchGuardPassthroughInstaller();
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
// The BlueBubblesClient now routes every BB API call through the SSRF
// guard (mode-2 allowlist for configured hostnames). Install a passthrough
// that wraps `globalThis.fetch` (our stubbed mockFetch) in a real Response
// so guarded callers get the same mocked behavior the pre-migration
// callsites did. (#34749, #59722)
installFetchGuardPassthrough();
mockFetch.mockReset();
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: [] }),
});
resetBlueBubblesMonitorTestState({
createRuntime: createMockRuntime,
fetchHistoryMock: mockFetchBlueBubblesHistory,
readAllowFromStoreMock: mockReadAllowFromStore,
upsertPairingRequestMock: mockUpsertPairingRequest,
resolveRequireMentionMock: mockResolveRequireMention,
hasControlCommandMock: mockHasControlCommand,
resolveCommandAuthorizedFromAuthorizersMock: mockResolveCommandAuthorizedFromAuthorizers,
buildMentionRegexesMock: mockBuildMentionRegexes,
extraReset: () => {
resetBlueBubblesSelfChatCache();
resetBlueBubblesParticipantContactNameCacheForTest();
setBlueBubblesParticipantContactDepsForTest();
},
});
});
afterEach(() => {
unregister?.();
setBlueBubblesParticipantContactDepsForTest();
vi.useRealTimers();
vi.unstubAllGlobals();
_setFetchGuardForTesting(null);
});
describe("DM pairing behavior vs allowFrom", () => {
it("allows DM from sender in allowFrom list", async () => {
setupWebhookTarget({
account: createMockAccount({
dmPolicy: "allowlist",
allowFrom: ["+15551234567"],
}),
});
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello from allowed sender",
});
const res = await dispatchWebhookPayload(payload);
expect(res.statusCode).toBe(200);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
it("blocks DM from sender not in allowFrom when dmPolicy=allowlist", async () => {
setupWebhookTarget({
account: createMockAccount({
dmPolicy: "allowlist",
allowFrom: ["+15559999999"], // Different number
}),
});
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello from blocked sender",
});
const res = await dispatchWebhookPayload(payload);
expect(res.statusCode).toBe(200);
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it("blocks DM when dmPolicy=allowlist and allowFrom is empty", async () => {
setupWebhookTarget({
account: createMockAccount({
dmPolicy: "allowlist",
allowFrom: [],
}),
});
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello from blocked sender",
});
const res = await dispatchWebhookPayload(payload);
expect(res.statusCode).toBe(200);
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
expect(mockUpsertPairingRequest).not.toHaveBeenCalled();
});
it("triggers pairing flow for unknown sender when dmPolicy=pairing and allowFrom is empty", async () => {
setupWebhookTarget({
account: createMockAccount({
dmPolicy: "pairing",
allowFrom: [],
}),
});
const payload = createTimestampedNewMessagePayloadForTest();
await dispatchWebhookPayload(payload);
expect(mockUpsertPairingRequest).toHaveBeenCalled();
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it("triggers pairing flow for unknown sender when dmPolicy=pairing", async () => {
setupWebhookTarget({
account: createMockAccount({
dmPolicy: "pairing",
allowFrom: ["+15559999999"], // Different number than sender
}),
});
const payload = createTimestampedNewMessagePayloadForTest();
await dispatchWebhookPayload(payload);
expect(mockUpsertPairingRequest).toHaveBeenCalled();
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it("does not resend pairing reply when request already exists", async () => {
mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: false });
setupWebhookTarget({
account: createMockAccount({
dmPolicy: "pairing",
allowFrom: ["+15559999999"], // Different number than sender
}),
});
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello again",
guid: "msg-2",
});
await dispatchWebhookPayload(payload);
expect(mockUpsertPairingRequest).toHaveBeenCalled();
// Should not send pairing reply since created=false
const { sendMessageBlueBubbles } = await import("./send.js");
expect(sendMessageBlueBubbles).not.toHaveBeenCalled();
});
it("allows wildcard DMs when dmPolicy=open", async () => {
setupWebhookTarget({
account: createMockAccount({
dmPolicy: "open",
allowFrom: ["*"],
}),
});
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello from anyone",
handle: { address: "+15559999999" },
});
await dispatchWebhookPayload(payload);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
it("blocks all DMs when dmPolicy=disabled", async () => {
setupWebhookTarget({
account: createMockAccount({
dmPolicy: "disabled",
}),
});
const payload = createTimestampedNewMessagePayloadForTest();
await dispatchWebhookPayload(payload);
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
});
describe("group message gating", () => {
it("allows group messages when groupPolicy=open and no allowlist", async () => {
setupWebhookTarget({
account: createMockAccount({
groupPolicy: "open",
}),
});
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello from group",
isGroup: true,
chatGuid: "iMessage;+;chat123456",
});
await dispatchWebhookPayload(payload);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
it("blocks group messages when groupPolicy=disabled", async () => {
setupWebhookTarget({
account: createMockAccount({
groupPolicy: "disabled",
}),
});
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello from group",
isGroup: true,
chatGuid: "iMessage;+;chat123456",
});
await dispatchWebhookPayload(payload);
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it("treats chat_guid groups as group even when isGroup=false", async () => {
setupWebhookTarget({
account: createMockAccount({
groupPolicy: "allowlist",
dmPolicy: "open",
allowFrom: [],
}),
});
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello from group",
chatGuid: "iMessage;+;chat123456",
});
await dispatchWebhookPayload(payload);
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it("allows group messages from allowed chat_guid in groupAllowFrom", async () => {
setupWebhookTarget({
account: createMockAccount({
groupPolicy: "allowlist",
groupAllowFrom: ["chat_guid:iMessage;+;chat123456"],
}),
});
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello from allowed group",
isGroup: true,
chatGuid: "iMessage;+;chat123456",
});
await dispatchWebhookPayload(payload);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
});
describe("mention gating (group messages)", () => {
it("processes group message when mentioned and requireMention=true", async () => {
mockResolveRequireMention.mockReturnValue(true);
mockMatchesMentionPatterns.mockReturnValue(true);
setupWebhookTarget();
const payload = createTimestampedNewMessagePayloadForTest({
text: "bert, can you help me?",
isGroup: true,
chatGuid: "iMessage;+;chat123456",
});
await dispatchWebhookPayload(payload);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = getFirstDispatchCall();
expect(callArgs.ctx.WasMentioned).toBe(true);
});
it("skips group message when not mentioned and requireMention=true", async () => {
mockResolveRequireMention.mockReturnValue(true);
mockMatchesMentionPatterns.mockReturnValue(false);
setupWebhookTarget();
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello everyone",
isGroup: true,
chatGuid: "iMessage;+;chat123456",
});
await dispatchWebhookPayload(payload);
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it("processes group message without mention when requireMention=false", async () => {
mockResolveRequireMention.mockReturnValue(false);
setupWebhookTarget();
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello everyone",
isGroup: true,
chatGuid: "iMessage;+;chat123456",
});
await dispatchWebhookPayload(payload);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
});
describe("group metadata", () => {
it("includes group subject + members in ctx", async () => {
setupWebhookTarget();
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello group",
isGroup: true,
chatGuid: "iMessage;+;chat123456",
chatName: "Family",
participants: [
{ address: "+15551234567", displayName: "Alice" },
{ address: "+15557654321", displayName: "Bob" },
],
});
await dispatchWebhookPayload(payload);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = getFirstDispatchCall();
expect(callArgs.ctx.GroupSubject).toBe("Family");
expect(callArgs.ctx.GroupMembers).toBe("Alice (+15551234567), Bob (+15557654321)");
});
it("threads per-group systemPrompt into ctx for group messages", async () => {
setupWebhookTarget({
account: createMockAccount({
groups: {
"iMessage;+;chat123456": {
systemPrompt: "Reply in thread with action=reply; ack via action=react.",
},
},
}),
});
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello group",
isGroup: true,
chatGuid: "iMessage;+;chat123456",
chatName: "Family",
participants: [{ address: "+15551234567", displayName: "Alice" }],
});
await dispatchWebhookPayload(payload);
const callArgs = getFirstDispatchCall();
expect(callArgs.ctx.GroupSystemPrompt).toBe(
"Reply in thread with action=reply; ack via action=react.",
);
});
it("falls back to the '*' wildcard systemPrompt when no exact group match", async () => {
setupWebhookTarget({
account: createMockAccount({
groups: {
"*": { systemPrompt: "Default group rule: keep it short." },
},
}),
});
const payload = createTimestampedNewMessagePayloadForTest({
text: "hi group",
isGroup: true,
chatGuid: "iMessage;+;chat-unmapped",
chatName: "Family",
participants: [{ address: "+15551234567", displayName: "Alice" }],
});
await dispatchWebhookPayload(payload);
const callArgs = getFirstDispatchCall();
expect(callArgs.ctx.GroupSystemPrompt).toBe("Default group rule: keep it short.");
});
it("prefers an exact group systemPrompt over the '*' wildcard", async () => {
setupWebhookTarget({
account: createMockAccount({
groups: {
"*": { systemPrompt: "wildcard value" },
"iMessage;+;chat123456": { systemPrompt: "exact value" },
},
}),
});
const payload = createTimestampedNewMessagePayloadForTest({
text: "hi group",
isGroup: true,
chatGuid: "iMessage;+;chat123456",
chatName: "Family",
participants: [{ address: "+15551234567", displayName: "Alice" }],
});
await dispatchWebhookPayload(payload);
const callArgs = getFirstDispatchCall();
expect(callArgs.ctx.GroupSystemPrompt).toBe("exact value");
});
it("omits GroupSystemPrompt for DMs even when the group config would match", async () => {
setupWebhookTarget({
account: createMockAccount({
groups: {
"+15551234567": { systemPrompt: "unused in DM" },
},
}),
});
const payload = createTimestampedNewMessagePayloadForTest({
text: "hi",
isGroup: false,
});
await dispatchWebhookPayload(payload);
const callArgs = getFirstDispatchCall();
expect(callArgs.ctx.GroupSystemPrompt).toBeUndefined();
});
it("does not enrich group participants when the config flag is disabled", async () => {
const resolvePhoneNames = vi.fn(async () => new Map([["5551234567", "Alice Contact"]]));
setupWebhookTarget({
account: createMockAccount({
enrichGroupParticipantsFromContacts: false,
}),
});
setBlueBubblesParticipantContactDepsForTest({
platform: "darwin",
resolvePhoneNames,
});
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello bert",
isGroup: true,
chatGuid: "iMessage;+;chat123456",
chatName: "Family",
participants: [{ address: "+15551234567" }],
});
await dispatchWebhookPayload(payload);
expect(resolvePhoneNames).not.toHaveBeenCalled();
expect(getFirstDispatchCall().ctx.GroupMembers).toBe("+15551234567");
});
it("enriches unnamed phone participants from local contacts after gating passes", async () => {
const resolvePhoneNames = vi.fn(
async (phoneKeys: string[]) =>
new Map(
phoneKeys.map((phoneKey) => [
phoneKey,
phoneKey === "5551234567" ? "Alice Contact" : "Bob Contact",
]),
),
);
setupWebhookTarget({
account: createMockAccount({
enrichGroupParticipantsFromContacts: true,
}),
});
setBlueBubblesParticipantContactDepsForTest({
platform: "darwin",
resolvePhoneNames,
});
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello bert",
isGroup: true,
chatGuid: "iMessage;+;chat123456",
chatName: "Family",
participants: [{ address: "+15551234567" }, { address: "+15557654321" }],
});
await dispatchWebhookPayload(payload);
expect(resolvePhoneNames).toHaveBeenCalledTimes(1);
const callArgs = getFirstDispatchCall();
expect(callArgs.ctx.GroupMembers).toBe(
"Alice Contact (+15551234567), Bob Contact (+15557654321)",
);
});
it("fetches missing group participants from the BlueBubbles API before contact enrichment", async () => {
const resolvePhoneNames = vi.fn(
async (phoneKeys: string[]) =>
new Map(
phoneKeys.map((phoneKey) => [
phoneKey,
phoneKey === "5551234567" ? "Alice Contact" : "Bob Contact",
]),
),
);
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;+;chat123456",
participants: [{ address: "+15551234567" }, { address: "+15557654321" }],
},
],
}),
});
setupWebhookTarget({
account: createMockAccount({
enrichGroupParticipantsFromContacts: true,
}),
});
setBlueBubblesParticipantContactDepsForTest({
platform: "darwin",
resolvePhoneNames,
});
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello bert",
isGroup: true,
chatGuid: "iMessage;+;chat123456",
chatName: "Family",
});
await dispatchWebhookPayload(payload);
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/chat/query"),
expect.objectContaining({ method: "POST" }),
);
expect(resolvePhoneNames).toHaveBeenCalledTimes(1);
expect(getFirstDispatchCall().ctx.GroupMembers).toBe(
"Alice Contact (+15551234567), Bob Contact (+15557654321)",
);
});
it("does not read local contacts before mention gating allows the message", async () => {
const resolvePhoneNames = vi.fn(async () => new Map([["5551234567", "Alice Contact"]]));
setupWebhookTarget({
account: createMockAccount({
enrichGroupParticipantsFromContacts: true,
}),
});
setBlueBubblesParticipantContactDepsForTest({
platform: "darwin",
resolvePhoneNames,
});
mockResolveRequireMention.mockReturnValueOnce(true);
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello group",
isGroup: true,
chatGuid: "iMessage;+;chat123456",
chatName: "Family",
participants: [{ address: "+15551234567" }],
});
await dispatchWebhookPayload(payload);
expect(resolvePhoneNames).not.toHaveBeenCalled();
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
});
describe("group sender identity in envelope", () => {
it("includes sender in envelope body and group label as from for group messages", async () => {
setupWebhookTarget();
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello everyone",
senderName: "Alice",
isGroup: true,
chatGuid: "iMessage;+;chat123456",
chatName: "Family Chat",
});
await dispatchWebhookPayload(payload);
// formatInboundEnvelope should be called with group label + id as from, and sender info
expect(mockFormatInboundEnvelope).toHaveBeenCalledWith(
expect.objectContaining({
from: "Family Chat id:iMessage;+;chat123456",
chatType: "group",
sender: { name: "Alice", id: "+15551234567" },
}),
);
// ConversationLabel should be the group label + id, not the sender
const callArgs = getFirstDispatchCall();
expect(callArgs.ctx.ConversationLabel).toBe("Family Chat id:iMessage;+;chat123456");
expect(callArgs.ctx.SenderName).toBe("Alice");
// BodyForAgent should be raw text, not the envelope-formatted body
expect(callArgs.ctx.BodyForAgent).toBe("hello everyone");
});
it("falls back to group:peerId when chatName is missing", async () => {
setupWebhookTarget();
const payload = createTimestampedNewMessagePayloadForTest({
isGroup: true,
chatGuid: "iMessage;+;chat123456",
});
await dispatchWebhookPayload(payload);
expect(mockFormatInboundEnvelope).toHaveBeenCalledWith(
expect.objectContaining({
from: expect.stringMatching(/^Group id:/),
chatType: "group",
sender: { name: undefined, id: "+15551234567" },
}),
);
});
it("uses sender as from label for DM messages", async () => {
setupWebhookTarget();
const payload = createTimestampedNewMessagePayloadForTest({
senderName: "Alice",
});
await dispatchWebhookPayload(payload);
expect(mockFormatInboundEnvelope).toHaveBeenCalledWith(
expect.objectContaining({
from: "Alice id:+15551234567",
chatType: "direct",
sender: { name: "Alice", id: "+15551234567" },
}),
);
const callArgs = getFirstDispatchCall();
expect(callArgs.ctx.ConversationLabel).toBe("Alice id:+15551234567");
});
});
describe("inbound debouncing", () => {
it("coalesces text-only then attachment webhook events by messageId", async () => {
vi.useFakeTimers();
try {
const core = createMockRuntime();
installTimingAwareInboundDebouncer(core);
const _registration = trackWebhookRegistrationForTest(
setupWebhookTargetForTest({
createCore: createMockRuntime,
core,
}),
(nextUnregister) => {
unregister = nextUnregister;
},
);
const messageId = "race-msg-1";
const chatGuid = "iMessage;-;+15551234567";
const payloadA = createTimestampedNewMessagePayloadForTest({
guid: messageId,
chatGuid,
});
const payloadB = createTimestampedNewMessagePayloadForTest({
guid: messageId,
chatGuid,
attachments: [
{
guid: "att-1",
mimeType: "image/jpeg",
totalBytes: 1024,
},
],
});
await dispatchWebhookPayloadDirect(payloadA);
// Simulate the real-world delay where the attachment-bearing webhook arrives shortly after.
await vi.advanceTimersByTimeAsync(300);
await dispatchWebhookPayloadDirect(payloadB);
// Not flushed yet; still within the debounce window.
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
// After the debounce window, the combined message should be processed exactly once.
await vi.advanceTimersByTimeAsync(600);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
const callArgs = getFirstDispatchCall();
expect(callArgs.ctx.MediaPaths).toEqual(["/tmp/test-media.jpg"]);
expect(callArgs.ctx.Body).toContain("hello");
} finally {
vi.useRealTimers();
}
});
it("coalesces URL text with URL balloon webhook events by associatedMessageGuid", async () => {
vi.useFakeTimers();
try {
const core = createMockRuntime();
installTimingAwareInboundDebouncer(core);
const processMessage = vi.fn().mockResolvedValue(undefined);
const registry = createBlueBubblesDebounceRegistry({ processMessage });
const account = createMockAccount();
const target = {
account,
config: {},
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
};
const debouncer = registry.getOrCreateDebouncer(target);
const messageId = "url-msg-1";
const chatGuid = "iMessage;-;+15551234567";
const url = "https://github.com/bitfocus/companion/issues/4047";
await debouncer.enqueue({
message: createDebounceTestMessage({
chatGuid,
text: url,
messageId,
}),
target,
});
await vi.advanceTimersByTimeAsync(300);
await debouncer.enqueue({
message: createDebounceTestMessage({
chatGuid,
text: url,
messageId: "url-balloon-1",
balloonBundleId: "com.apple.messages.URLBalloonProvider",
associatedMessageGuid: messageId,
}),
target,
});
expect(processMessage).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(600);
expect(processMessage).toHaveBeenCalledTimes(1);
expect(processMessage).toHaveBeenCalledWith(
expect.objectContaining({
text: url,
messageId,
balloonBundleId: undefined,
}),
target,
);
expect(target.runtime.error).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("coalesces same-sender DM messages when coalesceSameSenderDms is enabled", async () => {
vi.useFakeTimers();
try {
const core = createMockRuntime();
installTimingAwareInboundDebouncer(core);
const processMessage = vi.fn().mockResolvedValue(undefined);
const registry = createBlueBubblesDebounceRegistry({ processMessage });
const account = createMockAccount({ coalesceSameSenderDms: true });
const target = {
account,
// Pin an explicit short debounce window so these tests stay
// decoupled from the coalesce-flag default (2500 ms). The
// "widens the default debounce window" test intentionally omits
// this override to exercise the new default.
config: { messages: { inbound: { byChannel: { bluebubbles: 500 } } } },
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
};
const debouncer = registry.getOrCreateDebouncer(target);
const chatGuid = "iMessage;-;+15551234567";
// Two distinct user sends: a command ("Dump") followed by a URL.
// No associatedMessageGuid linking them. Default buildKey hashes by
// per-message messageId, so historically they dispatched separately.
await debouncer.enqueue({
message: createDebounceTestMessage({
chatGuid,
text: "Dump",
messageId: "dm-msg-1",
}),
target,
});
await vi.advanceTimersByTimeAsync(300);
await debouncer.enqueue({
message: createDebounceTestMessage({
chatGuid,
text: "https://example.com/article",
messageId: "dm-msg-2",
}),
target,
});
expect(processMessage).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(600);
expect(processMessage).toHaveBeenCalledTimes(1);
expect(processMessage).toHaveBeenCalledWith(
expect.objectContaining({
text: "Dump https://example.com/article",
// Every source messageId must reach inbound-dedupe so a later
// MessagePoller replay of either event alone is recognized as a
// duplicate rather than re-processed.
coalescedMessageIds: ["dm-msg-1", "dm-msg-2"],
}),
target,
);
expect(target.runtime.error).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("does not coalesce same-sender DM messages when coalesceSameSenderDms is off (default)", async () => {
vi.useFakeTimers();
try {
const core = createMockRuntime();
installTimingAwareInboundDebouncer(core);
const processMessage = vi.fn().mockResolvedValue(undefined);
const registry = createBlueBubblesDebounceRegistry({ processMessage });
const account = createMockAccount();
const target = {
account,
config: {},
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
};
const debouncer = registry.getOrCreateDebouncer(target);
const chatGuid = "iMessage;-;+15551234567";
await debouncer.enqueue({
message: createDebounceTestMessage({
chatGuid,
text: "Dump",
messageId: "dm-msg-1",
}),
target,
});
await vi.advanceTimersByTimeAsync(300);
await debouncer.enqueue({
message: createDebounceTestMessage({
chatGuid,
text: "https://example.com/article",
messageId: "dm-msg-2",
}),
target,
});
await vi.advanceTimersByTimeAsync(600);
expect(processMessage).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
it("bounds the coalesced output when many messages merge into one turn", async () => {
vi.useFakeTimers();
try {
const core = createMockRuntime();
installTimingAwareInboundDebouncer(core);
const processMessage = vi.fn().mockResolvedValue(undefined);
const registry = createBlueBubblesDebounceRegistry({ processMessage });
const account = createMockAccount({ coalesceSameSenderDms: true });
const target = {
account,
// Pin an explicit short debounce window so these tests stay
// decoupled from the coalesce-flag default (2500 ms). The
// "widens the default debounce window" test intentionally omits
// this override to exercise the new default.
config: { messages: { inbound: { byChannel: { bluebubbles: 500 } } } },
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
};
const debouncer = registry.getOrCreateDebouncer(target);
const chatGuid = "iMessage;-;+15551234567";
// Use a unique long text block per entry to exceed MAX_COALESCED_TEXT_CHARS (4000)
// after naive concatenation. 25 entries × ~400 chars ≈ 10_000 chars worth of content.
const blob = "x".repeat(400);
for (let i = 0; i < 25; i++) {
await debouncer.enqueue({
message: createDebounceTestMessage({
chatGuid,
text: `msg-${i}-${blob}`,
messageId: `flood-${i}`,
attachments: [{ guid: `att-${i}`, mimeType: "image/jpeg", totalBytes: 1024 }],
}),
target,
});
await vi.advanceTimersByTimeAsync(10);
}
await vi.advanceTimersByTimeAsync(600);
expect(processMessage).toHaveBeenCalledTimes(1);
const [merged] = processMessage.mock.calls[0] as [NormalizedWebhookMessage, unknown];
// Text is truncated with explicit marker instead of ballooning.
expect(merged.text.length).toBeLessThanOrEqual(4000 + "…[truncated]".length);
expect(merged.text.endsWith("…[truncated]")).toBe(true);
// Attachments are capped so downstream media fan-out stays bounded.
expect(merged.attachments?.length).toBeLessThanOrEqual(20);
// Every source messageId — including ones whose text/attachments the
// cap dropped — still reaches inbound-dedupe. Truncation caps prompt
// size; it must not leak replay risk.
expect(merged.coalescedMessageIds).toHaveLength(25);
expect(merged.coalescedMessageIds?.[0]).toBe("flood-0");
expect(merged.coalescedMessageIds?.[24]).toBe("flood-24");
} finally {
vi.useRealTimers();
}
});
it("does not coalesce group-chat messages even with coalesceSameSenderDms enabled", async () => {
vi.useFakeTimers();
try {
const core = createMockRuntime();
installTimingAwareInboundDebouncer(core);
const processMessage = vi.fn().mockResolvedValue(undefined);
const registry = createBlueBubblesDebounceRegistry({ processMessage });
const account = createMockAccount({ coalesceSameSenderDms: true });
const target = {
account,
// Pin an explicit short debounce window so these tests stay
// decoupled from the coalesce-flag default (2500 ms). The
// "widens the default debounce window" test intentionally omits
// this override to exercise the new default.
config: { messages: { inbound: { byChannel: { bluebubbles: 500 } } } },
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
};
const debouncer = registry.getOrCreateDebouncer(target);
const chatGuid = "iMessage;-;group-abc";
await debouncer.enqueue({
message: createDebounceTestMessage({
chatGuid,
text: "first",
messageId: "grp-msg-1",
isGroup: true,
}),
target,
});
await vi.advanceTimersByTimeAsync(300);
await debouncer.enqueue({
message: createDebounceTestMessage({
chatGuid,
text: "second",
messageId: "grp-msg-2",
isGroup: true,
}),
target,
});
await vi.advanceTimersByTimeAsync(600);
expect(processMessage).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
it("debounces DM control commands when coalesceSameSenderDms is on so a split-send URL can join the bucket", async () => {
vi.useFakeTimers();
try {
const core = createMockRuntime();
installTimingAwareInboundDebouncer(core);
const processMessage = vi.fn().mockResolvedValue(undefined);
const registry = createBlueBubblesDebounceRegistry({ processMessage });
const account = createMockAccount({ coalesceSameSenderDms: true });
const target = {
account,
// Pin an explicit short debounce window so these tests stay
// decoupled from the coalesce-flag default (2500 ms). The
// "widens the default debounce window" test intentionally omits
// this override to exercise the new default.
config: { messages: { inbound: { byChannel: { bluebubbles: 500 } } } },
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
};
const debouncer = registry.getOrCreateDebouncer(target);
const chatGuid = "iMessage;-;+15551234567";
// Simulate a registered skill alias ("Dump") — normally this would
// flush immediately and miss its split-send URL. The coalesce flag
// must override that short-circuit for DMs specifically.
mockHasControlCommand.mockReturnValue(true);
await debouncer.enqueue({
message: createDebounceTestMessage({
chatGuid,
text: "Dump",
messageId: "cmd-msg-1",
}),
target,
});
// Apple/BlueBubbles delivers the URL ~750 ms later — well inside a
// reasonable coalesce window.
await vi.advanceTimersByTimeAsync(300);
expect(processMessage).not.toHaveBeenCalled();
await debouncer.enqueue({
message: createDebounceTestMessage({
chatGuid,
text: "https://example.com/article",
messageId: "cmd-msg-2",
}),
target,
});
await vi.advanceTimersByTimeAsync(600);
expect(processMessage).toHaveBeenCalledTimes(1);
expect(processMessage).toHaveBeenCalledWith(
expect.objectContaining({
text: "Dump https://example.com/article",
coalescedMessageIds: ["cmd-msg-1", "cmd-msg-2"],
}),
target,
);
} finally {
vi.useRealTimers();
mockHasControlCommand.mockReturnValue(false);
}
});
it("coalesces an orphan URL-balloon with a preceding DM control command (Apple split-send)", async () => {
vi.useFakeTimers();
try {
const core = createMockRuntime();
installTimingAwareInboundDebouncer(core);
const processMessage = vi.fn().mockResolvedValue(undefined);
const registry = createBlueBubblesDebounceRegistry({ processMessage });
const account = createMockAccount({ coalesceSameSenderDms: true });
const target = {
account,
// Pin an explicit short debounce window so these tests stay
// decoupled from the coalesce-flag default (2500 ms). The
// "widens the default debounce window" test intentionally omits
// this override to exercise the new default.
config: { messages: { inbound: { byChannel: { bluebubbles: 500 } } } },
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
};
const debouncer = registry.getOrCreateDebouncer(target);
const chatGuid = "iMessage;-;+15551234567";
mockHasControlCommand.mockReturnValue(true);
// Matches the live trace from BB server:
// 20:45:13.232 New Message "Dump"
// 20:45:14.274 New Message "https://..."; Attachments: 3
// The second webhook arrives with balloonBundleId set (URL-preview
// balloon) but no associatedMessageGuid linking it back to "Dump" —
// this is Apple's orphan split-send. buildKey must still place it in
// the same dm:<chat>:<sender> bucket as the "Dump" event.
await debouncer.enqueue({
message: createDebounceTestMessage({
chatGuid,
text: "Dump",
messageId: "split-cmd",
}),
target,
});
// Stay inside the default 500 ms window so the bucket is still open
// when the URL-balloon arrives. Real traffic needs `messages.inbound.byChannel.bluebubbles`
// bumped to ~2500 ms for the observed ~800-1800 ms Apple split-send
// cadence; this unit test keeps the default window and just proves
// the key/shouldDebounce logic buckets both webhooks together.
await vi.advanceTimersByTimeAsync(300);
// The URL-balloon's text is not a registered command, so the mock
// must return false for that one call.
mockHasControlCommand.mockReturnValueOnce(false);
await debouncer.enqueue({
message: createDebounceTestMessage({
chatGuid,
text: "https://www.theverge.com/tech/906873/sofa-app-track-tv-movies-installer",
messageId: "split-url",
balloonBundleId: "com.apple.messages.URLBalloonProvider",
}),
target,
});
await vi.advanceTimersByTimeAsync(600);
expect(processMessage).toHaveBeenCalledTimes(1);
expect(processMessage).toHaveBeenCalledWith(
expect.objectContaining({
text: "Dump https://www.theverge.com/tech/906873/sofa-app-track-tv-movies-installer",
coalescedMessageIds: ["split-cmd", "split-url"],
}),
target,
);
} finally {
vi.useRealTimers();
mockHasControlCommand.mockReturnValue(false);
}
});
it("widens the default debounce window when coalesceSameSenderDms is enabled without explicit config", async () => {
vi.useFakeTimers();
try {
const core = createMockRuntime();
installTimingAwareInboundDebouncer(core);
const processMessage = vi.fn().mockResolvedValue(undefined);
const registry = createBlueBubblesDebounceRegistry({ processMessage });
const account = createMockAccount({ coalesceSameSenderDms: true });
const target = {
account,
// Intentionally NO messages.inbound.byChannel.bluebubbles —
// this test exercises the coalesce-flag default (2500 ms).
config: {},
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
};
const debouncer = registry.getOrCreateDebouncer(target);
const chatGuid = "iMessage;-;+15551234567";
await debouncer.enqueue({
message: createDebounceTestMessage({
chatGuid,
text: "first",
messageId: "wide-1",
}),
target,
});
// 1500 ms is well outside the legacy 500 ms default but inside the
// 2500 ms coalesce default — without the new default, the first
// entry would flush alone before the second enqueue arrives.
await vi.advanceTimersByTimeAsync(1500);
expect(processMessage).not.toHaveBeenCalled();
await debouncer.enqueue({
message: createDebounceTestMessage({
chatGuid,
text: "second",
messageId: "wide-2",
}),
target,
});
await vi.advanceTimersByTimeAsync(3000);
expect(processMessage).toHaveBeenCalledTimes(1);
expect(processMessage).toHaveBeenCalledWith(
expect.objectContaining({
text: "first second",
coalescedMessageIds: ["wide-1", "wide-2"],
}),
target,
);
} finally {
vi.useRealTimers();
}
});
it("keeps the legacy 500 ms default window when coalesceSameSenderDms is off", async () => {
vi.useFakeTimers();
try {
const core = createMockRuntime();
installTimingAwareInboundDebouncer(core);
const processMessage = vi.fn().mockResolvedValue(undefined);
const registry = createBlueBubblesDebounceRegistry({ processMessage });
const account = createMockAccount(); // flag off
const target = {
account,
config: {},
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
};
const debouncer = registry.getOrCreateDebouncer(target);
await debouncer.enqueue({
message: createDebounceTestMessage({
chatGuid: "iMessage;-;+15551234567",
text: "only",
messageId: "legacy-1",
}),
target,
});
// Legacy behavior: flush within the tight 500 ms window so non-opt-in
// users keep their existing responsiveness.
await vi.advanceTimersByTimeAsync(600);
expect(processMessage).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
it("keeps control commands instant for group chats even when coalesceSameSenderDms is enabled", async () => {
vi.useFakeTimers();
try {
const core = createMockRuntime();
installTimingAwareInboundDebouncer(core);
const processMessage = vi.fn().mockResolvedValue(undefined);
const registry = createBlueBubblesDebounceRegistry({ processMessage });
const account = createMockAccount({ coalesceSameSenderDms: true });
const target = {
account,
// Pin an explicit short debounce window so these tests stay
// decoupled from the coalesce-flag default (2500 ms). The
// "widens the default debounce window" test intentionally omits
// this override to exercise the new default.
config: { messages: { inbound: { byChannel: { bluebubbles: 500 } } } },
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
};
const debouncer = registry.getOrCreateDebouncer(target);
mockHasControlCommand.mockReturnValue(true);
await debouncer.enqueue({
message: createDebounceTestMessage({
chatGuid: "iMessage;-;group-xyz",
text: "Dump",
messageId: "grp-cmd-1",
isGroup: true,
}),
target,
});
// Group-chat command must not wait for a hypothetical bucket-mate;
// the per-message debounce key never shares anyway, so instant flush
// is the correct behavior.
expect(processMessage).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
mockHasControlCommand.mockReturnValue(false);
}
});
it("skips null-text entries during flush and still delivers the valid message", async () => {
vi.useFakeTimers();
try {
const core = createMockRuntime();
installTimingAwareInboundDebouncer(core);
const processMessage = vi.fn().mockResolvedValue(undefined);
const registry = createBlueBubblesDebounceRegistry({ processMessage });
const account = createMockAccount();
const target = {
account,
config: {},
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
};
const debouncer = registry.getOrCreateDebouncer(target);
await debouncer.enqueue({
message: {
...createDebounceTestMessage({
messageId: "msg-null",
chatGuid: "iMessage;-;+15551234567",
}),
text: null,
} as unknown as NormalizedWebhookMessage,
target,
});
await vi.advanceTimersByTimeAsync(300);
await debouncer.enqueue({
message: createDebounceTestMessage({
text: "hello from valid entry",
messageId: "msg-null",
chatGuid: "iMessage;-;+15551234567",
}),
target,
});
await vi.advanceTimersByTimeAsync(600);
expect(processMessage).toHaveBeenCalledTimes(1);
expect(processMessage).toHaveBeenCalledWith(
expect.objectContaining({
text: "hello from valid entry",
}),
target,
);
expect(target.runtime.error).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
});
describe("reply metadata", () => {
it("surfaces reply fields in ctx when provided", async () => {
setupWebhookTarget();
const payload = createTimestampedNewMessagePayloadForTest({
text: "replying now",
chatGuid: "iMessage;-;+15551234567",
replyTo: {
guid: "msg-0",
text: "original message",
handle: { address: "+15550000000", displayName: "Alice" },
},
});
await dispatchWebhookPayload(payload);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = getFirstDispatchCall();
// ReplyToId is the full UUID since it wasn't previously cached
expect(callArgs.ctx.ReplyToId).toBe("msg-0");
expect(callArgs.ctx.ReplyToBody).toBe("original message");
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
// Body uses inline [[reply_to:N]] tag format
expect(callArgs.ctx.Body).toContain("[[reply_to:msg-0]]");
});
it("drops group reply context from non-allowlisted senders in allowlist mode", async () => {
setupWebhookTarget({
account: createMockAccount({
groupPolicy: "allowlist",
groupAllowFrom: ["+15551234567"],
}),
config: {
channels: {
bluebubbles: {
contextVisibility: "allowlist",
},
},
} as OpenClawConfig,
});
const payload = createTimestampedNewMessagePayloadForTest({
text: "replying now",
isGroup: true,
chatGuid: "iMessage;+;chat-reply-visibility",
replyTo: {
guid: "msg-0",
text: "blocked context",
handle: { address: "+15550000000", displayName: "Alice" },
},
});
await dispatchWebhookPayload(payload);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = getFirstDispatchCall();
expect(callArgs.ctx.ReplyToId).toBeUndefined();
expect(callArgs.ctx.ReplyToIdFull).toBeUndefined();
expect(callArgs.ctx.ReplyToBody).toBeUndefined();
expect(callArgs.ctx.ReplyToSender).toBeUndefined();
expect(callArgs.ctx.Body).not.toContain("[[reply_to:");
});
it("keeps group reply context in allowlist_quote mode", async () => {
setupWebhookTarget({
account: createMockAccount({
groupPolicy: "allowlist",
groupAllowFrom: ["+15551234567"],
}),
config: {
channels: {
bluebubbles: {
contextVisibility: "allowlist_quote",
},
},
} as OpenClawConfig,
});
const payload = createTimestampedNewMessagePayloadForTest({
text: "replying now",
isGroup: true,
chatGuid: "iMessage;+;chat-reply-visibility",
replyTo: {
guid: "msg-0",
text: "quoted context",
handle: { address: "+15550000000", displayName: "Alice" },
},
});
await dispatchWebhookPayload(payload);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = getFirstDispatchCall();
expect(callArgs.ctx.ReplyToId).toBe("msg-0");
expect(callArgs.ctx.ReplyToBody).toBe("quoted context");
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
expect(callArgs.ctx.Body).toContain("[[reply_to:msg-0]]");
});
it("preserves part index prefixes in reply tags when short IDs are unavailable", async () => {
setupWebhookTarget();
const payload = createTimestampedNewMessagePayloadForTest({
text: "replying now",
chatGuid: "iMessage;-;+15551234567",
replyTo: {
guid: "p:1/msg-0",
text: "original message",
handle: { address: "+15550000000", displayName: "Alice" },
},
});
await dispatchWebhookPayload(payload);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = getFirstDispatchCall();
expect(callArgs.ctx.ReplyToId).toBe("p:1/msg-0");
expect(callArgs.ctx.ReplyToIdFull).toBe("p:1/msg-0");
expect(callArgs.ctx.Body).toContain("[[reply_to:p:1/msg-0]]");
});
it("hydrates missing reply sender/body from the recent-message cache", async () => {
setupWebhookTarget();
const chatGuid = "iMessage;+;chat-reply-cache";
const originalPayload = createTimestampedNewMessagePayloadForTest({
text: "original message (cached)",
handle: { address: "+15550000000" },
isGroup: true,
guid: "cache-msg-0",
chatGuid,
});
await dispatchWebhookPayload(originalPayload);
// Only assert the reply message behavior below.
mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
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",
});
await dispatchWebhookPayload(replyPayload);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = getFirstDispatchCall();
// ReplyToId uses short ID "1" (first cached message) for token savings
expect(callArgs.ctx.ReplyToId).toBe("1");
expect(callArgs.ctx.ReplyToIdFull).toBe("cache-msg-0");
expect(callArgs.ctx.ReplyToBody).toBe("original message (cached)");
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
// Body uses inline [[reply_to:N]] tag format with short ID
expect(callArgs.ctx.Body).toContain("[[reply_to:1]]");
});
it("falls back to threadOriginatorGuid when reply metadata is absent", async () => {
setupWebhookTarget();
const payload = createTimestampedNewMessagePayloadForTest({
text: "replying now",
threadOriginatorGuid: "msg-0",
chatGuid: "iMessage;-;+15551234567",
});
await dispatchWebhookPayload(payload);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = getFirstDispatchCall();
expect(callArgs.ctx.ReplyToId).toBe("msg-0");
});
});
describe("tapback text parsing", () => {
it("does not rewrite tapback-like text without metadata", async () => {
setupWebhookTarget();
const payload = createTimestampedNewMessagePayloadForTest({
text: "Loved this idea",
chatGuid: "iMessage;-;+15551234567",
});
await dispatchWebhookPayload(payload);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = getFirstDispatchCall();
expect(callArgs.ctx.RawBody).toBe("Loved this idea");
expect(callArgs.ctx.Body).toContain("Loved this idea");
expect(callArgs.ctx.Body).not.toContain("reacted with");
});
it("parses tapback text with custom emoji when metadata is present", async () => {
setupWebhookTarget();
const payload = createTimestampedNewMessagePayloadForTest({
text: 'Reacted 😅 to "nice one"',
guid: "msg-2",
chatGuid: "iMessage;-;+15551234567",
});
await dispatchWebhookPayload(payload);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = getFirstDispatchCall();
expect(callArgs.ctx.RawBody).toBe("reacted with 😅");
expect(callArgs.ctx.Body).toContain("reacted with 😅");
expect(callArgs.ctx.Body).not.toContain("[[reply_to:");
});
});
describe("ack reactions", () => {
it("sends ack reaction when configured", async () => {
const { sendBlueBubblesReaction } = await import("./reactions.js");
vi.mocked(sendBlueBubblesReaction).mockClear();
setupWebhookTarget({
config: {
messages: {
ackReaction: "❤️",
ackReactionScope: "direct",
},
},
});
const payload = createTimestampedNewMessagePayloadForTest({
chatGuid: "iMessage;-;+15551234567",
});
await dispatchWebhookPayload(payload);
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
expect.objectContaining({
chatGuid: "iMessage;-;+15551234567",
messageGuid: "msg-1",
emoji: "❤️",
opts: expect.objectContaining({ accountId: "default" }),
}),
);
});
});
describe("command gating", () => {
it("allows control command to bypass mention gating when authorized", async () => {
mockResolveRequireMention.mockReturnValue(true);
mockMatchesMentionPatterns.mockReturnValue(false); // Not mentioned
mockHasControlCommand.mockReturnValue(true); // Has control command
mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(true); // Authorized
setupWebhookTarget({
account: createMockAccount({
groupPolicy: "open",
allowFrom: ["+15551234567"],
}),
});
const payload = createTimestampedNewMessagePayloadForTest({
text: "/status",
isGroup: true,
chatGuid: "iMessage;+;chat123456",
});
await dispatchWebhookPayload(payload);
// Should process even without mention because it's an authorized control command
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
it("blocks control command from unauthorized sender in group", async () => {
mockHasControlCommand.mockReturnValue(true);
mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(false);
setupWebhookTarget({
account: createMockAccount({
groupPolicy: "open",
allowFrom: [], // No one authorized
}),
});
const payload = createTimestampedNewMessagePayloadForTest({
text: "/status",
handle: { address: "+15559999999" },
isGroup: true,
chatGuid: "iMessage;+;chat123456",
});
await dispatchWebhookPayload(payload);
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it("drops DM control commands in open mode without allowlists", async () => {
mockHasControlCommand.mockReturnValue(true);
setupWebhookTarget({
account: createMockAccount({
dmPolicy: "open",
allowFrom: [],
}),
});
const payload = createTimestampedNewMessagePayloadForTest({
text: "/status",
handle: { address: "+15559999999" },
guid: "msg-dm-open-unauthorized",
});
await dispatchWebhookPayload(payload);
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
});
describe("typing/read receipt toggles", () => {
it("marks chat as read when sendReadReceipts=true (default)", async () => {
const { markBlueBubblesChatRead } = await import("./chat.js");
vi.mocked(markBlueBubblesChatRead).mockClear();
setupWebhookTarget({
account: createMockAccount({
sendReadReceipts: true,
}),
});
const payload = createTimestampedNewMessagePayloadForTest({
chatGuid: "iMessage;-;+15551234567",
});
await dispatchWebhookPayload(payload);
expect(markBlueBubblesChatRead).toHaveBeenCalled();
});
it("does not mark chat as read when sendReadReceipts=false", async () => {
const { markBlueBubblesChatRead } = await import("./chat.js");
vi.mocked(markBlueBubblesChatRead).mockClear();
setupWebhookTarget({
account: createMockAccount({
sendReadReceipts: false,
}),
});
const payload = createTimestampedNewMessagePayloadForTest({
chatGuid: "iMessage;-;+15551234567",
});
await dispatchWebhookPayload(payload);
expect(markBlueBubblesChatRead).not.toHaveBeenCalled();
});
it("sends typing indicator when processing message", async () => {
const { sendBlueBubblesTyping } = await import("./chat.js");
vi.mocked(sendBlueBubblesTyping).mockClear();
setupWebhookTarget();
const payload = createTimestampedNewMessagePayloadForTest({
chatGuid: "iMessage;-;+15551234567",
});
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
await params.dispatcherOptions.onReplyStart?.();
return EMPTY_DISPATCH_RESULT;
});
await dispatchWebhookPayload(payload);
// Should call typing start when reply flow triggers it.
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
expect.any(String),
true,
expect.any(Object),
);
});
it("stops typing on idle", async () => {
const { sendBlueBubblesTyping } = await import("./chat.js");
vi.mocked(sendBlueBubblesTyping).mockClear();
setupWebhookTarget();
const payload = createTimestampedNewMessagePayloadForTest({
chatGuid: "iMessage;-;+15551234567",
});
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
await params.dispatcherOptions.onReplyStart?.();
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
params.dispatcherOptions.onIdle?.();
return EMPTY_DISPATCH_RESULT;
});
await dispatchWebhookPayload(payload);
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
expect.any(String),
false,
expect.any(Object),
);
});
it("stops typing when no reply is sent", async () => {
const { sendBlueBubblesTyping } = await import("./chat.js");
vi.mocked(sendBlueBubblesTyping).mockClear();
setupWebhookTarget();
const payload = createTimestampedNewMessagePayloadForTest({
chatGuid: "iMessage;-;+15551234567",
});
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(
async () => EMPTY_DISPATCH_RESULT,
);
await dispatchWebhookPayload(payload);
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
expect.any(String),
false,
expect.any(Object),
);
});
});
describe("outbound message ids", () => {
it("enqueues system event for outbound message id", async () => {
mockEnqueueSystemEvent.mockClear();
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
return EMPTY_DISPATCH_RESULT;
});
setupWebhookTarget();
const payload = createTimestampedNewMessagePayloadForTest({
chatGuid: "iMessage;-;+15551234567",
});
await dispatchWebhookPayload(payload);
// Outbound message ID uses short ID "2" (inbound msg-1 is "1", outbound msg-123 is "2")
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
'Assistant sent "replying now" [message_id:2]',
expect.objectContaining({
sessionKey: "agent:main:main",
}),
);
});
it("falls back to from-me webhook when send response has no message id", async () => {
mockEnqueueSystemEvent.mockClear();
const { sendMessageBlueBubbles } = await import("./send.js");
vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce(blueBubblesTestSendResult("ok"));
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
return EMPTY_DISPATCH_RESULT;
});
setupWebhookTarget();
const inboundPayload = createTimestampedNewMessagePayloadForTest({
chatGuid: "iMessage;-;+15551234567",
});
await dispatchWebhookPayload(inboundPayload);
// Send response did not include a message id, so nothing should be enqueued yet.
expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
const fromMePayload = createTimestampedNewMessagePayloadForTest({
text: "replying now",
handle: { address: "+15557654321" },
isFromMe: true,
guid: "msg-out-456",
chatGuid: "iMessage;-;+15551234567",
});
await dispatchWebhookPayload(fromMePayload);
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
'Assistant sent "replying now" [message_id:2]',
expect.objectContaining({
sessionKey: "agent:main:main",
}),
);
});
it("matches from-me fallback by chatIdentifier when chatGuid is missing", async () => {
mockEnqueueSystemEvent.mockClear();
const { sendMessageBlueBubbles } = await import("./send.js");
vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce(blueBubblesTestSendResult("ok"));
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
return EMPTY_DISPATCH_RESULT;
});
setupWebhookTarget();
const inboundPayload = createTimestampedNewMessagePayloadForTest({
chatGuid: "iMessage;-;+15551234567",
});
await dispatchWebhookPayload(inboundPayload);
expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
const fromMePayload = createTimestampedNewMessagePayloadForTest({
text: "replying now",
handle: { address: "+15557654321" },
isFromMe: true,
guid: "msg-out-789",
chatIdentifier: "+15551234567",
});
await dispatchWebhookPayload(fromMePayload);
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
'Assistant sent "replying now" [message_id:2]',
expect.objectContaining({
sessionKey: "agent:main:main",
}),
);
});
});
describe("reaction events", () => {
it("drops DM reactions when dmPolicy=pairing and allowFrom is empty", async () => {
mockEnqueueSystemEvent.mockClear();
setupWebhookTarget({
account: createMockAccount({ dmPolicy: "pairing", allowFrom: [] }),
});
const payload = createTimestampedMessageReactionPayloadForTest();
await dispatchWebhookPayload(payload);
expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
});
it("skips group reactions when requireMention=true", async () => {
mockEnqueueSystemEvent.mockClear();
mockResolveRequireMention.mockReturnValue(true);
setupWebhookTarget({
account: createMockAccount({
groupPolicy: "open",
}),
});
const payload = createTimestampedMessageReactionPayloadForTest({
isGroup: true,
chatGuid: "iMessage;+;chat123456",
associatedMessageType: 2000,
handle: { address: "+15559999999" },
});
await dispatchWebhookPayload(payload);
expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
});
it("enqueues system event for reaction added", async () => {
mockEnqueueSystemEvent.mockClear();
setupWebhookTarget();
const payload = createTimestampedMessageReactionPayloadForTest({
associatedMessageType: 2000, // Heart reaction added
});
await dispatchWebhookPayload(payload);
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
expect.stringContaining("reacted with ❤️ [[reply_to:"),
expect.any(Object),
);
});
it("enqueues group reactions when requireMention=false", async () => {
mockEnqueueSystemEvent.mockClear();
mockResolveRequireMention.mockReturnValue(false);
setupWebhookTarget({
account: createMockAccount({
groupPolicy: "open",
}),
});
const payload = createTimestampedMessageReactionPayloadForTest({
isGroup: true,
chatGuid: "iMessage;+;chat123456",
associatedMessageType: 2000,
handle: { address: "+15559999999" },
});
await dispatchWebhookPayload(payload);
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
expect.stringContaining("reacted with ❤️ [[reply_to:"),
expect.any(Object),
);
});
it("enqueues system event for reaction removed", async () => {
mockEnqueueSystemEvent.mockClear();
setupWebhookTarget();
const payload = createTimestampedMessageReactionPayloadForTest({
associatedMessageType: 3000, // Heart reaction removed
});
await dispatchWebhookPayload(payload);
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
expect.stringContaining("removed ❤️ reaction [[reply_to:"),
expect.any(Object),
);
});
it("ignores reaction from self (fromMe=true)", async () => {
mockEnqueueSystemEvent.mockClear();
setupWebhookTarget();
const payload = createTimestampedMessageReactionPayloadForTest({
isFromMe: true, // From self
});
await dispatchWebhookPayload(payload);
expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
});
it("drops group reactions that arrive with no chat identifiers", async () => {
// Real-world failure mode: BlueBubbles fires a reaction webhook with
// isGroup=true but omits chatGuid AND chatId AND chatIdentifier. The
// legacy code falls peerId back to the literal string "group" and
// resolves a session key unrelated to any real binding; if isGroup
// had been misclassified as false the same payload would have been
// routed to the sender's DM session instead — surfacing a group
// tapback inside an unrelated 1:1 transcript. Either way the event
// cannot be routed correctly, so drop it.
mockEnqueueSystemEvent.mockClear();
mockResolveRequireMention.mockReturnValue(false);
setupWebhookTarget({
account: createMockAccount({ groupPolicy: "open" }),
});
const payload = createTimestampedMessageReactionPayloadForTest({
isGroup: true,
// chatGuid / chatId / chatIdentifier intentionally omitted
associatedMessageType: 2000,
handle: { address: "+15559999999" },
});
await dispatchWebhookPayload(payload);
expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
});
it("still enqueues group reactions when at least one chat identifier is present", async () => {
// Sanity check: the drop guard must not fire when the webhook does
// include a chatGuid.
mockEnqueueSystemEvent.mockClear();
mockResolveRequireMention.mockReturnValue(false);
setupWebhookTarget({
account: createMockAccount({ groupPolicy: "open" }),
});
const payload = createTimestampedMessageReactionPayloadForTest({
isGroup: true,
chatGuid: "iMessage;+;chat-known-123",
associatedMessageType: 2000,
handle: { address: "+15559999999" },
});
await dispatchWebhookPayload(payload);
expect(mockEnqueueSystemEvent).toHaveBeenCalled();
});
it("maps reaction types to correct emojis", async () => {
mockEnqueueSystemEvent.mockClear();
setupWebhookTarget();
// Test thumbs up reaction (2001)
const payload = createTimestampedMessageReactionPayloadForTest({
associatedMessageGuid: "msg-123",
associatedMessageType: 2001, // Thumbs up
});
await dispatchWebhookPayload(payload);
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
expect.stringContaining("👍"),
expect.any(Object),
);
});
});
describe("short message ID mapping", () => {
it("assigns sequential short IDs to messages", async () => {
setupWebhookTarget();
const payload = createTimestampedNewMessagePayloadForTest({
guid: "p:1/msg-uuid-12345",
chatGuid: "iMessage;-;+15551234567",
});
await dispatchWebhookPayload(payload);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = getFirstDispatchCall();
// MessageSid should be short ID "1" instead of full UUID
expect(callArgs.ctx.MessageSid).toBe("1");
expect(callArgs.ctx.MessageSidFull).toBe("p:1/msg-uuid-12345");
});
it("resolves short ID back to UUID", async () => {
setupWebhookTarget();
const payload = createTimestampedNewMessagePayloadForTest({
guid: "p:1/msg-uuid-12345",
chatGuid: "iMessage;-;+15551234567",
});
await dispatchWebhookPayload(payload);
// The short ID "1" should resolve back to the full UUID
expect(resolveBlueBubblesMessageId("1")).toBe("p:1/msg-uuid-12345");
});
it("returns UUID unchanged when not in cache", () => {
expect(resolveBlueBubblesMessageId("msg-not-cached")).toBe("msg-not-cached");
});
it("returns short ID unchanged when numeric but not in cache", () => {
expect(resolveBlueBubblesMessageId("999")).toBe("999");
});
it("throws when numeric short ID is missing and requireKnownShortId is set", () => {
expect(() => resolveBlueBubblesMessageId("999", { requireKnownShortId: true })).toThrow(
/short message id/i,
);
});
});
describe("history backfill", () => {
it("scopes in-memory history by account to avoid cross-account leakage", async () => {
mockFetchBlueBubblesHistory.mockImplementation(async (_chatIdentifier, _limit, opts) => {
if (opts?.accountId === "acc-a") {
return {
resolved: true,
entries: [
{ sender: "A", body: "a-history", messageId: "a-history-1", timestamp: 1000 },
],
};
}
if (opts?.accountId === "acc-b") {
return {
resolved: true,
entries: [
{ sender: "B", body: "b-history", messageId: "b-history-1", timestamp: 1000 },
],
};
}
return { resolved: true, entries: [] };
});
const accountA: ResolvedBlueBubblesAccount = {
...createMockAccount({ dmHistoryLimit: 3, password: "password-a" }), // pragma: allowlist secret
accountId: "acc-a",
};
const accountB: ResolvedBlueBubblesAccount = {
...createMockAccount({ dmHistoryLimit: 3, password: "password-b" }), // pragma: allowlist secret
accountId: "acc-b",
};
const core = createMockRuntime();
trackWebhookRegistrationForTest(
setupWebhookTargetsForTest({
createCore: createMockRuntime,
core,
accounts: [{ account: accountA }, { account: accountB }],
}),
(nextUnregister) => {
unregister = nextUnregister;
},
);
await dispatchWebhookPayload(
createTimestampedNewMessagePayloadForTest({
text: "message for account a",
guid: "a-msg-1",
chatGuid: "iMessage;-;+15551234567",
}),
"/bluebubbles-webhook?password=password-a",
);
await dispatchWebhookPayload(
createTimestampedNewMessagePayloadForTest({
text: "message for account b",
guid: "b-msg-1",
chatGuid: "iMessage;-;+15551234567",
}),
"/bluebubbles-webhook?password=password-b",
);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(2);
const firstCall = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
const secondCall = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[1]?.[0];
const firstHistory = (firstCall?.ctx.InboundHistory ?? []) as Array<{ body: string }>;
const secondHistory = (secondCall?.ctx.InboundHistory ?? []) as Array<{ body: string }>;
expect(firstHistory.map((entry) => entry.body)).toContain("a-history");
expect(secondHistory.map((entry) => entry.body)).toContain("b-history");
expect(secondHistory.map((entry) => entry.body)).not.toContain("a-history");
});
it("dedupes and caps merged history to dmHistoryLimit", async () => {
mockFetchBlueBubblesHistory.mockResolvedValueOnce({
resolved: true,
entries: [
{ sender: "Friend", body: "older context", messageId: "hist-1", timestamp: 1000 },
{ sender: "Friend", body: "current text", messageId: "msg-1", timestamp: 2000 },
],
});
setupWebhookTarget({
account: createMockAccount({ dmHistoryLimit: 2 }),
});
await dispatchWebhookPayload(
createTimestampedNewMessagePayloadForTest({
text: "current text",
chatGuid: "iMessage;-;+15550002002",
}),
);
const callArgs = getFirstDispatchCall();
const inboundHistory = (callArgs.ctx.InboundHistory ?? []) as Array<{ body: string }>;
expect(inboundHistory).toHaveLength(2);
expect(inboundHistory.map((entry) => entry.body)).toEqual(["older context", "current text"]);
expect(inboundHistory.filter((entry) => entry.body === "current text")).toHaveLength(1);
});
it("uses exponential backoff for unresolved backfill and stops after resolve", async () => {
mockFetchBlueBubblesHistory
.mockResolvedValueOnce({ resolved: false, entries: [] })
.mockResolvedValueOnce({
resolved: true,
entries: [
{ sender: "Friend", body: "older context", messageId: "hist-1", timestamp: 1000 },
],
});
setupWebhookTarget({
account: createMockAccount({ dmHistoryLimit: 4 }),
});
const mkPayload = (guid: string, text: string, now: number) =>
createNewMessagePayloadForTest({
text,
guid,
chatGuid: "iMessage;-;+15550003003",
date: now,
});
let now = 1_700_000_000_000;
const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now);
try {
await dispatchWebhookPayload(mkPayload("msg-1", "first text", now));
expect(mockFetchBlueBubblesHistory).toHaveBeenCalledTimes(1);
now += 1_000;
await dispatchWebhookPayload(mkPayload("msg-2", "second text", now));
expect(mockFetchBlueBubblesHistory).toHaveBeenCalledTimes(1);
now += 6_000;
await dispatchWebhookPayload(mkPayload("msg-3", "third text", now));
expect(mockFetchBlueBubblesHistory).toHaveBeenCalledTimes(2);
const thirdCall = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[2]?.[0];
const thirdHistory = (thirdCall?.ctx.InboundHistory ?? []) as Array<{ body: string }>;
expect(thirdHistory.map((entry) => entry.body)).toContain("older context");
expect(thirdHistory.map((entry) => entry.body)).toContain("third text");
now += 10_000;
await dispatchWebhookPayload(mkPayload("msg-4", "fourth text", now));
expect(mockFetchBlueBubblesHistory).toHaveBeenCalledTimes(2);
} finally {
nowSpy.mockRestore();
}
});
it("caps inbound history payload size to reduce prompt-bomb risk", async () => {
const huge = "x".repeat(8_000);
mockFetchBlueBubblesHistory.mockResolvedValueOnce({
resolved: true,
entries: Array.from({ length: 20 }, (_, idx) => ({
sender: `Friend ${idx}`,
body: `${huge} ${idx}`,
messageId: `hist-${idx}`,
timestamp: idx + 1,
})),
});
setupWebhookTarget({
account: createMockAccount({ dmHistoryLimit: 20 }),
});
await dispatchWebhookPayload(
createTimestampedNewMessagePayloadForTest({
text: "latest text",
guid: "msg-bomb-1",
chatGuid: "iMessage;-;+15550004004",
}),
);
const callArgs = getFirstDispatchCall();
const inboundHistory = (callArgs.ctx.InboundHistory ?? []) as Array<{ body: string }>;
const totalChars = inboundHistory.reduce((sum, entry) => sum + entry.body.length, 0);
expect(inboundHistory.length).toBeLessThan(20);
expect(totalChars).toBeLessThanOrEqual(12_000);
expect(inboundHistory.every((entry) => entry.body.length <= 1_203)).toBe(true);
});
});
describe("fromMe messages", () => {
it("ignores messages from self (fromMe=true)", async () => {
setupWebhookTarget();
const payload = createTimestampedNewMessagePayloadForTest({
text: "my own message",
isFromMe: true,
});
await dispatchWebhookPayload(payload);
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it("drops reflected self-chat duplicates after a confirmed assistant outbound", async () => {
setupWebhookTarget();
const { sendMessageBlueBubbles } = await import("./send.js");
vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce(
blueBubblesTestSendResult("msg-self-1"),
);
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
return EMPTY_DISPATCH_RESULT;
});
const timestamp = Date.now();
const inboundPayload = createNewMessagePayloadForTest({
guid: "msg-self-0",
chatGuid: "iMessage;-;+15551234567",
date: timestamp,
});
await dispatchWebhookPayload(inboundPayload);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
const fromMePayload = createNewMessagePayloadForTest({
text: "replying now",
isFromMe: true,
guid: "msg-self-1",
chatGuid: "iMessage;-;+15551234567",
date: timestamp,
});
await dispatchWebhookPayload(fromMePayload);
const reflectedPayload = createNewMessagePayloadForTest({
text: "replying now",
guid: "msg-self-2",
chatGuid: "iMessage;-;+15551234567",
date: timestamp,
});
await dispatchWebhookPayload(reflectedPayload);
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it("does not drop inbound messages when no fromMe self-chat copy was seen", async () => {
setupWebhookTarget();
const inboundPayload = createTimestampedNewMessagePayloadForTest({
text: "genuinely new message",
guid: "msg-inbound-1",
chatGuid: "iMessage;-;+15551234567",
});
await dispatchWebhookPayload(inboundPayload);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
it("does not drop reflected copies after the self-chat cache TTL expires", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
setupWebhookTarget();
const timestamp = Date.now();
const fromMePayload = createNewMessagePayloadForTest({
text: "ttl me",
isFromMe: true,
guid: "msg-self-ttl-1",
chatGuid: "iMessage;-;+15551234567",
date: timestamp,
});
await dispatchWebhookPayloadDirect(fromMePayload);
await vi.runAllTimersAsync();
mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
vi.advanceTimersByTime(10_001);
const reflectedPayload = createNewMessagePayloadForTest({
text: "ttl me",
guid: "msg-self-ttl-2",
chatGuid: "iMessage;-;+15551234567",
date: timestamp,
});
await dispatchWebhookPayloadDirect(reflectedPayload);
await vi.runAllTimersAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
it("does not cache regular fromMe DMs as self-chat reflections", async () => {
setupWebhookTarget();
const timestamp = Date.now();
const fromMePayload = createNewMessagePayloadForTest({
text: "shared text",
handle: { address: "+15557654321" },
isFromMe: true,
guid: "msg-normal-fromme",
chatGuid: "iMessage;-;+15551234567",
date: timestamp,
});
await dispatchWebhookPayload(fromMePayload);
mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
const inboundPayload = createNewMessagePayloadForTest({
text: "shared text",
guid: "msg-normal-inbound",
chatGuid: "iMessage;-;+15551234567",
date: timestamp,
});
await dispatchWebhookPayload(inboundPayload);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
it("does not drop user-authored self-chat prompts without a confirmed assistant outbound", async () => {
setupWebhookTarget();
const timestamp = Date.now();
const fromMePayload = createNewMessagePayloadForTest({
text: "user-authored self prompt",
isFromMe: true,
guid: "msg-self-user-1",
chatGuid: "iMessage;-;+15551234567",
date: timestamp,
});
await dispatchWebhookPayload(fromMePayload);
mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
const reflectedPayload = createNewMessagePayloadForTest({
text: "user-authored self prompt",
guid: "msg-self-user-2",
chatGuid: "iMessage;-;+15551234567",
date: timestamp,
});
await dispatchWebhookPayload(reflectedPayload);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
it("does not treat a pending text-only match as confirmed assistant outbound", async () => {
setupWebhookTarget();
const { sendMessageBlueBubbles } = await import("./send.js");
vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce(blueBubblesTestSendResult("ok"));
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
await params.dispatcherOptions.deliver({ text: "same text" }, { kind: "final" });
return EMPTY_DISPATCH_RESULT;
});
const timestamp = Date.now();
const inboundPayload = createNewMessagePayloadForTest({
guid: "msg-self-race-0",
chatGuid: "iMessage;-;+15551234567",
date: timestamp,
});
await dispatchWebhookPayload(inboundPayload);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
const fromMePayload = createNewMessagePayloadForTest({
text: "same text",
isFromMe: true,
guid: "msg-self-race-1",
chatGuid: "iMessage;-;+15551234567",
date: timestamp,
});
await dispatchWebhookPayload(fromMePayload);
const reflectedPayload = createNewMessagePayloadForTest({
text: "same text",
guid: "msg-self-race-2",
chatGuid: "iMessage;-;+15551234567",
date: timestamp,
});
await dispatchWebhookPayload(reflectedPayload);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
it("does not treat chatGuid-inferred sender ids as self-chat evidence", async () => {
setupWebhookTarget();
const timestamp = Date.now();
const fromMePayload = createNewMessagePayloadForTest({
text: "shared inferred text",
handle: null,
isFromMe: true,
guid: "msg-inferred-fromme",
chatGuid: "iMessage;-;+15551234567",
date: timestamp,
});
await dispatchWebhookPayload(fromMePayload);
mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
const inboundPayload = createNewMessagePayloadForTest({
text: "shared inferred text",
guid: "msg-inferred-inbound",
chatGuid: "iMessage;-;+15551234567",
date: timestamp,
});
await dispatchWebhookPayload(inboundPayload);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
});
});