mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
refactor(extensions): reuse shared helper primitives
This commit is contained in:
@@ -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,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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"}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
77
extensions/bluebubbles/src/config-apply.ts
Normal file
77
extensions/bluebubbles/src/config-apply.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user