feat: add support for setting group icons in BlueBubbles, enhancing group management capabilities

This commit is contained in:
Tyler Yust
2026-01-20 00:34:59 -08:00
committed by Peter Steinberger
parent 574b848863
commit 14a072f5fa
18 changed files with 684 additions and 102 deletions

View File

@@ -28,6 +28,7 @@ vi.mock("./chat.js", () => ({
editBlueBubblesMessage: vi.fn().mockResolvedValue(undefined),
unsendBlueBubblesMessage: vi.fn().mockResolvedValue(undefined),
renameBlueBubblesChat: vi.fn().mockResolvedValue(undefined),
setGroupIconBlueBubbles: vi.fn().mockResolvedValue(undefined),
addBlueBubblesParticipant: vi.fn().mockResolvedValue(undefined),
removeBlueBubblesParticipant: vi.fn().mockResolvedValue(undefined),
leaveBlueBubblesChat: vi.fn().mockResolvedValue(undefined),
@@ -103,6 +104,7 @@ describe("bluebubblesMessageActions", () => {
expect(bluebubblesMessageActions.supportsAction({ action: "reply" })).toBe(true);
expect(bluebubblesMessageActions.supportsAction({ action: "sendWithEffect" })).toBe(true);
expect(bluebubblesMessageActions.supportsAction({ action: "renameGroup" })).toBe(true);
expect(bluebubblesMessageActions.supportsAction({ action: "setGroupIcon" })).toBe(true);
expect(bluebubblesMessageActions.supportsAction({ action: "addParticipant" })).toBe(true);
expect(bluebubblesMessageActions.supportsAction({ action: "removeParticipant" })).toBe(true);
expect(bluebubblesMessageActions.supportsAction({ action: "leaveGroup" })).toBe(true);
@@ -414,5 +416,96 @@ describe("bluebubblesMessageActions", () => {
details: { ok: true, messageId: "msg-123", effect: "invisible ink" },
});
});
it("throws when buffer is missing for setGroupIcon", async () => {
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
await expect(
bluebubblesMessageActions.handleAction({
action: "setGroupIcon",
params: { chatGuid: "iMessage;-;chat-guid" },
cfg,
accountId: null,
}),
).rejects.toThrow(/requires an image/i);
});
it("sets group icon successfully with chatGuid and buffer", async () => {
const { setGroupIconBlueBubbles } = await import("./chat.js");
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
// Base64 encode a simple test buffer
const testBuffer = Buffer.from("fake-image-data");
const base64Buffer = testBuffer.toString("base64");
const result = await bluebubblesMessageActions.handleAction({
action: "setGroupIcon",
params: {
chatGuid: "iMessage;-;chat-guid",
buffer: base64Buffer,
filename: "group-icon.png",
contentType: "image/png",
},
cfg,
accountId: null,
});
expect(setGroupIconBlueBubbles).toHaveBeenCalledWith(
"iMessage;-;chat-guid",
expect.any(Uint8Array),
"group-icon.png",
expect.objectContaining({ contentType: "image/png" }),
);
expect(result).toMatchObject({
details: { ok: true, chatGuid: "iMessage;-;chat-guid", iconSet: true },
});
});
it("uses default filename when not provided for setGroupIcon", async () => {
const { setGroupIconBlueBubbles } = await import("./chat.js");
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
const base64Buffer = Buffer.from("test").toString("base64");
await bluebubblesMessageActions.handleAction({
action: "setGroupIcon",
params: {
chatGuid: "iMessage;-;chat-guid",
buffer: base64Buffer,
},
cfg,
accountId: null,
});
expect(setGroupIconBlueBubbles).toHaveBeenCalledWith(
"iMessage;-;chat-guid",
expect.any(Uint8Array),
"icon.png",
expect.anything(),
);
});
});
});

View File

