fix(bluebubbles): tighten chat target handling

This commit is contained in:
Peter Steinberger
2026-02-22 11:27:54 +00:00
parent ad404c9626
commit 5056f4e142
5 changed files with 321 additions and 184 deletions

View File

@@ -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),

View File

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

View File

@@ -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<void> {
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"}`);
}
}
/**

View File

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

View File

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