refactor(extensions): dedupe connector helper usage

This commit is contained in:
Peter Steinberger
2026-02-16 14:51:55 +00:00
parent bc55ffb160
commit 544ffbcf7b
49 changed files with 854 additions and 1478 deletions

View File

@@ -0,0 +1,29 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { resolveBlueBubblesAccount } from "./accounts.js";
export type BlueBubblesAccountResolveOpts = {
serverUrl?: string;
password?: string;
accountId?: string;
cfg?: OpenClawConfig;
};
export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolveOpts): {
baseUrl: string;
password: string;
accountId: string;
} {
const account = resolveBlueBubblesAccount({
cfg: params.cfg ?? {},
accountId: params.accountId,
});
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
const password = params.password?.trim() || account.config.password?.trim();
if (!baseUrl) {
throw new Error("BlueBubbles serverUrl is required");
}
if (!password) {
throw new Error("BlueBubbles password is required");
}
return { baseUrl, password, accountId: account.accountId };
}

View File

@@ -1,38 +1,18 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import "./test-mocks.js";
import type { BlueBubblesAttachment } from "./types.js";
import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
vi.mock("./accounts.js", () => ({
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
const config = cfg?.channels?.bluebubbles ?? {};
return {
accountId: accountId ?? "default",
enabled: config.enabled !== false,
configured: Boolean(config.serverUrl && config.password),
config,
};
}),
}));
vi.mock("./probe.js", () => ({
getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
}));
import { installBlueBubblesFetchTestHooks } from "./test-harness.js";
const mockFetch = vi.fn();
installBlueBubblesFetchTestHooks({
mockFetch,
privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus),
});
describe("downloadBlueBubblesAttachment", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("throws when guid is missing", async () => {
const attachment: BlueBubblesAttachment = {};
await expect(

View File

@@ -1,7 +1,7 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import crypto from "node:crypto";
import path from "node:path";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
import { postMultipartFormData } from "./multipart.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
@@ -54,19 +54,7 @@ function resolveVoiceInfo(filename: string, contentType?: string) {
}
function resolveAccount(params: BlueBubblesAttachmentOpts) {
const account = resolveBlueBubblesAccount({
cfg: params.cfg ?? {},
accountId: params.accountId,
});
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
const password = params.password?.trim() || account.config.password?.trim();
if (!baseUrl) {
throw new Error("BlueBubbles serverUrl is required");
}
if (!password) {
throw new Error("BlueBubbles password is required");
}
return { baseUrl, password, accountId: account.accountId };
return resolveBlueBubblesServerAccount(params);
}
export async function downloadBlueBubblesAttachment(

View File

@@ -1,37 +1,17 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { describe, expect, it, vi } from "vitest";
import "./test-mocks.js";
import { markBlueBubblesChatRead, sendBlueBubblesTyping, setGroupIconBlueBubbles } from "./chat.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
vi.mock("./accounts.js", () => ({
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
const config = cfg?.channels?.bluebubbles ?? {};
return {
accountId: accountId ?? "default",
enabled: config.enabled !== false,
configured: Boolean(config.serverUrl && config.password),
config,
};
}),
}));
vi.mock("./probe.js", () => ({
getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
}));
import { installBlueBubblesFetchTestHooks } from "./test-harness.js";
const mockFetch = vi.fn();
installBlueBubblesFetchTestHooks({
mockFetch,
privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus),
});
describe("chat", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("markBlueBubblesChatRead", () => {
it("does nothing when chatGuid is empty", async () => {
await markBlueBubblesChatRead("", {

View File

@@ -1,7 +1,7 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import crypto from "node:crypto";
import path from "node:path";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
import { postMultipartFormData } from "./multipart.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
@@ -15,19 +15,7 @@ export type BlueBubblesChatOpts = {
};
function resolveAccount(params: BlueBubblesChatOpts) {
const account = resolveBlueBubblesAccount({
cfg: params.cfg ?? {},
accountId: params.accountId,
});
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
const password = params.password?.trim() || account.config.password?.trim();
if (!baseUrl) {
throw new Error("BlueBubbles serverUrl is required");
}
if (!password) {
throw new Error("BlueBubbles password is required");
}
return { baseUrl, password, accountId: account.accountId };
return resolveBlueBubblesServerAccount(params);
}
function assertPrivateApiEnabled(accountId: string, feature: string): void {

View File

@@ -1,6 +1,11 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { timingSafeEqual } from "node:crypto";
import {
registerWebhookTarget,
rejectNonPostWebhookRequest,
resolveWebhookTargets,
} from "openclaw/plugin-sdk";
import {
normalizeWebhookMessage,
normalizeWebhookReaction,
@@ -226,20 +231,11 @@ function removeDebouncer(target: WebhookTarget): void {
}
export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void {
const key = normalizeWebhookPath(target.path);
const normalizedTarget = { ...target, path: key };
const existing = webhookTargets.get(key) ?? [];
const next = [...existing, normalizedTarget];
webhookTargets.set(key, next);
const registered = registerWebhookTarget(webhookTargets, target);
return () => {
const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
if (updated.length > 0) {
webhookTargets.set(key, updated);
} else {
webhookTargets.delete(key);
}
registered.unregister();
// Clean up debouncer when target is unregistered
removeDebouncer(normalizedTarget);
removeDebouncer(registered.target);
};
}
@@ -387,17 +383,14 @@ export async function handleBlueBubblesWebhookRequest(
req: IncomingMessage,
res: ServerResponse,
): Promise<boolean> {
const url = new URL(req.url ?? "/", "http://localhost");
const path = normalizeWebhookPath(url.pathname);
const targets = webhookTargets.get(path);
if (!targets || targets.length === 0) {
const resolved = resolveWebhookTargets(req, webhookTargets);
if (!resolved) {
return false;
}
const { path, targets } = resolved;
const url = new URL(req.url ?? "/", "http://localhost");
if (req.method !== "POST") {
res.statusCode = 405;
res.setHeader("Allow", "POST");
res.end("Method Not Allowed");
if (rejectNonPostWebhookRequest(req, res)) {
return true;
}

View File

@@ -1,5 +1,5 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
@@ -112,19 +112,7 @@ const REACTION_EMOJIS = new Map<string, string>([
]);
function resolveAccount(params: BlueBubblesReactionOpts) {
const account = resolveBlueBubblesAccount({
cfg: params.cfg ?? {},
accountId: params.accountId,
});
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
const password = params.password?.trim() || account.config.password?.trim();
if (!baseUrl) {
throw new Error("BlueBubbles serverUrl is required");
}
if (!password) {
throw new Error("BlueBubbles password is required");
}
return { baseUrl, password, accountId: account.accountId };
return resolveBlueBubblesServerAccount(params);
}
export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolean): string {

View File

@@ -1,39 +1,62 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import "./test-mocks.js";
import type { BlueBubblesSendTarget } from "./types.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { sendMessageBlueBubbles, resolveChatGuidForTarget } from "./send.js";
vi.mock("./accounts.js", () => ({
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
const config = cfg?.channels?.bluebubbles ?? {};
return {
accountId: accountId ?? "default",
enabled: config.enabled !== false,
configured: Boolean(config.serverUrl && config.password),
config,
};
}),
}));
vi.mock("./probe.js", () => ({
getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
}));
import { installBlueBubblesFetchTestHooks } from "./test-harness.js";
const mockFetch = vi.fn();
installBlueBubblesFetchTestHooks({
mockFetch,
privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus),
});
function mockResolvedHandleTarget(
guid: string = "iMessage;-;+15551234567",
address: string = "+15551234567",
) {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid,
participants: [{ address }],
},
],
}),
});
}
function mockSendResponse(body: unknown) {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify(body)),
});
}
describe("send", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("resolveChatGuidForTarget", () => {
const resolveHandleTargetGuid = async (data: Array<Record<string, unknown>>) => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data }),
});
const target: BlueBubblesSendTarget = {
kind: "handle",
address: "+15551234567",
service: "imessage",
};
return await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
};
it("returns chatGuid directly for chat_guid target", async () => {
const target: BlueBubblesSendTarget = {
kind: "chat_guid",
@@ -130,65 +153,31 @@ describe("send", () => {
});
it("resolves handle target by matching participant", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15559999999",
participants: [{ address: "+15559999999" }],
},
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
});
const target: BlueBubblesSendTarget = {
kind: "handle",
address: "+15551234567",
service: "imessage",
};
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
const result = await resolveHandleTargetGuid([
{
guid: "iMessage;-;+15559999999",
participants: [{ address: "+15559999999" }],
},
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
]);
expect(result).toBe("iMessage;-;+15551234567");
});
it("prefers direct chat guid when handle also appears in a group chat", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;+;group-123",
participants: [{ address: "+15551234567" }, { address: "+15550001111" }],
},
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
});
const target: BlueBubblesSendTarget = {
kind: "handle",
address: "+15551234567",
service: "imessage",
};
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
const result = await resolveHandleTargetGuid([
{
guid: "iMessage;+;group-123",
participants: [{ address: "+15551234567" }, { address: "+15550001111" }],
},
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
]);
expect(result).toBe("iMessage;-;+15551234567");
});
@@ -416,28 +405,8 @@ describe("send", () => {
});
it("sends message successfully", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { guid: "msg-uuid-123" },
}),
),
});
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-uuid-123" } });
const result = await sendMessageBlueBubbles("+15551234567", "Hello world!", {
serverUrl: "http://localhost:1234",
@@ -456,28 +425,8 @@ describe("send", () => {
});
it("strips markdown formatting from outbound messages", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { guid: "msg-uuid-stripped" },
}),
),
});
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-uuid-stripped" } });
const result = await sendMessageBlueBubbles(
"+15551234567",
@@ -578,28 +527,8 @@ describe("send", () => {
});
it("uses private-api when reply metadata is present", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { guid: "msg-uuid-124" },
}),
),
});
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-uuid-124" } });
const result = await sendMessageBlueBubbles("+15551234567", "Replying", {
serverUrl: "http://localhost:1234",
@@ -620,28 +549,8 @@ describe("send", () => {
it("downgrades threaded reply to plain send when private API is disabled", async () => {
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { guid: "msg-uuid-plain" },
}),
),
});
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-uuid-plain" } });
const result = await sendMessageBlueBubbles("+15551234567", "Reply fallback", {
serverUrl: "http://localhost:1234",
@@ -659,28 +568,8 @@ describe("send", () => {
});
it("normalizes effect names and uses private-api for effects", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { guid: "msg-uuid-125" },
}),
),
});
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-uuid-125" } });
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
@@ -722,24 +611,12 @@ describe("send", () => {
});
it("handles send failure", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: false,
status: 500,
text: () => Promise.resolve("Internal server error"),
});
mockResolvedHandleTarget();
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
text: () => Promise.resolve("Internal server error"),
});
await expect(
sendMessageBlueBubbles("+15551234567", "Hello", {
@@ -750,23 +627,11 @@ describe("send", () => {
});
it("handles empty response body", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
mockResolvedHandleTarget();
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
@@ -777,23 +642,11 @@ describe("send", () => {
});
it("handles invalid JSON response body", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve("not valid json"),
});
mockResolvedHandleTarget();
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve("not valid json"),
});
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
@@ -804,28 +657,8 @@ describe("send", () => {
});
it("extracts messageId from various response formats", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
id: "numeric-id-456",
}),
),
});
mockResolvedHandleTarget();
mockSendResponse({ id: "numeric-id-456" });
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
@@ -836,28 +669,8 @@ describe("send", () => {
});
it("extracts messageGuid from response payload", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { messageGuid: "msg-guid-789" },
}),
),
});
mockResolvedHandleTarget();
mockSendResponse({ data: { messageGuid: "msg-guid-789" } });
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
@@ -868,23 +681,8 @@ describe("send", () => {
});
it("resolves credentials from config", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-123" } })),
});
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-123" } });
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
cfg: {
@@ -903,23 +701,8 @@ describe("send", () => {
});
it("includes tempGuid in request payload", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg" } })),
});
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg" } });
await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",

