From a0935ded110b67b4c66d220b43d3c62656ba655b Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Fri, 13 Feb 2026 21:03:27 -0800 Subject: [PATCH] refactor: enhance private API handling in attachment and message sending logic - Updated `sendBlueBubblesAttachment` and `sendMessageBlueBubbles` functions to conditionally downgrade reply threading when the private API is disabled. - Introduced tests to verify the behavior of attachment sending and message processing under different private API statuses. - Refactored related tests to ensure proper mocking of the private API status, improving test reliability and coverage. --- .../bluebubbles/src/attachments.test.ts | 32 +++++++++++++ extensions/bluebubbles/src/attachments.ts | 7 +-- .../bluebubbles/src/monitor-processing.ts | 24 ++++++++-- extensions/bluebubbles/src/send.test.ts | 47 +++++++++++++++++++ extensions/bluebubbles/src/send.ts | 19 ++++---- 5 files changed, 112 insertions(+), 17 deletions(-) diff --git a/extensions/bluebubbles/src/attachments.test.ts b/extensions/bluebubbles/src/attachments.test.ts index 9bc0e4d217b..ca6f8b92aef 100644 --- a/extensions/bluebubbles/src/attachments.test.ts +++ b/extensions/bluebubbles/src/attachments.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; 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 }) => { @@ -14,12 +15,18 @@ vi.mock("./accounts.js", () => ({ }), })); +vi.mock("./probe.js", () => ({ + getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null), +})); + const mockFetch = vi.fn(); describe("downloadBlueBubblesAttachment", () => { beforeEach(() => { vi.stubGlobal("fetch", mockFetch); mockFetch.mockReset(); + vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset(); + vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null); }); afterEach(() => { @@ -242,6 +249,8 @@ describe("sendBlueBubblesAttachment", () => { beforeEach(() => { vi.stubGlobal("fetch", mockFetch); mockFetch.mockReset(); + vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset(); + vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null); }); afterEach(() => { @@ -342,4 +351,27 @@ describe("sendBlueBubblesAttachment", () => { expect(bodyText).toContain('filename="evil.mp3"'); expect(bodyText).toContain('name="evil.mp3"'); }); + + it("downgrades attachment reply threading when private API is disabled", async () => { + vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false); + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ messageId: "msg-4" })), + }); + + await sendBlueBubblesAttachment({ + to: "chat_guid:iMessage;-;+15551234567", + buffer: new Uint8Array([1, 2, 3]), + filename: "photo.jpg", + contentType: "image/jpeg", + replyToMessageGuid: "reply-guid-123", + opts: { serverUrl: "http://localhost:1234", password: "test" }, + }); + + const body = mockFetch.mock.calls[0][1]?.body as Uint8Array; + const bodyText = decodeBody(body); + expect(bodyText).not.toContain('name="method"'); + expect(bodyText).not.toContain('name="selectedMessageGuid"'); + expect(bodyText).not.toContain('name="partIndex"'); + }); }); diff --git a/extensions/bluebubbles/src/attachments.ts b/extensions/bluebubbles/src/attachments.ts index 07db6228f7a..24fe357b7c5 100644 --- a/extensions/bluebubbles/src/attachments.ts +++ b/extensions/bluebubbles/src/attachments.ts @@ -250,12 +250,7 @@ export async function sendBlueBubblesAttachment(params: { } const trimmedReplyTo = replyToMessageGuid?.trim(); - if (trimmedReplyTo) { - if (privateApiStatus === false) { - throw new Error( - "BlueBubbles attachment replies require Private API, but it is disabled on the BlueBubbles server.", - ); - } + if (trimmedReplyTo && privateApiStatus !== false) { addField("selectedMessageGuid", trimmedReplyTo); addField("partIndex", typeof replyToPartIndex === "number" ? String(replyToPartIndex) : "0"); } diff --git a/extensions/bluebubbles/src/monitor-processing.ts b/extensions/bluebubbles/src/monitor-processing.ts index 34ae8b420cb..9d1514fa8e1 100644 --- a/extensions/bluebubbles/src/monitor-processing.ts +++ b/extensions/bluebubbles/src/monitor-processing.ts @@ -32,12 +32,14 @@ import { resolveBlueBubblesMessageId, resolveReplyContextFromCache, } from "./monitor-reply-cache.js"; +import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js"; import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender } from "./targets.js"; const DEFAULT_TEXT_LIMIT = 4000; const invalidAckReactions = new Set(); +const REPLY_DIRECTIVE_TAG_RE = /\[\[\s*(?:reply_to_current|reply_to\s*:\s*[^\]\n]+)\s*\]\]/gi; export function logVerbose( core: BlueBubblesCoreRuntime, @@ -110,6 +112,7 @@ export async function processMessage( target: WebhookTarget, ): Promise { const { account, config, runtime, core, statusSink } = target; + const privateApiEnabled = getCachedBlueBubblesPrivateApiStatus(account.accountId) !== false; const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid); const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup; @@ -639,6 +642,15 @@ export async function processMessage( contextKey: `bluebubbles:outbound:${outboundTarget}:${trimmed}`, }); }; + const sanitizeReplyDirectiveText = (value: string): string => { + if (privateApiEnabled) { + return value; + } + return value + .replace(REPLY_DIRECTIVE_TAG_RE, " ") + .replace(/[ \t]+/g, " ") + .trim(); + }; const ctxPayload = { Body: body, @@ -721,7 +733,9 @@ export async function processMessage( ...prefixOptions, deliver: async (payload, info) => { const rawReplyToId = - typeof payload.replyToId === "string" ? payload.replyToId.trim() : ""; + privateApiEnabled && typeof payload.replyToId === "string" + ? payload.replyToId.trim() + : ""; // Resolve short ID (e.g., "5") to full UUID const replyToMessageGuid = rawReplyToId ? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true }) @@ -737,7 +751,9 @@ export async function processMessage( channel: "bluebubbles", accountId: account.accountId, }); - const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); + const text = sanitizeReplyDirectiveText( + core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode), + ); let first = true; for (const mediaUrl of mediaList) { const caption = first ? text : undefined; @@ -771,7 +787,9 @@ export async function processMessage( channel: "bluebubbles", accountId: account.accountId, }); - const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); + const text = sanitizeReplyDirectiveText( + core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode), + ); const chunks = chunkMode === "newline" ? core.channel.text.chunkTextWithMode(text, textLimit, chunkMode) diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts index c10266068fc..88b1631ce93 100644 --- a/extensions/bluebubbles/src/send.test.ts +++ b/extensions/bluebubbles/src/send.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import type { BlueBubblesSendTarget } from "./types.js"; +import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; import { sendMessageBlueBubbles, resolveChatGuidForTarget } from "./send.js"; vi.mock("./accounts.js", () => ({ @@ -14,12 +15,18 @@ vi.mock("./accounts.js", () => ({ }), })); +vi.mock("./probe.js", () => ({ + getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null), +})); + const mockFetch = vi.fn(); describe("send", () => { beforeEach(() => { vi.stubGlobal("fetch", mockFetch); mockFetch.mockReset(); + vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset(); + vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null); }); afterEach(() => { @@ -611,6 +618,46 @@ describe("send", () => { expect(body.partIndex).toBe(1); }); + 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" }, + }), + ), + }); + + const result = await sendMessageBlueBubbles("+15551234567", "Reply fallback", { + serverUrl: "http://localhost:1234", + password: "test", + replyToMessageGuid: "reply-guid-123", + replyToPartIndex: 1, + }); + + expect(result.messageId).toBe("msg-uuid-plain"); + const sendCall = mockFetch.mock.calls[1]; + const body = JSON.parse(sendCall[1].body); + expect(body.method).toBeUndefined(); + expect(body.selectedMessageGuid).toBeUndefined(); + expect(body.partIndex).toBeUndefined(); + }); + it("normalizes effect names and uses private-api for effects", async () => { mockFetch .mockResolvedValueOnce({ diff --git a/extensions/bluebubbles/src/send.ts b/extensions/bluebubbles/src/send.ts index 32be2825d67..eaa85a67898 100644 --- a/extensions/bluebubbles/src/send.ts +++ b/extensions/bluebubbles/src/send.ts @@ -424,23 +424,26 @@ export async function sendMessageBlueBubbles( ); } const effectId = resolveEffectId(opts.effectId); - const needsPrivateApi = Boolean(opts.replyToMessageGuid || effectId); + const wantsReplyThread = Boolean(opts.replyToMessageGuid?.trim()); + const wantsEffect = Boolean(effectId); + const needsPrivateApi = wantsReplyThread || wantsEffect; + const canUsePrivateApi = needsPrivateApi && privateApiStatus !== false; + if (wantsEffect && privateApiStatus === false) { + throw new Error( + "BlueBubbles send failed: reply/effect requires Private API, but it is disabled on the BlueBubbles server.", + ); + } const payload: Record = { chatGuid, tempGuid: crypto.randomUUID(), message: strippedText, }; - if (needsPrivateApi) { - if (privateApiStatus === false) { - throw new Error( - "BlueBubbles send failed: reply/effect requires Private API, but it is disabled on the BlueBubbles server.", - ); - } + if (canUsePrivateApi) { payload.method = "private-api"; } // Add reply threading support - if (opts.replyToMessageGuid) { + if (wantsReplyThread && canUsePrivateApi) { payload.selectedMessageGuid = opts.replyToMessageGuid; payload.partIndex = typeof opts.replyToPartIndex === "number" ? opts.replyToPartIndex : 0; }