@gguf/claw
Version:
WhatsApp gateway CLI (Baileys web) with Pi RPC agent
1,504 lines (1,306 loc) • 75.7 kB
text/typescript
import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
import { EventEmitter } from "node:events";
import { removeAckReactionAfterReply, shouldAckReaction } from "openclaw/plugin-sdk";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
import {
handleBlueBubblesWebhookRequest,
registerBlueBubblesWebhookTarget,
resolveBlueBubblesMessageId,
_resetBlueBubblesShortIdState,
} from "./monitor.js";
import { setBlueBubblesRuntime } from "./runtime.js";
// Mock dependencies
vi.mock("./send.js", () => ({
resolveChatGuidForTarget: vi.fn().mockResolvedValue("iMessage;-;+15551234567"),
sendMessageBlueBubbles: vi.fn().mockResolvedValue({ messageId: "msg-123" }),
}));
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),
};
});
// 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 mockResolveAgentRoute = vi.fn(() => ({
agentId: "main",
accountId: "default",
sessionKey: "agent:main:bluebubbles:dm:+15551234567",
}));
const mockBuildMentionRegexes = vi.fn(() => [/\bbert\b/i]);
const mockMatchesMentionPatterns = vi.fn((text: string, regexes: RegExp[]) =>
regexes.some((r) => r.test(text)),
);
const mockResolveRequireMention = vi.fn(() => false);
const mockResolveGroupPolicy = vi.fn(() => "open");
const mockDispatchReplyWithBufferedBlockDispatcher = vi.fn(async () => undefined);
const mockHasControlCommand = vi.fn(() => false);
const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false);
const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
path: "/tmp/test-media.jpg",
contentType: "image/jpeg",
});
const mockResolveStorePath = vi.fn(() => "/tmp/sessions.json");
const mockReadSessionUpdatedAt = vi.fn(() => undefined);
const mockResolveEnvelopeFormatOptions = vi.fn(() => ({
template: "channel+name+time",
}));
const mockFormatAgentEnvelope = vi.fn((opts: { body: string }) => opts.body);
const mockChunkMarkdownText = vi.fn((text: string) => [text]);
function createMockRuntime(): PluginRuntime {
return {
version: "1.0.0",
config: {
loadConfig: vi.fn(() => ({})) as unknown as PluginRuntime["config"]["loadConfig"],
writeConfigFile: vi.fn() as unknown as PluginRuntime["config"]["writeConfigFile"],
},
system: {
enqueueSystemEvent:
mockEnqueueSystemEvent as unknown as PluginRuntime["system"]["enqueueSystemEvent"],
runCommandWithTimeout: vi.fn() as unknown as PluginRuntime["system"]["runCommandWithTimeout"],
},
media: {
loadWebMedia: vi.fn() as unknown as PluginRuntime["media"]["loadWebMedia"],
detectMime: vi.fn() as unknown as PluginRuntime["media"]["detectMime"],
mediaKindFromMime: vi.fn() as unknown as PluginRuntime["media"]["mediaKindFromMime"],
isVoiceCompatibleAudio:
vi.fn() as unknown as PluginRuntime["media"]["isVoiceCompatibleAudio"],
getImageMetadata: vi.fn() as unknown as PluginRuntime["media"]["getImageMetadata"],
resizeToJpeg: vi.fn() as unknown as PluginRuntime["media"]["resizeToJpeg"],
},
tools: {
createMemoryGetTool: vi.fn() as unknown as PluginRuntime["tools"]["createMemoryGetTool"],
createMemorySearchTool:
vi.fn() as unknown as PluginRuntime["tools"]["createMemorySearchTool"],
registerMemoryCli: vi.fn() as unknown as PluginRuntime["tools"]["registerMemoryCli"],
},
channel: {
text: {
chunkMarkdownText:
mockChunkMarkdownText as unknown as PluginRuntime["channel"]["text"]["chunkMarkdownText"],
chunkText: vi.fn() as unknown as PluginRuntime["channel"]["text"]["chunkText"],
resolveTextChunkLimit: vi.fn(
() => 4000,
) as unknown as PluginRuntime["channel"]["text"]["resolveTextChunkLimit"],
hasControlCommand:
mockHasControlCommand as unknown as PluginRuntime["channel"]["text"]["hasControlCommand"],
resolveMarkdownTableMode: vi.fn(
() => "code",
) as unknown as PluginRuntime["channel"]["text"]["resolveMarkdownTableMode"],
convertMarkdownTables: vi.fn(
(text: string) => text,
) as unknown as PluginRuntime["channel"]["text"]["convertMarkdownTables"],
},
reply: {
dispatchReplyWithBufferedBlockDispatcher:
mockDispatchReplyWithBufferedBlockDispatcher as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
createReplyDispatcherWithTyping:
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["createReplyDispatcherWithTyping"],
resolveEffectiveMessagesConfig:
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveEffectiveMessagesConfig"],
resolveHumanDelayConfig:
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveHumanDelayConfig"],
dispatchReplyFromConfig:
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"],
finalizeInboundContext:
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
formatAgentEnvelope:
mockFormatAgentEnvelope as unknown as PluginRuntime["channel"]["reply"]["formatAgentEnvelope"],
formatInboundEnvelope:
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["formatInboundEnvelope"],
resolveEnvelopeFormatOptions:
mockResolveEnvelopeFormatOptions as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
},
routing: {
resolveAgentRoute:
mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
},
pairing: {
buildPairingReply:
mockBuildPairingReply as unknown as PluginRuntime["channel"]["pairing"]["buildPairingReply"],
readAllowFromStore:
mockReadAllowFromStore as unknown as PluginRuntime["channel"]["pairing"]["readAllowFromStore"],
upsertPairingRequest:
mockUpsertPairingRequest as unknown as PluginRuntime["channel"]["pairing"]["upsertPairingRequest"],
},
media: {
fetchRemoteMedia:
vi.fn() as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"],
saveMediaBuffer:
mockSaveMediaBuffer as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
},
session: {
resolveStorePath:
mockResolveStorePath as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"],
readSessionUpdatedAt:
mockReadSessionUpdatedAt as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"],
recordInboundSession:
vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"],
recordSessionMetaFromInbound:
vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordSessionMetaFromInbound"],
updateLastRoute:
vi.fn() as unknown as PluginRuntime["channel"]["session"]["updateLastRoute"],
},
mentions: {
buildMentionRegexes:
mockBuildMentionRegexes as unknown as PluginRuntime["channel"]["mentions"]["buildMentionRegexes"],
matchesMentionPatterns:
mockMatchesMentionPatterns as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"],
},
reactions: {
shouldAckReaction,
removeAckReactionAfterReply,
},
groups: {
resolveGroupPolicy:
mockResolveGroupPolicy as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"],
resolveRequireMention:
mockResolveRequireMention as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"],
},
debounce: {
// Create a pass-through debouncer that immediately calls onFlush
createInboundDebouncer: vi.fn(
(params: { onFlush: (items: unknown[]) => Promise<void> }) => ({
enqueue: async (item: unknown) => {
await params.onFlush([item]);
},
flushKey: vi.fn(),
}),
) as unknown as PluginRuntime["channel"]["debounce"]["createInboundDebouncer"],
resolveInboundDebounceMs: vi.fn(
() => 0,
) as unknown as PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"],
},
commands: {
resolveCommandAuthorizedFromAuthorizers:
mockResolveCommandAuthorizedFromAuthorizers as unknown as PluginRuntime["channel"]["commands"]["resolveCommandAuthorizedFromAuthorizers"],
isControlCommandMessage:
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["isControlCommandMessage"],
shouldComputeCommandAuthorized:
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldComputeCommandAuthorized"],
shouldHandleTextCommands:
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldHandleTextCommands"],
},
discord: {} as PluginRuntime["channel"]["discord"],
slack: {} as PluginRuntime["channel"]["slack"],
telegram: {} as PluginRuntime["channel"]["telegram"],
signal: {} as PluginRuntime["channel"]["signal"],
imessage: {} as PluginRuntime["channel"]["imessage"],
whatsapp: {} as PluginRuntime["channel"]["whatsapp"],
},
logging: {
shouldLogVerbose: vi.fn(
() => false,
) as unknown as PluginRuntime["logging"]["shouldLogVerbose"],
getChildLogger: vi.fn(() => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
})) as unknown as PluginRuntime["logging"]["getChildLogger"],
},
state: {
resolveStateDir: vi.fn(
() => "/tmp/openclaw",
) as unknown as PluginRuntime["state"]["resolveStateDir"],
},
};
}
function createMockAccount(
overrides: Partial<ResolvedBlueBubblesAccount["config"]> = {},
): ResolvedBlueBubblesAccount {
return {
accountId: "default",
enabled: true,
configured: true,
config: {
serverUrl: "http://localhost:1234",
password: "test-password",
dmPolicy: "open",
groupPolicy: "open",
allowFrom: [],
groupAllowFrom: [],
...overrides,
},
};
}
function createMockRequest(
method: string,
url: string,
body: unknown,
headers: Record<string, string> = {},
): IncomingMessage {
const req = new EventEmitter() as IncomingMessage;
req.method = method;
req.url = url;
req.headers = headers;
(req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress: "127.0.0.1" };
// Emit body data after a microtask
// oxlint-disable-next-line no-floating-promises
Promise.resolve().then(() => {
const bodyStr = typeof body === "string" ? body : JSON.stringify(body);
req.emit("data", Buffer.from(bodyStr));
req.emit("end");
});
return req;
}
function createMockResponse(): ServerResponse & { body: string; statusCode: number } {
const res = {
statusCode: 200,
body: "",
setHeader: vi.fn(),
end: vi.fn((data?: string) => {
res.body = data ?? "";
}),
} as unknown as ServerResponse & { body: string; statusCode: number };
return res;
}
const flushAsync = async () => {
for (let i = 0; i < 2; i += 1) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
};
describe("BlueBubbles webhook monitor", () => {
let unregister: () => void;
beforeEach(() => {
vi.clearAllMocks();
// Reset short ID state between tests for predictable behavior
_resetBlueBubblesShortIdState();
mockReadAllowFromStore.mockResolvedValue([]);
mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: true });
mockResolveRequireMention.mockReturnValue(false);
mockHasControlCommand.mockReturnValue(false);
mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(false);
mockBuildMentionRegexes.mockReturnValue([/\bbert\b/i]);
setBlueBubblesRuntime(createMockRuntime());
});
afterEach(() => {
unregister?.();
});
describe("webhook parsing + auth handling", () => {
it("rejects non-POST requests", async () => {
const account = createMockAccount();
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const req = createMockRequest("GET", "/bluebubbles-webhook", {});
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(405);
});
it("accepts POST requests with valid JSON payload", async () => {
const account = createMockAccount();
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(res.body).toBe("ok");
});
it("rejects requests with invalid JSON", async () => {
const account = createMockAccount();
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const req = createMockRequest("POST", "/bluebubbles-webhook", "invalid json {{");
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(400);
});
it("authenticates via password query parameter", async () => {
const account = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
// Mock non-localhost request
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
});
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "192.168.1.100",
};
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
});
it("authenticates via x-password header", async () => {
const account = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
const req = createMockRequest(
"POST",
"/bluebubbles-webhook",
{
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
},
{ "x-password": "secret-token" },
);
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "192.168.1.100",
};
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
});
it("rejects unauthorized requests with wrong password", async () => {
const account = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
const req = createMockRequest("POST", "/bluebubbles-webhook?password=wrong-token", {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
});
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "192.168.1.100",
};
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
});
it("allows localhost requests without authentication", async () => {
const account = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
const req = createMockRequest("POST", "/bluebubbles-webhook", {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
});
// Localhost address
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "127.0.0.1",
};
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
});
it("ignores unregistered webhook paths", async () => {
const req = createMockRequest("POST", "/unregistered-path", {});
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(false);
});
it("parses chatId when provided as a string (webhook variant)", async () => {
const { resolveChatGuidForTarget } = await import("./send.js");
vi.mocked(resolveChatGuidForTarget).mockClear();
const account = createMockAccount({ groupPolicy: "open" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello from group",
handle: { address: "+15551234567" },
isGroup: true,
isFromMe: false,
guid: "msg-1",
chatId: "123",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
expect.objectContaining({
target: { kind: "chat_id", chatId: 123 },
}),
);
});
it("extracts chatGuid from nested chat object fields (webhook variant)", async () => {
const { sendMessageBlueBubbles, resolveChatGuidForTarget } = await import("./send.js");
vi.mocked(sendMessageBlueBubbles).mockClear();
vi.mocked(resolveChatGuidForTarget).mockClear();
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
});
const account = createMockAccount({ groupPolicy: "open" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello from group",
handle: { address: "+15551234567" },
isGroup: true,
isFromMe: false,
guid: "msg-1",
chat: { chatGuid: "iMessage;+;chat123456" },
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(resolveChatGuidForTarget).not.toHaveBeenCalled();
expect(sendMessageBlueBubbles).toHaveBeenCalledWith(
"chat_guid:iMessage;+;chat123456",
expect.any(String),
expect.any(Object),
);
});
});
describe("DM pairing behavior vs allowFrom", () => {
it("allows DM from sender in allowFrom list", async () => {
const account = createMockAccount({
dmPolicy: "allowlist",
allowFrom: ["+15551234567"],
});
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello from allowed sender",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
// Wait for async processing
await flushAsync();
expect(res.statusCode).toBe(200);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
it("blocks DM from sender not in allowFrom when dmPolicy=allowlist", async () => {
const account = createMockAccount({
dmPolicy: "allowlist",
allowFrom: ["+15559999999"], // Different number
});
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello from blocked sender",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(res.statusCode).toBe(200);
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it("triggers pairing flow for unknown sender when dmPolicy=pairing", async () => {
// Note: empty allowFrom = allow all. To trigger pairing, we need a non-empty
// allowlist that doesn't include the sender
const account = createMockAccount({
dmPolicy: "pairing",
allowFrom: ["+15559999999"], // Different number than sender
});
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(mockUpsertPairingRequest).toHaveBeenCalled();
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it("does not resend pairing reply when request already exists", async () => {
mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: false });
// Note: empty allowFrom = allow all. To trigger pairing, we need a non-empty
// allowlist that doesn't include the sender
const account = createMockAccount({
dmPolicy: "pairing",
allowFrom: ["+15559999999"], // Different number than sender
});
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello again",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-2",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(mockUpsertPairingRequest).toHaveBeenCalled();
// Should not send pairing reply since created=false
const { sendMessageBlueBubbles } = await import("./send.js");
expect(sendMessageBlueBubbles).not.toHaveBeenCalled();
});
it("allows all DMs when dmPolicy=open", async () => {
const account = createMockAccount({
dmPolicy: "open",
allowFrom: [],
});
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello from anyone",
handle: { address: "+15559999999" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
it("blocks all DMs when dmPolicy=disabled", async () => {
const account = createMockAccount({
dmPolicy: "disabled",
});
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
});
describe("group message gating", () => {
it("allows group messages when groupPolicy=open and no allowlist", async () => {
const account = createMockAccount({
groupPolicy: "open",
});
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello from group",
handle: { address: "+15551234567" },
isGroup: true,
isFromMe: false,
guid: "msg-1",
chatGuid: "iMessage;+;chat123456",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
it("blocks group messages when groupPolicy=disabled", async () => {
const account = createMockAccount({
groupPolicy: "disabled",
});
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello from group",
handle: { address: "+15551234567" },
isGroup: true,
isFromMe: false,
guid: "msg-1",
chatGuid: "iMessage;+;chat123456",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it("treats chat_guid groups as group even when isGroup=false", async () => {
const account = createMockAccount({
groupPolicy: "allowlist",
dmPolicy: "open",
});
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello from group",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
chatGuid: "iMessage;+;chat123456",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it("allows group messages from allowed chat_guid in groupAllowFrom", async () => {
const account = createMockAccount({
groupPolicy: "allowlist",
groupAllowFrom: ["chat_guid:iMessage;+;chat123456"],
});
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello from allowed group",
handle: { address: "+15551234567" },
isGroup: true,
isFromMe: false,
guid: "msg-1",
chatGuid: "iMessage;+;chat123456",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
});
describe("mention gating (group messages)", () => {
it("processes group message when mentioned and requireMention=true", async () => {
mockResolveRequireMention.mockReturnValue(true);
mockMatchesMentionPatterns.mockReturnValue(true);
const account = createMockAccount({ groupPolicy: "open" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "bert, can you help me?",
handle: { address: "+15551234567" },
isGroup: true,
isFromMe: false,
guid: "msg-1",
chatGuid: "iMessage;+;chat123456",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
expect(callArgs.ctx.WasMentioned).toBe(true);
});
it("skips group message when not mentioned and requireMention=true", async () => {
mockResolveRequireMention.mockReturnValue(true);
mockMatchesMentionPatterns.mockReturnValue(false);
const account = createMockAccount({ groupPolicy: "open" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello everyone",
handle: { address: "+15551234567" },
isGroup: true,
isFromMe: false,
guid: "msg-1",
chatGuid: "iMessage;+;chat123456",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it("processes group message without mention when requireMention=false", async () => {
mockResolveRequireMention.mockReturnValue(false);
const account = createMockAccount({ groupPolicy: "open" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello everyone",
handle: { address: "+15551234567" },
isGroup: true,
isFromMe: false,
guid: "msg-1",
chatGuid: "iMessage;+;chat123456",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
});
describe("group metadata", () => {
it("includes group subject + members in ctx", async () => {
const account = createMockAccount({ groupPolicy: "open" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello group",
handle: { address: "+15551234567" },
isGroup: true,
isFromMe: false,
guid: "msg-1",
chatGuid: "iMessage;+;chat123456",
chatName: "Family",
participants: [
{ address: "+15551234567", displayName: "Alice" },
{ address: "+15557654321", displayName: "Bob" },
],
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
expect(callArgs.ctx.GroupSubject).toBe("Family");
expect(callArgs.ctx.GroupMembers).toBe("Alice (+15551234567), Bob (+15557654321)");
});
});
describe("inbound debouncing", () => {
it("coalesces text-only then attachment webhook events by messageId", async () => {
vi.useFakeTimers();
try {
const account = createMockAccount({ dmPolicy: "open" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
// Use a timing-aware debouncer test double that respects debounceMs/buildKey/shouldDebounce.
// oxlint-disable-next-line typescript/no-explicit-any
core.channel.debounce.createInboundDebouncer = vi.fn((params: any) => {
// oxlint-disable-next-line typescript/no-explicit-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"];
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const messageId = "race-msg-1";
const chatGuid = "iMessage;-;+15551234567";
const payloadA = {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: messageId,
chatGuid,
date: Date.now(),
},
};
const payloadB = {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: messageId,
chatGuid,
attachments: [
{
guid: "att-1",
mimeType: "image/jpeg",
totalBytes: 1024,
},
],
date: Date.now(),
},
};
await handleBlueBubblesWebhookRequest(
createMockRequest("POST", "/bluebubbles-webhook", payloadA),
createMockResponse(),
);
// Simulate the real-world delay where the attachment-bearing webhook arrives shortly after.
await vi.advanceTimersByTimeAsync(300);
await handleBlueBubblesWebhookRequest(
createMockRequest("POST", "/bluebubbles-webhook", payloadB),
createMockResponse(),
);
// 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 = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
expect(callArgs.ctx.MediaPaths).toEqual(["/tmp/test-media.jpg"]);
expect(callArgs.ctx.Body).toContain("hello");
} finally {
vi.useRealTimers();
}
});
});
describe("reply metadata", () => {
it("surfaces reply fields in ctx when provided", async () => {
const account = createMockAccount({ dmPolicy: "open" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "replying now",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
chatGuid: "iMessage;-;+15551234567",
replyTo: {
guid: "msg-0",
text: "original message",
handle: { address: "+15550000000", displayName: "Alice" },
},
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
// 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("preserves part index prefixes in reply tags when short IDs are unavailable", async () => {
const account = createMockAccount({ dmPolicy: "open" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "replying now",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
chatGuid: "iMessage;-;+15551234567",
replyTo: {
guid: "p:1/msg-0",
text: "original message",
handle: { address: "+15550000000", displayName: "Alice" },
},
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
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 () => {
const account = createMockAccount({ dmPolicy: "open", groupPolicy: "open" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const chatGuid = "iMessage;+;chat-reply-cache";
const originalPayload = {
type: "new-message",
data: {
text: "original message (cached)",
handle: { address: "+15550000000" },
isGroup: true,
isFromMe: false,
guid: "cache-msg-0",
chatGuid,
date: Date.now(),
},
};
const originalReq = createMockRequest("POST", "/bluebubbles-webhook", originalPayload);
const originalRes = createMockResponse();
await handleBlueBubblesWebhookRequest(originalReq, originalRes);
await flushAsync();
// Only assert the reply message behavior below.
mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
const replyPayload = {
type: "new-message",
data: {
text: "replying now",
handle: { address: "+15551234567" },
isGroup: true,
isFromMe: false,
guid: "cache-msg-1",
chatGuid,
// Only the GUID is provided; sender/body must be hydrated.
replyToMessageGuid: "cache-msg-0",
date: Date.now(),
},
};
const replyReq = createMockRequest("POST", "/bluebubbles-webhook", replyPayload);
const replyRes = createMockResponse();
await handleBlueBubblesWebhookRequest(replyReq, replyRes);
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
// ReplyToId uses short ID "1" (first cac