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.
This commit is contained in:
Tyler Yust
2026-02-13 21:03:27 -08:00
parent b11d3516e9
commit a0935ded11
5 changed files with 112 additions and 17 deletions

View File

@@ -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"');
});
});

View File

@@ -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");
}

View File

@@ -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<string>();
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<void> {
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)

View File

@@ -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({

View File

@@ -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<string, unknown> = {
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;
}