mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-28 00:43:57 +00:00
refactor(extensions): dedupe connector helper usage
This commit is contained in:
29
extensions/bluebubbles/src/account-resolve.ts
Normal file
29
extensions/bluebubbles/src/account-resolve.ts
Normal 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 };
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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("", {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
45
extensions/bluebubbles/src/test-harness.ts
Normal file
45
extensions/bluebubbles/src/test-harness.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
11
extensions/bluebubbles/src/test-mocks.ts
Normal file
11
extensions/bluebubbles/src/test-mocks.ts
Normal 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();
|
||||
});
|
||||
Reference in New Issue
Block a user