View File

@@ -1,4 +1,5 @@
import {
isAllowedParsedChatSender,
parseChatAllowTargetPrefixes,
parseChatTargetPrefixesOrThrow,
resolveServicePrefixedAllowTarget,
@@ -329,43 +330,15 @@ export function isAllowedBlueBubblesSender(params: {
chatGuid?: string | null;
chatIdentifier?: string | null;
}): boolean {
const allowFrom = params.allowFrom.map((entry) => String(entry).trim());
if (allowFrom.length === 0) {
return true;
}
if (allowFrom.includes("*")) {
return true;
}
const senderNormalized = normalizeBlueBubblesHandle(params.sender);
const chatId = params.chatId ?? undefined;
const chatGuid = params.chatGuid?.trim();
const chatIdentifier = params.chatIdentifier?.trim();
for (const entry of allowFrom) {
if (!entry) {
continue;
}
const parsed = parseBlueBubblesAllowTarget(entry);
if (parsed.kind === "chat_id" && chatId !== undefined) {
if (parsed.chatId === chatId) {
return true;
}
} else if (parsed.kind === "chat_guid" && chatGuid) {
if (parsed.chatGuid === chatGuid) {
return true;
}
} else if (parsed.kind === "chat_identifier" && chatIdentifier) {
if (parsed.chatIdentifier === chatIdentifier) {
return true;
}
} else if (parsed.kind === "handle" && senderNormalized) {
if (parsed.handle === senderNormalized) {
return true;
}
}
}
return false;
return isAllowedParsedChatSender({
allowFrom: params.allowFrom,
sender: params.sender,
chatId: params.chatId,
chatGuid: params.chatGuid,
chatIdentifier: params.chatIdentifier,
normalizeSender: normalizeBlueBubblesHandle,
parseAllowTarget: parseBlueBubblesAllowTarget,
});
}
export function formatBlueBubblesChatTarget(params: {

View File

@@ -0,0 +1,45 @@
import { afterEach, beforeEach, vi } from "vitest";
export function resolveBlueBubblesAccountFromConfig(params: {
cfg?: { channels?: { bluebubbles?: Record<string, unknown> } };
accountId?: string;
}) {
const config = params.cfg?.channels?.bluebubbles ?? {};
return {
accountId: params.accountId ?? "default",
enabled: config.enabled !== false,
configured: Boolean(config.serverUrl && config.password),
config,
};
}
export function createBlueBubblesAccountsMockModule() {
return {
resolveBlueBubblesAccount: vi.fn(resolveBlueBubblesAccountFromConfig),
};
}
export function createBlueBubblesProbeMockModule() {
return {
getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
};
}
export function installBlueBubblesFetchTestHooks(params: {
mockFetch: ReturnType<typeof vi.fn>;
privateApiStatusMock: {
mockReset: () => unknown;
mockReturnValue: (value: boolean | null) => unknown;
};
}) {
beforeEach(() => {
vi.stubGlobal("fetch", params.mockFetch);
params.mockFetch.mockReset();
params.privateApiStatusMock.mockReset();
params.privateApiStatusMock.mockReturnValue(null);
});
afterEach(() => {
vi.unstubAllGlobals();
});
}

View File

@@ -0,0 +1,11 @@
import { vi } from "vitest";
vi.mock("./accounts.js", async () => {
const { createBlueBubblesAccountsMockModule } = await import("./test-harness.js");
return createBlueBubblesAccountsMockModule();
});
vi.mock("./probe.js", async () => {
const { createBlueBubblesProbeMockModule } = await import("./test-harness.js");
return createBlueBubblesProbeMockModule();
});