refactor(extensions): reuse shared helper primitives

This commit is contained in:
Peter Steinberger
2026-03-07 10:40:57 +00:00
parent 3c71e2bd48
commit 1aa77e4603
58 changed files with 1567 additions and 2195 deletions

View File

@@ -6,6 +6,7 @@ import type {
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
buildComputedAccountStatusSnapshot,
buildProbeChannelStatusSummary,
collectBlueBubblesStatusIssues,
DEFAULT_ACCOUNT_ID,
@@ -25,6 +26,7 @@ import {
resolveDefaultBlueBubblesAccountId,
} from "./accounts.js";
import { bluebubblesMessageActions } from "./actions.js";
import { applyBlueBubblesConnectionConfig } from "./config-apply.js";
import { BlueBubblesConfigSchema } from "./config-schema.js";
import { sendBlueBubblesMedia } from "./media-send.js";
import { resolveBlueBubblesMessageId } from "./monitor.js";
@@ -255,40 +257,27 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
})
: namedConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
channels: {
...next.channels,
bluebubbles: {
...next.channels?.bluebubbles,
enabled: true,
...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
...(input.password ? { password: input.password } : {}),
...(input.webhookPath ? { webhookPath: input.webhookPath } : {}),
},
return applyBlueBubblesConnectionConfig({
cfg: next,
accountId,
patch: {
serverUrl: input.httpUrl,
password: input.password,
webhookPath: input.webhookPath,
},
} as OpenClawConfig;
onlyDefinedFields: true,
});
}
return {
...next,
channels: {
...next.channels,
bluebubbles: {
...next.channels?.bluebubbles,
enabled: true,
accounts: {
...next.channels?.bluebubbles?.accounts,
[accountId]: {
...next.channels?.bluebubbles?.accounts?.[accountId],
enabled: true,
...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
...(input.password ? { password: input.password } : {}),
...(input.webhookPath ? { webhookPath: input.webhookPath } : {}),
},
},
},
return applyBlueBubblesConnectionConfig({
cfg: next,
accountId,
patch: {
serverUrl: input.httpUrl,
password: input.password,
webhookPath: input.webhookPath,
},
} as OpenClawConfig;
onlyDefinedFields: true,
});
},
},
pairing: {
@@ -372,20 +361,18 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
buildAccountSnapshot: ({ account, runtime, probe }) => {
const running = runtime?.running ?? false;
const probeOk = (probe as BlueBubblesProbe | undefined)?.ok;
return {
const base = buildComputedAccountStatusSnapshot({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
baseUrl: account.baseUrl,
running,
connected: probeOk ?? running,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
runtime,
probe,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
});
return {
...base,
baseUrl: account.baseUrl,
connected: probeOk ?? running,
};
},
},

View File

@@ -30,6 +30,39 @@ function resolvePartIndex(partIndex: number | undefined): number {
return typeof partIndex === "number" ? partIndex : 0;
}
async function sendBlueBubblesChatEndpointRequest(params: {
chatGuid: string;
opts: BlueBubblesChatOpts;
endpoint: "read" | "typing";
method: "POST" | "DELETE";
action: "read" | "typing";
}): Promise<void> {
const trimmed = params.chatGuid.trim();
if (!trimmed) {
return;
}
const { baseUrl, password, accountId } = resolveAccount(params.opts);
if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
return;
}
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/chat/${encodeURIComponent(trimmed)}/${params.endpoint}`,
password,
});
const res = await blueBubblesFetchWithTimeout(
url,
{ method: params.method },
params.opts.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(
`BlueBubbles ${params.action} failed (${res.status}): ${errorText || "unknown"}`,
);
}
}
async function sendPrivateApiJsonRequest(params: {
opts: BlueBubblesChatOpts;
feature: string;
@@ -65,24 +98,13 @@ export async function markBlueBubblesChatRead(
chatGuid: string,
opts: BlueBubblesChatOpts = {},
): Promise<void> {
const trimmed = chatGuid.trim();
if (!trimmed) {
return;
}
const { baseUrl, password, accountId } = resolveAccount(opts);
if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
return;
}
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/chat/${encodeURIComponent(trimmed)}/read`,
password,
await sendBlueBubblesChatEndpointRequest({
chatGuid,
opts,
endpoint: "read",
method: "POST",
action: "read",
});
const res = await blueBubblesFetchWithTimeout(url, { method: "POST" }, opts.timeoutMs);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`BlueBubbles read failed (${res.status}): ${errorText || "unknown"}`);
}
}
export async function sendBlueBubblesTyping(
@@ -90,28 +112,13 @@ export async function sendBlueBubblesTyping(
typing: boolean,
opts: BlueBubblesChatOpts = {},
): Promise<void> {
const trimmed = chatGuid.trim();
if (!trimmed) {
return;
}
const { baseUrl, password, accountId } = resolveAccount(opts);
if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
return;
}
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/chat/${encodeURIComponent(trimmed)}/typing`,
password,
await sendBlueBubblesChatEndpointRequest({
chatGuid,
opts,
endpoint: "typing",
method: typing ? "POST" : "DELETE",
action: "typing",
});
const res = await blueBubblesFetchWithTimeout(
url,
{ method: typing ? "POST" : "DELETE" },
opts.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`BlueBubbles typing failed (${res.status}): ${errorText || "unknown"}`);
}
}
/**

View File

@@ -0,0 +1,77 @@
import { DEFAULT_ACCOUNT_ID, type OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
type BlueBubblesConfigPatch = {
serverUrl?: string;
password?: unknown;
webhookPath?: string;
};
type AccountEnabledMode = boolean | "preserve-or-true";
function normalizePatch(
patch: BlueBubblesConfigPatch,
onlyDefinedFields: boolean,
): BlueBubblesConfigPatch {
if (!onlyDefinedFields) {
return patch;
}
const next: BlueBubblesConfigPatch = {};
if (patch.serverUrl !== undefined) {
next.serverUrl = patch.serverUrl;
}
if (patch.password !== undefined) {
next.password = patch.password;
}
if (patch.webhookPath !== undefined) {
next.webhookPath = patch.webhookPath;
}
return next;
}
export function applyBlueBubblesConnectionConfig(params: {
cfg: OpenClawConfig;
accountId: string;
patch: BlueBubblesConfigPatch;
onlyDefinedFields?: boolean;
accountEnabled?: AccountEnabledMode;
}): OpenClawConfig {
const patch = normalizePatch(params.patch, params.onlyDefinedFields === true);
if (params.accountId === DEFAULT_ACCOUNT_ID) {
return {
...params.cfg,
channels: {
...params.cfg.channels,
bluebubbles: {
...params.cfg.channels?.bluebubbles,
enabled: true,
...patch,
},
},
};
}
const currentAccount = params.cfg.channels?.bluebubbles?.accounts?.[params.accountId];
const enabled =
params.accountEnabled === "preserve-or-true"
? (currentAccount?.enabled ?? true)
: (params.accountEnabled ?? true);
return {
...params.cfg,
channels: {
...params.cfg.channels,
bluebubbles: {
...params.cfg.channels?.bluebubbles,
enabled: true,
accounts: {
...params.cfg.channels?.bluebubbles?.accounts,
[params.accountId]: {
...currentAccount,
enabled,
...patch,
},
},
},
},
};
}

View File

@@ -1,3 +1,4 @@
import { parseFiniteNumber } from "../../../src/infra/parse-finite-number.js";
import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
import type { BlueBubblesAttachment } from "./types.js";
@@ -35,17 +36,7 @@ function readNumberLike(record: Record<string, unknown> | null, key: string): nu
if (!record) {
return undefined;
}
const value = record[key];
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return undefined;
return parseFiniteNumber(record[key]);
}
function extractAttachments(message: Record<string, unknown>): BlueBubblesAttachment[] {

View File

@@ -240,6 +240,15 @@ function getFirstDispatchCall(): DispatchReplyParams {
}
describe("BlueBubbles webhook monitor", () => {
const WEBHOOK_PATH = "/bluebubbles-webhook";
const BASE_WEBHOOK_MESSAGE_DATA = {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
} as const;
let unregister: () => void;
beforeEach(() => {
@@ -261,122 +270,144 @@ describe("BlueBubbles webhook monitor", () => {
unregister?.();
});
function createWebhookPayload(
dataOverrides: Record<string, unknown> = {},
): Record<string, unknown> {
return {
type: "new-message",
data: {
...BASE_WEBHOOK_MESSAGE_DATA,
...dataOverrides,
},
};
}
function createWebhookTargetDeps(core?: PluginRuntime): {
config: OpenClawConfig;
core: PluginRuntime;
runtime: {
log: ReturnType<typeof vi.fn<(message: string) => void>>;
error: ReturnType<typeof vi.fn<(message: string) => void>>;
};
} {
const resolvedCore = core ?? createMockRuntime();
setBlueBubblesRuntime(resolvedCore);
return {
config: {},
core: resolvedCore,
runtime: {
log: vi.fn<(message: string) => void>(),
error: vi.fn<(message: string) => void>(),
},
};
}
function registerWebhookTarget(
params: {
account?: ResolvedBlueBubblesAccount;
config?: OpenClawConfig;
core?: PluginRuntime;
runtime?: {
log: ReturnType<typeof vi.fn<(message: string) => void>>;
error: ReturnType<typeof vi.fn<(message: string) => void>>;
};
path?: string;
statusSink?: Parameters<typeof registerBlueBubblesWebhookTarget>[0]["statusSink"];
trackForCleanup?: boolean;
} = {},
): {
config: OpenClawConfig;
core: PluginRuntime;
runtime: {
log: ReturnType<typeof vi.fn<(message: string) => void>>;
error: ReturnType<typeof vi.fn<(message: string) => void>>;
};
stop: () => void;
} {
const deps =
params.config && params.core && params.runtime
? { config: params.config, core: params.core, runtime: params.runtime }
: createWebhookTargetDeps(params.core);
const stop = registerBlueBubblesWebhookTarget({
account: params.account ?? createMockAccount(),
...deps,
path: params.path ?? WEBHOOK_PATH,
statusSink: params.statusSink,
});
if (params.trackForCleanup !== false) {
unregister = stop;
}
return { ...deps, stop };
}
async function sendWebhookRequest(params: {
method?: string;
url?: string;
body?: unknown;
headers?: Record<string, string>;
remoteAddress?: string;
}): Promise<{
req: IncomingMessage;
res: ServerResponse & { body: string; statusCode: number };
handled: boolean;
}> {
const req = createMockRequest(
params.method ?? "POST",
params.url ?? WEBHOOK_PATH,
params.body ?? createWebhookPayload(),
params.headers,
);
if (params.remoteAddress) {
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: params.remoteAddress,
};
}
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
return { req, res, handled };
}
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",
registerWebhookTarget();
const { handled, res } = await sendWebhookRequest({
method: "GET",
body: {},
});
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",
registerWebhookTarget();
const { handled, res } = await sendWebhookRequest({
body: createWebhookPayload({ date: Date.now() }),
});
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",
registerWebhookTarget();
const { handled, res } = await sendWebhookRequest({
body: "invalid json {{",
});
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("accepts URL-encoded payload wrappers", 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(),
},
};
registerWebhookTarget();
const payload = createWebhookPayload({ date: Date.now() });
const encodedBody = new URLSearchParams({
payload: JSON.stringify(payload),
}).toString();
const req = createMockRequest("POST", "/bluebubbles-webhook", encodedBody);
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
const { handled, res } = await sendWebhookRequest({ body: encodedBody });
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
@@ -386,23 +417,12 @@ describe("BlueBubbles webhook monitor", () => {
it("returns 408 when request body times out (Slow-Loris protection)", async () => {
vi.useFakeTimers();
try {
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",
});
registerWebhookTarget();
// Create a request that never sends data or ends (simulates slow-loris)
const req = new EventEmitter() as IncomingMessage;
req.method = "POST";
req.url = "/bluebubbles-webhook?password=test-password";
req.url = `${WEBHOOK_PATH}?password=test-password`;
req.headers = {};
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "127.0.0.1",
@@ -426,22 +446,13 @@ describe("BlueBubbles webhook monitor", () => {
});
it("rejects unauthorized requests before reading the body", async () => {
const account = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
registerWebhookTarget({
account: createMockAccount({ password: "secret-token" }),
});
const req = new EventEmitter() as IncomingMessage;
req.method = "POST";
req.url = "/bluebubbles-webhook?password=wrong-token";
req.url = `${WEBHOOK_PATH}?password=wrong-token`;
req.headers = {};
const onSpy = vi.spyOn(req, "on");
(req as unknown as { socket: { remoteAddress: string } }).socket = {
@@ -457,112 +468,43 @@ describe("BlueBubbles webhook monitor", () => {
});
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",
},
registerWebhookTarget({
account: createMockAccount({ password: "secret-token" }),
});
(req as unknown as { socket: { remoteAddress: string } }).socket = {
const { handled, res } = await sendWebhookRequest({
url: `${WEBHOOK_PATH}?password=secret-token`,
body: createWebhookPayload(),
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",
registerWebhookTarget({
account: createMockAccount({ password: "secret-token" }),
});
const { handled, res } = await sendWebhookRequest({
body: createWebhookPayload(),
headers: { "x-password": "secret-token" },
remoteAddress: "192.168.1.100",
});
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",
},
registerWebhookTarget({
account: createMockAccount({ password: "secret-token" }),
});
(req as unknown as { socket: { remoteAddress: string } }).socket = {
const { handled, res } = await sendWebhookRequest({
url: `${WEBHOOK_PATH}?password=wrong-token`,
body: createWebhookPayload(),
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);
});
@@ -570,50 +512,37 @@ describe("BlueBubbles webhook monitor", () => {
it("rejects ambiguous routing when multiple targets match the same password", async () => {
const accountA = createMockAccount({ password: "secret-token" });
const accountB = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
const { config, core, runtime } = createWebhookTargetDeps();
const sinkA = vi.fn();
const sinkB = vi.fn();
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",
};
const unregisterA = registerBlueBubblesWebhookTarget({
const unregisterA = registerWebhookTarget({
account: accountA,
config,
runtime: { log: vi.fn(), error: vi.fn() },
runtime,
core,
path: "/bluebubbles-webhook",
trackForCleanup: false,
statusSink: sinkA,
});
const unregisterB = registerBlueBubblesWebhookTarget({
}).stop;
const unregisterB = registerWebhookTarget({
account: accountB,
config,
runtime: { log: vi.fn(), error: vi.fn() },
runtime,
core,
path: "/bluebubbles-webhook",
trackForCleanup: false,
statusSink: sinkB,
});
}).stop;
unregister = () => {
unregisterA();
unregisterB();
};
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
const { handled, res } = await sendWebhookRequest({
url: `${WEBHOOK_PATH}?password=secret-token`,
body: createWebhookPayload(),
remoteAddress: "192.168.1.100",
});
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
@@ -624,50 +553,37 @@ describe("BlueBubbles webhook monitor", () => {
it("ignores targets without passwords when a password-authenticated target matches", async () => {
const accountStrict = createMockAccount({ password: "secret-token" });
const accountWithoutPassword = createMockAccount({ password: undefined });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
const { config, core, runtime } = createWebhookTargetDeps();
const sinkStrict = vi.fn();
const sinkWithoutPassword = vi.fn();
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",
};
const unregisterStrict = registerBlueBubblesWebhookTarget({
const unregisterStrict = registerWebhookTarget({
account: accountStrict,
config,
runtime: { log: vi.fn(), error: vi.fn() },
runtime,
core,
path: "/bluebubbles-webhook",
trackForCleanup: false,
statusSink: sinkStrict,
});
const unregisterNoPassword = registerBlueBubblesWebhookTarget({
}).stop;
const unregisterNoPassword = registerWebhookTarget({
account: accountWithoutPassword,
config,
runtime: { log: vi.fn(), error: vi.fn() },
runtime,
core,
path: "/bluebubbles-webhook",
trackForCleanup: false,
statusSink: sinkWithoutPassword,
});
}).stop;
unregister = () => {
unregisterStrict();
unregisterNoPassword();
};
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
const { handled, res } = await sendWebhookRequest({
url: `${WEBHOOK_PATH}?password=secret-token`,
body: createWebhookPayload(),
remoteAddress: "192.168.1.100",
});
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
@@ -677,34 +593,20 @@ describe("BlueBubbles webhook monitor", () => {
it("requires authentication for loopback requests when password is configured", async () => {
const account = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
const { config, core, runtime } = createWebhookTargetDeps();
for (const remoteAddress of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) {
const req = createMockRequest("POST", "/bluebubbles-webhook", {
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,
};
const loopbackUnregister = registerBlueBubblesWebhookTarget({
const loopbackUnregister = registerWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
runtime,
core,
path: "/bluebubbles-webhook",
});
trackForCleanup: false,
}).stop;
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
const { handled, res } = await sendWebhookRequest({
body: createWebhookPayload(),
remoteAddress,
});
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
@@ -713,17 +615,8 @@ describe("BlueBubbles webhook monitor", () => {
});
it("rejects targets without passwords for loopback and proxied-looking requests", async () => {
const account = createMockAccount({ password: undefined });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
registerWebhookTarget({
account: createMockAccount({ password: undefined }),
});
const headerVariants: Record<string, string>[] = [
@@ -732,26 +625,11 @@ describe("BlueBubbles webhook monitor", () => {
{ host: "localhost", forwarded: "for=203.0.113.10;proto=https;host=example.com" },
];
for (const headers of headerVariants) {
const req = createMockRequest(
"POST",
"/bluebubbles-webhook",
{
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
},
const { handled, res } = await sendWebhookRequest({
body: createWebhookPayload(),
headers,
);
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "127.0.0.1",
};
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
});
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
}
@@ -770,36 +648,18 @@ describe("BlueBubbles webhook monitor", () => {
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",
registerWebhookTarget({
account: createMockAccount({ groupPolicy: "open" }),
});
const payload = {
type: "new-message",
data: {
await sendWebhookRequest({
body: createWebhookPayload({
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(
@@ -819,36 +679,18 @@ describe("BlueBubbles webhook monitor", () => {
return EMPTY_DISPATCH_RESULT;
});
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",
registerWebhookTarget({
account: createMockAccount({ groupPolicy: "open" }),
});
const payload = {
type: "new-message",
data: {
await sendWebhookRequest({
body: createWebhookPayload({
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();

View File

@@ -18,6 +18,7 @@ import {
resolveBlueBubblesAccount,
resolveDefaultBlueBubblesAccountId,
} from "./accounts.js";
import { applyBlueBubblesConnectionConfig } from "./config-apply.js";
import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js";
import { parseBlueBubblesAllowTarget } from "./targets.js";
import { normalizeBlueBubblesServerUrl } from "./types.js";
@@ -283,42 +284,16 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
}
// Apply config
if (accountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
channels: {
...next.channels,
bluebubbles: {
...next.channels?.bluebubbles,
enabled: true,
serverUrl,
password,
webhookPath,
},
},
};
} else {
next = {
...next,
channels: {
...next.channels,
bluebubbles: {
...next.channels?.bluebubbles,
enabled: true,
accounts: {
...next.channels?.bluebubbles?.accounts,
[accountId]: {
...next.channels?.bluebubbles?.accounts?.[accountId],
enabled: next.channels?.bluebubbles?.accounts?.[accountId]?.enabled ?? true,
serverUrl,
password,
webhookPath,
},
},
},
},
};
}
next = applyBlueBubblesConnectionConfig({
cfg: next,
accountId,
patch: {
serverUrl,
password,
webhookPath,
},
accountEnabled: "preserve-or-true",
});
await prompter.note(
[

View File

@@ -1,12 +1 @@
export function resolveRequestUrl(input: RequestInfo | URL): string {
if (typeof input === "string") {
return input;
}
if (input instanceof URL) {
return input.toString();
}
if (typeof input === "object" && input && "url" in input && typeof input.url === "string") {
return input.url;
}
return String(input);
}
export { resolveRequestUrl } from "openclaw/plugin-sdk/bluebubbles";

View File

@@ -108,6 +108,19 @@ function resolvePrivateApiDecision(params: {
};
}
async function parseBlueBubblesMessageResponse(res: Response): Promise<BlueBubblesSendResult> {
const body = await res.text();
if (!body) {
return { messageId: "ok" };
}
try {
const parsed = JSON.parse(body) as unknown;
return { messageId: extractBlueBubblesMessageId(parsed) };
} catch {
return { messageId: "ok" };
}
}
type BlueBubblesChatRecord = Record<string, unknown>;
function extractChatGuid(chat: BlueBubblesChatRecord): string | null {
@@ -342,16 +355,7 @@ async function createNewChatWithMessage(params: {
}
throw new Error(`BlueBubbles create chat failed (${res.status}): ${errorText || "unknown"}`);
}
const body = await res.text();
if (!body) {
return { messageId: "ok" };
}
try {
const parsed = JSON.parse(body) as unknown;
return { messageId: extractBlueBubblesMessageId(parsed) };
} catch {
return { messageId: "ok" };
}
return parseBlueBubblesMessageResponse(res);
}
export async function sendMessageBlueBubbles(
@@ -464,14 +468,5 @@ export async function sendMessageBlueBubbles(
const errorText = await res.text();
throw new Error(`BlueBubbles send failed (${res.status}): ${errorText || "unknown"}`);
}
const body = await res.text();
if (!body) {
return { messageId: "ok" };
}
try {
const parsed = JSON.parse(body) as unknown;
return { messageId: extractBlueBubblesMessageId(parsed) };
} catch {
return { messageId: "ok" };
}
return parseBlueBubblesMessageResponse(res);
}