@@ -18,6 +18,7 @@ import {
editBlueBubblesMessage,
unsendBlueBubblesMessage,
renameBlueBubblesChat,
setGroupIconBlueBubbles,
addBlueBubblesParticipant,
removeBlueBubblesParticipant,
leaveBlueBubblesChat,
@@ -54,6 +55,7 @@ const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>([
"reply",
"sendWithEffect",
"renameGroup",
"setGroupIcon",
"addParticipant",
"removeParticipant",
"leaveGroup",
@@ -72,6 +74,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
if (gate("reply")) actions.add("reply");
if (gate("sendWithEffect")) actions.add("sendWithEffect");
if (gate("renameGroup")) actions.add("renameGroup");
if (gate("setGroupIcon")) actions.add("setGroupIcon");
if (gate("addParticipant")) actions.add("addParticipant");
if (gate("removeParticipant")) actions.add("removeParticipant");
if (gate("leaveGroup")) actions.add("leaveGroup");
@@ -275,6 +278,35 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
return jsonResult({ ok: true, renamed: resolvedChatGuid, displayName });
}
// Handle setGroupIcon action
if (action === "setGroupIcon") {
const resolvedChatGuid = await resolveChatGuid();
const base64Buffer = readStringParam(params, "buffer");
const filename =
readStringParam(params, "filename") ??
readStringParam(params, "name") ??
"icon.png";
const contentType =
readStringParam(params, "contentType") ?? readStringParam(params, "mimeType");
if (!base64Buffer) {
throw new Error(
"BlueBubbles setGroupIcon requires an image. " +
"Use action=setGroupIcon with media=<image_url> or path=<local_file_path> to set the group icon.",
);
}
// Decode base64 to buffer
const buffer = Uint8Array.from(atob(base64Buffer), (c) => c.charCodeAt(0));
await setGroupIconBlueBubbles(resolvedChatGuid, buffer, filename, {
...opts,
contentType: contentType ?? undefined,
});
return jsonResult({ ok: true, chatGuid: resolvedChatGuid, iconSet: true });
}
// Handle addParticipant action
if (action === "addParticipant") {
const resolvedChatGuid = await resolveChatGuid();

View File

@@ -1,6 +1,6 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
import { markBlueBubblesChatRead, sendBlueBubblesTyping, setGroupIconBlueBubbles } from "./chat.js";
vi.mock("./accounts.js", () => ({
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
@@ -315,4 +315,148 @@ describe("chat", () => {
expect(mockFetch.mock.calls[1][1].method).toBe("DELETE");
});
});
describe("setGroupIconBlueBubbles", () => {
it("throws when chatGuid is empty", async () => {
await expect(
setGroupIconBlueBubbles("", new Uint8Array([1, 2, 3]), "icon.png", {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("chatGuid");
});
it("throws when buffer is empty", async () => {
await expect(
setGroupIconBlueBubbles("chat-guid", new Uint8Array(0), "icon.png", {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("image buffer");
});
it("throws when serverUrl is missing", async () => {
await expect(
setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", {}),
).rejects.toThrow("serverUrl is required");
});
it("throws when password is missing", async () => {
await expect(
setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", {
serverUrl: "http://localhost:1234",
}),
).rejects.toThrow("password is required");
});
it("sets group icon successfully", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); // PNG magic bytes
await setGroupIconBlueBubbles("iMessage;-;chat-guid", buffer, "icon.png", {
serverUrl: "http://localhost:1234",
password: "test-password",
contentType: "image/png",
});
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/chat/iMessage%3B-%3Bchat-guid/icon"),
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
"Content-Type": expect.stringContaining("multipart/form-data"),
}),
}),
);
});
it("includes password in URL query", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", {
serverUrl: "http://localhost:1234",
password: "my-secret",
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("password=my-secret");
});
it("throws on non-ok response", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
text: () => Promise.resolve("Internal error"),
});
await expect(
setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("setGroupIcon failed (500): Internal error");
});
it("trims chatGuid before using", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await setGroupIconBlueBubbles(" chat-with-spaces ", new Uint8Array([1]), "icon.png", {
serverUrl: "http://localhost:1234",
password: "test",
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("/api/v1/chat/chat-with-spaces/icon");
expect(calledUrl).not.toContain("%20chat");
});
it("resolves credentials from config", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", {
cfg: {
channels: {
bluebubbles: {
serverUrl: "http://config-server:9999",
password: "config-pass",
},
},
},
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("config-server:9999");
expect(calledUrl).toContain("password=config-pass");
});
it("includes filename in multipart body", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "custom-icon.jpg", {
serverUrl: "http://localhost:1234",
password: "test",
contentType: "image/jpeg",
});
const body = mockFetch.mock.calls[0][1].body as Uint8Array;
const bodyString = new TextDecoder().decode(body);
expect(bodyString).toContain('filename="custom-icon.jpg"');
expect(bodyString).toContain("image/jpeg");
});
});
});

View File

