diff --git a/extensions/bluebubbles/src/actions.test.ts b/extensions/bluebubbles/src/actions.test.ts index efb4859fac4..aabc5adf8fe 100644 --- a/extensions/bluebubbles/src/actions.test.ts +++ b/extensions/bluebubbles/src/actions.test.ts @@ -3,17 +3,10 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { bluebubblesMessageActions } from "./actions.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("./accounts.js", async () => { + const { createBlueBubblesAccountsMockModule } = await import("./test-harness.js"); + return createBlueBubblesAccountsMockModule(); +}); vi.mock("./reactions.js", () => ({ sendBlueBubblesReaction: vi.fn().mockResolvedValue(undefined), diff --git a/extensions/bluebubbles/src/chat.test.ts b/extensions/bluebubbles/src/chat.test.ts index f372ca4614e..d22ded63613 100644 --- a/extensions/bluebubbles/src/chat.test.ts +++ b/extensions/bluebubbles/src/chat.test.ts @@ -1,6 +1,16 @@ import { describe, expect, it, vi } from "vitest"; import "./test-mocks.js"; -import { markBlueBubblesChatRead, sendBlueBubblesTyping, setGroupIconBlueBubbles } from "./chat.js"; +import { + addBlueBubblesParticipant, + editBlueBubblesMessage, + leaveBlueBubblesChat, + markBlueBubblesChatRead, + removeBlueBubblesParticipant, + renameBlueBubblesChat, + sendBlueBubblesTyping, + setGroupIconBlueBubbles, + unsendBlueBubblesMessage, +} from "./chat.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; import { installBlueBubblesFetchTestHooks } from "./test-harness.js"; @@ -278,6 +288,188 @@ describe("chat", () => { }); }); + describe("editBlueBubblesMessage", () => { + it("throws when required args are missing", async () => { + await expect(editBlueBubblesMessage("", "updated", {})).rejects.toThrow("messageGuid"); + await expect(editBlueBubblesMessage("message-guid", " ", {})).rejects.toThrow("newText"); + }); + + it("sends edit request with default payload values", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await editBlueBubblesMessage(" message-guid ", " updated text ", { + serverUrl: "http://localhost:1234", + password: "test-password", + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/message/message-guid/edit"), + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + }), + ); + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body).toEqual({ + editedMessage: "updated text", + backwardsCompatibilityMessage: "Edited to: updated text", + partIndex: 0, + }); + }); + + it("supports custom part index and backwards compatibility message", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await editBlueBubblesMessage("message-guid", "new text", { + serverUrl: "http://localhost:1234", + password: "test-password", + partIndex: 3, + backwardsCompatMessage: "custom-backwards-message", + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.partIndex).toBe(3); + expect(body.backwardsCompatibilityMessage).toBe("custom-backwards-message"); + }); + + it("throws on non-ok response", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 422, + text: () => Promise.resolve("Unprocessable"), + }); + + await expect( + editBlueBubblesMessage("message-guid", "new text", { + serverUrl: "http://localhost:1234", + password: "test-password", + }), + ).rejects.toThrow("edit failed (422): Unprocessable"); + }); + }); + + describe("unsendBlueBubblesMessage", () => { + it("throws when messageGuid is missing", async () => { + await expect(unsendBlueBubblesMessage("", {})).rejects.toThrow("messageGuid"); + }); + + it("sends unsend request with default part index", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await unsendBlueBubblesMessage(" msg-123 ", { + serverUrl: "http://localhost:1234", + password: "test-password", + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/message/msg-123/unsend"), + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + }), + ); + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.partIndex).toBe(0); + }); + + it("uses custom part index", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await unsendBlueBubblesMessage("msg-123", { + serverUrl: "http://localhost:1234", + password: "test-password", + partIndex: 2, + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.partIndex).toBe(2); + }); + }); + + describe("group chat mutation actions", () => { + it("renames chat", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await renameBlueBubblesChat(" chat-guid ", "New Group Name", { + serverUrl: "http://localhost:1234", + password: "test-password", + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/chat/chat-guid"), + expect.objectContaining({ method: "PUT" }), + ); + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.displayName).toBe("New Group Name"); + }); + + it("adds and removes participant using matching endpoint", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }) + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await addBlueBubblesParticipant("chat-guid", "+15551234567", { + serverUrl: "http://localhost:1234", + password: "test-password", + }); + await removeBlueBubblesParticipant("chat-guid", "+15551234567", { + serverUrl: "http://localhost:1234", + password: "test-password", + }); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch.mock.calls[0][0]).toContain("/api/v1/chat/chat-guid/participant"); + expect(mockFetch.mock.calls[0][1].method).toBe("POST"); + expect(mockFetch.mock.calls[1][0]).toContain("/api/v1/chat/chat-guid/participant"); + expect(mockFetch.mock.calls[1][1].method).toBe("DELETE"); + + const addBody = JSON.parse(mockFetch.mock.calls[0][1].body); + const removeBody = JSON.parse(mockFetch.mock.calls[1][1].body); + expect(addBody.address).toBe("+15551234567"); + expect(removeBody.address).toBe("+15551234567"); + }); + + it("leaves chat without JSON body", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await leaveBlueBubblesChat("chat-guid", { + serverUrl: "http://localhost:1234", + password: "test-password", + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/chat/chat-guid/leave"), + expect.objectContaining({ method: "POST" }), + ); + expect(mockFetch.mock.calls[0][1].body).toBeUndefined(); + expect(mockFetch.mock.calls[0][1].headers).toBeUndefined(); + }); + }); + describe("setGroupIconBlueBubbles", () => { it("throws when chatGuid is empty", async () => { await expect( diff --git a/extensions/bluebubbles/src/chat.ts b/extensions/bluebubbles/src/chat.ts index 354e7076722..f5f83b1b6ae 100644 --- a/extensions/bluebubbles/src/chat.ts +++ b/extensions/bluebubbles/src/chat.ts @@ -26,6 +26,41 @@ function assertPrivateApiEnabled(accountId: string, feature: string): void { } } +function resolvePartIndex(partIndex: number | undefined): number { + return typeof partIndex === "number" ? partIndex : 0; +} + +async function sendPrivateApiJsonRequest(params: { + opts: BlueBubblesChatOpts; + feature: string; + action: string; + path: string; + method: "POST" | "PUT" | "DELETE"; + payload?: unknown; +}): Promise { + const { baseUrl, password, accountId } = resolveAccount(params.opts); + assertPrivateApiEnabled(accountId, params.feature); + const url = buildBlueBubblesApiUrl({ + baseUrl, + path: params.path, + password, + }); + + const request: RequestInit = { method: params.method }; + if (params.payload !== undefined) { + request.headers = { "Content-Type": "application/json" }; + request.body = JSON.stringify(params.payload); + } + + const res = await blueBubblesFetchWithTimeout(url, request, params.opts.timeoutMs); + if (!res.ok) { + const errorText = await res.text().catch(() => ""); + throw new Error( + `BlueBubbles ${params.action} failed (${res.status}): ${errorText || "unknown"}`, + ); + } +} + export async function markBlueBubblesChatRead( chatGuid: string, opts: BlueBubblesChatOpts = {}, @@ -97,34 +132,18 @@ export async function editBlueBubblesMessage( throw new Error("BlueBubbles edit requires newText"); } - const { baseUrl, password, accountId } = resolveAccount(opts); - assertPrivateApiEnabled(accountId, "edit"); - const url = buildBlueBubblesApiUrl({ - baseUrl, + await sendPrivateApiJsonRequest({ + opts, + feature: "edit", + action: "edit", + method: "POST", path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/edit`, - password, - }); - - const payload = { - editedMessage: trimmedText, - backwardsCompatibilityMessage: opts.backwardsCompatMessage ?? `Edited to: ${trimmedText}`, - partIndex: typeof opts.partIndex === "number" ? opts.partIndex : 0, - }; - - const res = await blueBubblesFetchWithTimeout( - url, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), + payload: { + editedMessage: trimmedText, + backwardsCompatibilityMessage: opts.backwardsCompatMessage ?? `Edited to: ${trimmedText}`, + partIndex: resolvePartIndex(opts.partIndex), }, - opts.timeoutMs, - ); - - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error(`BlueBubbles edit failed (${res.status}): ${errorText || "unknown"}`); - } + }); } /** @@ -140,32 +159,14 @@ export async function unsendBlueBubblesMessage( throw new Error("BlueBubbles unsend requires messageGuid"); } - const { baseUrl, password, accountId } = resolveAccount(opts); - assertPrivateApiEnabled(accountId, "unsend"); - const url = buildBlueBubblesApiUrl({ - baseUrl, + await sendPrivateApiJsonRequest({ + opts, + feature: "unsend", + action: "unsend", + method: "POST", path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/unsend`, - password, + payload: { partIndex: resolvePartIndex(opts.partIndex) }, }); - - const payload = { - partIndex: typeof opts.partIndex === "number" ? opts.partIndex : 0, - }; - - const res = await blueBubblesFetchWithTimeout( - url, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }, - opts.timeoutMs, - ); - - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error(`BlueBubbles unsend failed (${res.status}): ${errorText || "unknown"}`); - } } /** @@ -181,28 +182,14 @@ export async function renameBlueBubblesChat( throw new Error("BlueBubbles rename requires chatGuid"); } - const { baseUrl, password, accountId } = resolveAccount(opts); - assertPrivateApiEnabled(accountId, "renameGroup"); - const url = buildBlueBubblesApiUrl({ - baseUrl, + await sendPrivateApiJsonRequest({ + opts, + feature: "renameGroup", + action: "rename", + method: "PUT", path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}`, - password, + payload: { displayName }, }); - - const res = await blueBubblesFetchWithTimeout( - url, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ displayName }), - }, - opts.timeoutMs, - ); - - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error(`BlueBubbles rename failed (${res.status}): ${errorText || "unknown"}`); - } } /** @@ -222,28 +209,14 @@ export async function addBlueBubblesParticipant( throw new Error("BlueBubbles addParticipant requires address"); } - const { baseUrl, password, accountId } = resolveAccount(opts); - assertPrivateApiEnabled(accountId, "addParticipant"); - const url = buildBlueBubblesApiUrl({ - baseUrl, + await sendPrivateApiJsonRequest({ + opts, + feature: "addParticipant", + action: "addParticipant", + method: "POST", path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`, - password, + payload: { address: trimmedAddress }, }); - - const res = await blueBubblesFetchWithTimeout( - url, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ address: trimmedAddress }), - }, - opts.timeoutMs, - ); - - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error(`BlueBubbles addParticipant failed (${res.status}): ${errorText || "unknown"}`); - } } /** @@ -263,30 +236,14 @@ export async function removeBlueBubblesParticipant( throw new Error("BlueBubbles removeParticipant requires address"); } - const { baseUrl, password, accountId } = resolveAccount(opts); - assertPrivateApiEnabled(accountId, "removeParticipant"); - const url = buildBlueBubblesApiUrl({ - baseUrl, + await sendPrivateApiJsonRequest({ + opts, + feature: "removeParticipant", + action: "removeParticipant", + method: "DELETE", path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`, - password, + payload: { address: trimmedAddress }, }); - - const res = await blueBubblesFetchWithTimeout( - url, - { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ address: trimmedAddress }), - }, - opts.timeoutMs, - ); - - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error( - `BlueBubbles removeParticipant failed (${res.status}): ${errorText || "unknown"}`, - ); - } } /** @@ -301,20 +258,13 @@ export async function leaveBlueBubblesChat( throw new Error("BlueBubbles leaveChat requires chatGuid"); } - const { baseUrl, password, accountId } = resolveAccount(opts); - assertPrivateApiEnabled(accountId, "leaveGroup"); - const url = buildBlueBubblesApiUrl({ - baseUrl, + await sendPrivateApiJsonRequest({ + opts, + feature: "leaveGroup", + action: "leaveChat", + method: "POST", path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/leave`, - password, }); - - const res = await blueBubblesFetchWithTimeout(url, { method: "POST" }, opts.timeoutMs); - - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error(`BlueBubbles leaveChat failed (${res.status}): ${errorText || "unknown"}`); - } } /** diff --git a/extensions/bluebubbles/src/reactions.test.ts b/extensions/bluebubbles/src/reactions.test.ts index 643a926b889..0ea99f911f6 100644 --- a/extensions/bluebubbles/src/reactions.test.ts +++ b/extensions/bluebubbles/src/reactions.test.ts @@ -1,17 +1,10 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { sendBlueBubblesReaction } from "./reactions.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("./accounts.js", async () => { + const { createBlueBubblesAccountsMockModule } = await import("./test-harness.js"); + return createBlueBubblesAccountsMockModule(); +}); const mockFetch = vi.fn(); diff --git a/extensions/bluebubbles/src/targets.ts b/extensions/bluebubbles/src/targets.ts index be9d0fa6770..b136de3095c 100644 --- a/extensions/bluebubbles/src/targets.ts +++ b/extensions/bluebubbles/src/targets.ts @@ -78,6 +78,40 @@ function looksLikeRawChatIdentifier(value: string): boolean { return CHAT_IDENTIFIER_UUID_RE.test(trimmed) || CHAT_IDENTIFIER_HEX_RE.test(trimmed); } +function parseGroupTarget(params: { + trimmed: string; + lower: string; + requireValue: boolean; +}): { kind: "chat_id"; chatId: number } | { kind: "chat_guid"; chatGuid: string } | null { + if (!params.lower.startsWith("group:")) { + return null; + } + const value = stripPrefix(params.trimmed, "group:"); + const chatId = Number.parseInt(value, 10); + if (Number.isFinite(chatId)) { + return { kind: "chat_id", chatId }; + } + if (value) { + return { kind: "chat_guid", chatGuid: value }; + } + if (params.requireValue) { + throw new Error("group target is required"); + } + return null; +} + +function parseRawChatIdentifierTarget( + trimmed: string, +): { kind: "chat_identifier"; chatIdentifier: string } | null { + if (/^chat\d+$/i.test(trimmed)) { + return { kind: "chat_identifier", chatIdentifier: trimmed }; + } + if (looksLikeRawChatIdentifier(trimmed)) { + return { kind: "chat_identifier", chatIdentifier: trimmed }; + } + return null; +} + export function normalizeBlueBubblesHandle(raw: string): string { const trimmed = raw.trim(); if (!trimmed) { @@ -239,16 +273,9 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget { return chatTarget; } - if (lower.startsWith("group:")) { - const value = stripPrefix(trimmed, "group:"); - const chatId = Number.parseInt(value, 10); - if (Number.isFinite(chatId)) { - return { kind: "chat_id", chatId }; - } - if (!value) { - throw new Error("group target is required"); - } - return { kind: "chat_guid", chatGuid: value }; + const groupTarget = parseGroupTarget({ trimmed, lower, requireValue: true }); + if (groupTarget) { + return groupTarget; } const rawChatGuid = parseRawChatGuid(trimmed); @@ -256,15 +283,9 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget { return { kind: "chat_guid", chatGuid: rawChatGuid }; } - // Handle chat pattern (e.g., "chat660250192681427962") as chat_identifier - // These are BlueBubbles chat identifiers (the third part of a chat GUID), not numeric IDs - if (/^chat\d+$/i.test(trimmed)) { - return { kind: "chat_identifier", chatIdentifier: trimmed }; - } - - // Handle UUID/hex chat identifiers (e.g., "8b9c1a10536d4d86a336ea03ab7151cc") - if (looksLikeRawChatIdentifier(trimmed)) { - return { kind: "chat_identifier", chatIdentifier: trimmed }; + const rawChatIdentifierTarget = parseRawChatIdentifierTarget(trimmed); + if (rawChatIdentifierTarget) { + return rawChatIdentifierTarget; } return { kind: "handle", to: trimmed, service: "auto" }; @@ -298,26 +319,14 @@ export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget return chatTarget; } - if (lower.startsWith("group:")) { - const value = stripPrefix(trimmed, "group:"); - const chatId = Number.parseInt(value, 10); - if (Number.isFinite(chatId)) { - return { kind: "chat_id", chatId }; - } - if (value) { - return { kind: "chat_guid", chatGuid: value }; - } + const groupTarget = parseGroupTarget({ trimmed, lower, requireValue: false }); + if (groupTarget) { + return groupTarget; } - // Handle chat pattern (e.g., "chat660250192681427962") as chat_identifier - // These are BlueBubbles chat identifiers (the third part of a chat GUID), not numeric IDs - if (/^chat\d+$/i.test(trimmed)) { - return { kind: "chat_identifier", chatIdentifier: trimmed }; - } - - // Handle UUID/hex chat identifiers (e.g., "8b9c1a10536d4d86a336ea03ab7151cc") - if (looksLikeRawChatIdentifier(trimmed)) { - return { kind: "chat_identifier", chatIdentifier: trimmed }; + const rawChatIdentifierTarget = parseRawChatIdentifierTarget(trimmed); + if (rawChatIdentifierTarget) { + return rawChatIdentifierTarget; } return { kind: "handle", handle: normalizeBlueBubblesHandle(trimmed) };