@@ -1,3 +1,4 @@
import crypto from "node:crypto";
import { resolveBlueBubblesAccount } from "./accounts.js";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
@@ -280,3 +281,74 @@ export async function leaveBlueBubblesChat(
throw new Error(`BlueBubbles leaveChat failed (${res.status}): ${errorText || "unknown"}`);
}
}
/**
* Set a group chat's icon/photo via BlueBubbles API.
* Requires Private API to be enabled.
*/
export async function setGroupIconBlueBubbles(
chatGuid: string,
buffer: Uint8Array,
filename: string,
opts: BlueBubblesChatOpts & { contentType?: string } = {},
): Promise<void> {
const trimmedGuid = chatGuid.trim();
if (!trimmedGuid) throw new Error("BlueBubbles setGroupIcon requires chatGuid");
if (!buffer || buffer.length === 0) {
throw new Error("BlueBubbles setGroupIcon requires image buffer");
}
const { baseUrl, password } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/icon`,
password,
});
// Build multipart form-data
const boundary = `----BlueBubblesFormBoundary${crypto.randomUUID().replace(/-/g, "")}`;
const parts: Uint8Array[] = [];
const encoder = new TextEncoder();
// Add file field named "icon" as per API spec
parts.push(encoder.encode(`--${boundary}\r\n`));
parts.push(
encoder.encode(
`Content-Disposition: form-data; name="icon"; filename="${filename}"\r\n`,
),
);
parts.push(
encoder.encode(`Content-Type: ${opts.contentType ?? "application/octet-stream"}\r\n\r\n`),
);
parts.push(buffer);
parts.push(encoder.encode("\r\n"));
// Close multipart body
parts.push(encoder.encode(`--${boundary}--\r\n`));
// Combine into single buffer
const totalLength = parts.reduce((acc, part) => acc + part.length, 0);
const body = new Uint8Array(totalLength);
let offset = 0;
for (const part of parts) {
body.set(part, offset);
offset += part.length;
}
const res = await blueBubblesFetchWithTimeout(
url,
{
method: "POST",
headers: {
"Content-Type": `multipart/form-data; boundary=${boundary}`,
},
body,
},
opts.timeoutMs ?? 60_000, // longer timeout for file uploads
);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`BlueBubbles setGroupIcon failed (${res.status}): ${errorText || "unknown"}`);
}
}

View File

@@ -4,16 +4,17 @@ const allowFromEntry = z.union([z.string(), z.number()]);
const bluebubblesActionSchema = z
.object({
reactions: z.boolean().optional(),
edit: z.boolean().optional(),
unsend: z.boolean().optional(),
reply: z.boolean().optional(),
sendWithEffect: z.boolean().optional(),
renameGroup: z.boolean().optional(),
addParticipant: z.boolean().optional(),
removeParticipant: z.boolean().optional(),
leaveGroup: z.boolean().optional(),
sendAttachment: z.boolean().optional(),
reactions: z.boolean().default(true),
edit: z.boolean().default(true),
unsend: z.boolean().default(true),
reply: z.boolean().default(true),
sendWithEffect: z.boolean().default(true),
renameGroup: z.boolean().default(true),
setGroupIcon: z.boolean().default(true),
addParticipant: z.boolean().default(true),
removeParticipant: z.boolean().default(true),
leaveGroup: z.boolean().default(true),
sendAttachment: z.boolean().default(true),
})
.optional();

View File

@@ -1,6 +1,11 @@
import { describe, expect, it } from "vitest";
import { looksLikeBlueBubblesTargetId, normalizeBlueBubblesMessagingTarget } from "./targets.js";
import {
looksLikeBlueBubblesTargetId,
normalizeBlueBubblesMessagingTarget,
parseBlueBubblesTarget,
parseBlueBubblesAllowTarget,
} from "./targets.js";
describe("normalizeBlueBubblesMessagingTarget", () => {
it("normalizes chat_guid targets", () => {
@@ -37,6 +42,21 @@ describe("normalizeBlueBubblesMessagingTarget", () => {
"chat_guid:iMessage;+;chat123456789",
);
});
it("normalizes raw chat_guid values", () => {
expect(normalizeBlueBubblesMessagingTarget("iMessage;+;chat660250192681427962")).toBe(
"chat_guid:iMessage;+;chat660250192681427962",
);
expect(normalizeBlueBubblesMessagingTarget("iMessage;-;+19257864429")).toBe("+19257864429");
});
it("normalizes chat<digits> pattern to chat_id format", () => {
expect(normalizeBlueBubblesMessagingTarget("chat660250192681427962")).toBe(
"chat_id:660250192681427962",
);
expect(normalizeBlueBubblesMessagingTarget("chat123")).toBe("chat_id:123");
expect(normalizeBlueBubblesMessagingTarget("Chat456789")).toBe("chat_id:456789");
});
});
describe("looksLikeBlueBubblesTargetId", () => {
@@ -52,7 +72,68 @@ describe("looksLikeBlueBubblesTargetId", () => {
expect(looksLikeBlueBubblesTargetId("+1 (555) 123-4567")).toBe(true);
});
it("accepts raw chat_guid values", () => {
expect(looksLikeBlueBubblesTargetId("iMessage;+;chat660250192681427962")).toBe(true);
});
it("accepts chat<digits> pattern as chat_id", () => {
expect(looksLikeBlueBubblesTargetId("chat660250192681427962")).toBe(true);
expect(looksLikeBlueBubblesTargetId("chat123")).toBe(true);
expect(looksLikeBlueBubblesTargetId("Chat456789")).toBe(true);
});
it("rejects display names", () => {
expect(looksLikeBlueBubblesTargetId("Jane Doe")).toBe(false);
});
});
describe("parseBlueBubblesTarget", () => {
it("parses chat<digits> pattern as chat_id", () => {
expect(parseBlueBubblesTarget("chat660250192681427962")).toEqual({
kind: "chat_id",
chatId: 660250192681427962,
});
expect(parseBlueBubblesTarget("chat123")).toEqual({ kind: "chat_id", chatId: 123 });
expect(parseBlueBubblesTarget("Chat456789")).toEqual({ kind: "chat_id", chatId: 456789 });
});
it("parses explicit chat_id: prefix", () => {
expect(parseBlueBubblesTarget("chat_id:123")).toEqual({ kind: "chat_id", chatId: 123 });
});
it("parses phone numbers as handles", () => {
expect(parseBlueBubblesTarget("+19257864429")).toEqual({
kind: "handle",
to: "+19257864429",
service: "auto",
});
});
it("parses raw chat_guid format", () => {
expect(parseBlueBubblesTarget("iMessage;+;chat660250192681427962")).toEqual({
kind: "chat_guid",
chatGuid: "iMessage;+;chat660250192681427962",
});
});
});
describe("parseBlueBubblesAllowTarget", () => {
it("parses chat<digits> pattern as chat_id", () => {
expect(parseBlueBubblesAllowTarget("chat660250192681427962")).toEqual({
kind: "chat_id",
chatId: 660250192681427962,
});
expect(parseBlueBubblesAllowTarget("chat123")).toEqual({ kind: "chat_id", chatId: 123 });
});
it("parses explicit chat_id: prefix", () => {
expect(parseBlueBubblesAllowTarget("chat_id:456")).toEqual({ kind: "chat_id", chatId: 456 });
});
it("parses phone numbers as handles", () => {
expect(parseBlueBubblesAllowTarget("+19257864429")).toEqual({
kind: "handle",
handle: "+19257864429",
});
});
});

View File

@@ -21,6 +21,19 @@ const SERVICE_PREFIXES: Array<{ prefix: string; service: BlueBubblesService }> =
{ prefix: "auto:", service: "auto" },
];
function parseRawChatGuid(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
const parts = trimmed.split(";");
if (parts.length !== 3) return null;
const service = parts[0]?.trim();
const separator = parts[1]?.trim();
const identifier = parts[2]?.trim();
if (!service || !identifier) return null;
if (separator !== "+" && separator !== "-") return null;
return `${service};${separator};${identifier}`;
}
function stripPrefix(value: string, prefix: string): string {
return value.slice(prefix.length).trim();
}
@@ -88,6 +101,7 @@ export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string):
if (!trimmed) return false;
const candidate = stripBlueBubblesPrefix(trimmed);
if (!candidate) return false;
if (parseRawChatGuid(candidate)) return true;
const lowered = candidate.toLowerCase();
if (/^(imessage|sms|auto):/.test(lowered)) return true;
if (
@@ -97,6 +111,8 @@ export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string):
) {
return true;
}
// Recognize chat<digits> patterns (e.g., "chat660250192681427962") as chat IDs
if (/^chat\d+$/i.test(candidate)) return true;
if (candidate.includes("@")) return true;
const digitsOnly = candidate.replace(/[\s().-]/g, "");
if (/^\+?\d{3,}$/.test(digitsOnly)) return true;
@@ -115,7 +131,7 @@ export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string):
}
export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
const trimmed = raw.trim();
const trimmed = stripBlueBubblesPrefix(raw);
if (!trimmed) throw new Error("BlueBubbles target is required");
const lower = trimmed.toLowerCase();
@@ -173,6 +189,17 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
return { kind: "chat_guid", chatGuid: value };
}
const rawChatGuid = parseRawChatGuid(trimmed);
if (rawChatGuid) {
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 };
}
return { kind: "handle", to: trimmed, service: "auto" };
}
@@ -218,6 +245,13 @@ export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget
if (value) return { kind: "chat_guid", chatGuid: value };
}
// Handle chat<digits> pattern (e.g., "chat660250192681427962") as chat_id
const chatDigitsMatch = /^chat(\d+)$/i.exec(trimmed);
if (chatDigitsMatch) {
const chatId = Number.parseInt(chatDigitsMatch[1], 10);
if (Number.isFinite(chatId)) return { kind: "chat_id", chatId };
}
return { kind: "handle", handle: normalizeBlueBubblesHandle(trimmed) };
}