(Step 2) Phase 2 & 3 Complete + Reviewed

This commit is contained in:
Tyler Yust
2026-01-19 18:23:00 -08:00
committed by Peter Steinberger
parent ac2fcfe96a
commit e9d691d472
12 changed files with 2591 additions and 49 deletions

View File

@@ -0,0 +1,359 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { bluebubblesMessageActions } from "./actions.js";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
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("./reactions.js", () => ({
sendBlueBubblesReaction: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("./send.js", () => ({
resolveChatGuidForTarget: vi.fn().mockResolvedValue("iMessage;-;+15551234567"),
sendMessageBlueBubbles: vi.fn().mockResolvedValue({ messageId: "msg-123" }),
}));
vi.mock("./chat.js", () => ({
editBlueBubblesMessage: vi.fn().mockResolvedValue(undefined),
unsendBlueBubblesMessage: vi.fn().mockResolvedValue(undefined),
renameBlueBubblesChat: vi.fn().mockResolvedValue(undefined),
addBlueBubblesParticipant: vi.fn().mockResolvedValue(undefined),
removeBlueBubblesParticipant: vi.fn().mockResolvedValue(undefined),
leaveBlueBubblesChat: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("./attachments.js", () => ({
sendBlueBubblesAttachment: vi.fn().mockResolvedValue({ messageId: "att-msg-123" }),
}));
describe("bluebubblesMessageActions", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("listActions", () => {
it("returns empty array when account is not enabled", () => {
const cfg: ClawdbotConfig = {
channels: { bluebubbles: { enabled: false } },
};
const actions = bluebubblesMessageActions.listActions({ cfg });
expect(actions).toEqual([]);
});
it("returns empty array when account is not configured", () => {
const cfg: ClawdbotConfig = {
channels: { bluebubbles: { enabled: true } },
};
const actions = bluebubblesMessageActions.listActions({ cfg });
expect(actions).toEqual([]);
});
it("returns react action when enabled and configured", () => {
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
enabled: true,
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
const actions = bluebubblesMessageActions.listActions({ cfg });
expect(actions).toContain("react");
});
it("excludes react action when reactions are gated off", () => {
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
enabled: true,
serverUrl: "http://localhost:1234",
password: "test-password",
actions: { reactions: false },
},
},
};
const actions = bluebubblesMessageActions.listActions({ cfg });
expect(actions).not.toContain("react");
// Other actions should still be present
expect(actions).toContain("edit");
expect(actions).toContain("unsend");
});
});
describe("supportsAction", () => {
it("returns true for react action", () => {
expect(bluebubblesMessageActions.supportsAction({ action: "react" })).toBe(true);
});
it("returns true for all supported actions", () => {
expect(bluebubblesMessageActions.supportsAction({ action: "edit" })).toBe(true);
expect(bluebubblesMessageActions.supportsAction({ action: "unsend" })).toBe(true);
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: "addParticipant" })).toBe(true);
expect(bluebubblesMessageActions.supportsAction({ action: "removeParticipant" })).toBe(true);
expect(bluebubblesMessageActions.supportsAction({ action: "leaveGroup" })).toBe(true);
expect(bluebubblesMessageActions.supportsAction({ action: "sendAttachment" })).toBe(true);
});
it("returns false for unsupported actions", () => {
expect(bluebubblesMessageActions.supportsAction({ action: "delete" })).toBe(false);
expect(bluebubblesMessageActions.supportsAction({ action: "unknown" })).toBe(false);
});
});
describe("extractToolSend", () => {
it("extracts send params from sendMessage action", () => {
const result = bluebubblesMessageActions.extractToolSend({
args: {
action: "sendMessage",
to: "+15551234567",
accountId: "test-account",
},
});
expect(result).toEqual({
to: "+15551234567",
accountId: "test-account",
});
});
it("returns null for non-sendMessage action", () => {
const result = bluebubblesMessageActions.extractToolSend({
args: { action: "react", to: "+15551234567" },
});
expect(result).toBeNull();
});
it("returns null when to is missing", () => {
const result = bluebubblesMessageActions.extractToolSend({
args: { action: "sendMessage" },
});
expect(result).toBeNull();
});
});
describe("handleAction", () => {
it("throws for unsupported actions", async () => {
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
await expect(
bluebubblesMessageActions.handleAction({
action: "unknownAction",
params: {},
cfg,
accountId: null,
}),
).rejects.toThrow("is not supported");
});
it("throws when emoji is missing for react action", async () => {
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
await expect(
bluebubblesMessageActions.handleAction({
action: "react",
params: { messageId: "msg-123" },
cfg,
accountId: null,
}),
).rejects.toThrow(/emoji/i);
});
it("throws when messageId is missing", async () => {
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
await expect(
bluebubblesMessageActions.handleAction({
action: "react",
params: { emoji: "❤️" },
cfg,
accountId: null,
}),
).rejects.toThrow("messageId");
});
it("throws when chatGuid cannot be resolved", async () => {
const { resolveChatGuidForTarget } = await import("./send.js");
vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce(null);
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
await expect(
bluebubblesMessageActions.handleAction({
action: "react",
params: { emoji: "❤️", messageId: "msg-123", to: "+15551234567" },
cfg,
accountId: null,
}),
).rejects.toThrow("chatGuid not found");
});
it("sends reaction successfully with chatGuid", async () => {
const { sendBlueBubblesReaction } = await import("./reactions.js");
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
const result = await bluebubblesMessageActions.handleAction({
action: "react",
params: {
emoji: "❤️",
messageId: "msg-123",
chatGuid: "iMessage;-;+15551234567",
},
cfg,
accountId: null,
});
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
expect.objectContaining({
chatGuid: "iMessage;-;+15551234567",
messageGuid: "msg-123",
emoji: "❤️",
}),
);
// jsonResult returns { content: [...], details: payload }
expect(result).toMatchObject({
details: { ok: true, added: "❤️" },
});
});
it("sends reaction removal successfully", async () => {
const { sendBlueBubblesReaction } = await import("./reactions.js");
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
const result = await bluebubblesMessageActions.handleAction({
action: "react",
params: {
emoji: "❤️",
messageId: "msg-123",
chatGuid: "iMessage;-;+15551234567",
remove: true,
},
cfg,
accountId: null,
});
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
expect.objectContaining({
remove: true,
}),
);
// jsonResult returns { content: [...], details: payload }
expect(result).toMatchObject({
details: { ok: true, removed: true },
});
});
it("resolves chatGuid from to parameter", async () => {
const { sendBlueBubblesReaction } = await import("./reactions.js");
const { resolveChatGuidForTarget } = await import("./send.js");
vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce("iMessage;-;+15559876543");
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
await bluebubblesMessageActions.handleAction({
action: "react",
params: {
emoji: "👍",
messageId: "msg-456",
to: "+15559876543",
},
cfg,
accountId: null,
});
expect(resolveChatGuidForTarget).toHaveBeenCalled();
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
expect.objectContaining({
chatGuid: "iMessage;-;+15559876543",
}),
);
});
it("passes partIndex when provided", async () => {
const { sendBlueBubblesReaction } = await import("./reactions.js");
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
await bluebubblesMessageActions.handleAction({
action: "react",
params: {
emoji: "😂",
messageId: "msg-789",
chatGuid: "iMessage;-;chat-guid",
partIndex: 2,
},
cfg,
accountId: null,
});
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
expect.objectContaining({
partIndex: 2,
}),
);
});
});
});

View File

@@ -1,6 +1,7 @@
import {
createActionGate,
jsonResult,
readBooleanParam,
readNumberParam,
readReactionParams,
readStringParam,
@@ -12,7 +13,16 @@ import {
import { resolveBlueBubblesAccount } from "./accounts.js";
import { sendBlueBubblesReaction } from "./reactions.js";
import { resolveChatGuidForTarget } from "./send.js";
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
import {
editBlueBubblesMessage,
unsendBlueBubblesMessage,
renameBlueBubblesChat,
addBlueBubblesParticipant,
removeBlueBubblesParticipant,
leaveBlueBubblesChat,
} from "./chat.js";
import { sendBlueBubblesAttachment } from "./attachments.js";
import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
import type { BlueBubblesSendTarget } from "./types.js";
@@ -32,6 +42,20 @@ function mapTarget(raw: string): BlueBubblesSendTarget {
};
}
/** Supported action names for BlueBubbles */
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>([
"react",
"edit",
"unsend",
"reply",
"sendWithEffect",
"renameGroup",
"addParticipant",
"removeParticipant",
"leaveGroup",
"sendAttachment",
]);
export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
const account = resolveBlueBubblesAccount({ cfg: cfg as ClawdbotConfig });
@@ -39,9 +63,18 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
const gate = createActionGate((cfg as ClawdbotConfig).channels?.bluebubbles?.actions);
const actions = new Set<ChannelMessageActionName>();
if (gate("reactions")) actions.add("react");
if (gate("edit")) actions.add("edit");
if (gate("unsend")) actions.add("unsend");
if (gate("reply")) actions.add("reply");
if (gate("sendWithEffect")) actions.add("sendWithEffect");
if (gate("renameGroup")) actions.add("renameGroup");
if (gate("addParticipant")) actions.add("addParticipant");
if (gate("removeParticipant")) actions.add("removeParticipant");
if (gate("leaveGroup")) actions.add("leaveGroup");
if (gate("sendAttachment")) actions.add("sendAttachment");
return Array.from(actions);
},
supportsAction: ({ action }) => action === "react",
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
extractToolSend: ({ args }): ChannelToolSend | null => {
const action = typeof args.action === "string" ? args.action.trim() : "";
if (action !== "sendMessage") return null;
@@ -51,31 +84,23 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
return { to, accountId };
},
handleAction: async ({ action, params, cfg, accountId }) => {
if (action !== "react") {
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
}
const { emoji, remove, isEmpty } = readReactionParams(params, {
removeErrorMessage: "Emoji is required to remove a BlueBubbles reaction.",
});
if (isEmpty && !remove) {
throw new Error("Emoji is required to send a BlueBubbles reaction.");
}
const messageId = readStringParam(params, "messageId", { required: true });
const chatGuid = readStringParam(params, "chatGuid");
const chatIdentifier = readStringParam(params, "chatIdentifier");
const chatId = readNumberParam(params, "chatId", { integer: true });
const to = readStringParam(params, "to");
const partIndex = readNumberParam(params, "partIndex", { integer: true });
const account = resolveBlueBubblesAccount({
cfg: cfg as ClawdbotConfig,
accountId: accountId ?? undefined,
});
const baseUrl = account.config.serverUrl?.trim();
const password = account.config.password?.trim();
const opts = { cfg: cfg as ClawdbotConfig, accountId: accountId ?? undefined };
// Helper to resolve chatGuid from various params
const resolveChatGuid = async (): Promise<string> => {
const chatGuid = readStringParam(params, "chatGuid");
if (chatGuid?.trim()) return chatGuid.trim();
const chatIdentifier = readStringParam(params, "chatIdentifier");
const chatId = readNumberParam(params, "chatId", { integer: true });
const to = readStringParam(params, "to");
let resolvedChatGuid = chatGuid?.trim() || "";
if (!resolvedChatGuid) {
const target =
chatIdentifier?.trim()
? ({ kind: "chat_identifier", chatIdentifier: chatIdentifier.trim() } as BlueBubblesSendTarget)
@@ -84,38 +109,192 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
: to
? mapTarget(to)
: null;
if (!target) {
throw new Error("BlueBubbles reaction requires chatGuid, chatIdentifier, chatId, or to.");
throw new Error(`BlueBubbles ${action} requires chatGuid, chatIdentifier, chatId, or to.`);
}
if (!baseUrl || !password) {
throw new Error("BlueBubbles reaction requires serverUrl and password.");
throw new Error(`BlueBubbles ${action} requires serverUrl and password.`);
}
resolvedChatGuid =
(await resolveChatGuidForTarget({
baseUrl,
password,
target,
})) ?? "";
}
if (!resolvedChatGuid) {
throw new Error("BlueBubbles reaction failed: chatGuid not found for target.");
const resolved = await resolveChatGuidForTarget({ baseUrl, password, target });
if (!resolved) {
throw new Error(`BlueBubbles ${action} failed: chatGuid not found for target.`);
}
return resolved;
};
// Handle react action
if (action === "react") {
const { emoji, remove, isEmpty } = readReactionParams(params, {
removeErrorMessage: "Emoji is required to remove a BlueBubbles reaction.",
});
if (isEmpty && !remove) {
throw new Error("Emoji is required to send a BlueBubbles reaction.");
}
const messageId = readStringParam(params, "messageId", { required: true });
const partIndex = readNumberParam(params, "partIndex", { integer: true });
const resolvedChatGuid = await resolveChatGuid();
await sendBlueBubblesReaction({
chatGuid: resolvedChatGuid,
messageGuid: messageId,
emoji,
remove: remove || undefined,
partIndex: typeof partIndex === "number" ? partIndex : undefined,
opts,
});
return jsonResult({ ok: true, ...(remove ? { removed: true } : { added: emoji }) });
}
await sendBlueBubblesReaction({
chatGuid: resolvedChatGuid,
messageGuid: messageId,
emoji,
remove: remove || undefined,
partIndex: typeof partIndex === "number" ? partIndex : undefined,
opts: {
cfg: cfg as ClawdbotConfig,
accountId: accountId ?? undefined,
},
});
// Handle edit action
if (action === "edit") {
const messageId = readStringParam(params, "messageId", { required: true });
const newText = readStringParam(params, "text") ?? readStringParam(params, "newText");
if (!newText) {
throw new Error("BlueBubbles edit requires text or newText parameter.");
}
const partIndex = readNumberParam(params, "partIndex", { integer: true });
const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage");
if (!remove) {
return jsonResult({ ok: true, added: emoji });
await editBlueBubblesMessage(messageId, newText, {
...opts,
partIndex: typeof partIndex === "number" ? partIndex : undefined,
backwardsCompatMessage: backwardsCompatMessage ?? undefined,
});
return jsonResult({ ok: true, edited: messageId });
}
return jsonResult({ ok: true, removed: true });
// Handle unsend action
if (action === "unsend") {
const messageId = readStringParam(params, "messageId", { required: true });
const partIndex = readNumberParam(params, "partIndex", { integer: true });
await unsendBlueBubblesMessage(messageId, {
...opts,
partIndex: typeof partIndex === "number" ? partIndex : undefined,
});
return jsonResult({ ok: true, unsent: messageId });
}
// Handle reply action
if (action === "reply") {
const messageId = readStringParam(params, "messageId", { required: true });
const text = readStringParam(params, "text", { required: true });
const to = readStringParam(params, "to", { required: true });
const partIndex = readNumberParam(params, "partIndex", { integer: true });
const result = await sendMessageBlueBubbles(to, text, {
...opts,
replyToMessageGuid: messageId,
replyToPartIndex: typeof partIndex === "number" ? partIndex : undefined,
});
return jsonResult({ ok: true, messageId: result.messageId, repliedTo: messageId });
}
// Handle sendWithEffect action
if (action === "sendWithEffect") {
const text = readStringParam(params, "text", { required: true });
const to = readStringParam(params, "to", { required: true });
const effectId = readStringParam(params, "effectId") ?? readStringParam(params, "effect");
if (!effectId) {
throw new Error("BlueBubbles sendWithEffect requires effectId or effect parameter.");
}
const result = await sendMessageBlueBubbles(to, text, {
...opts,
effectId,
});
return jsonResult({ ok: true, messageId: result.messageId, effect: effectId });
}
// Handle renameGroup action
if (action === "renameGroup") {
const resolvedChatGuid = await resolveChatGuid();
const displayName = readStringParam(params, "displayName") ?? readStringParam(params, "name");
if (!displayName) {
throw new Error("BlueBubbles renameGroup requires displayName or name parameter.");
}
await renameBlueBubblesChat(resolvedChatGuid, displayName, opts);
return jsonResult({ ok: true, renamed: resolvedChatGuid, displayName });
}
// Handle addParticipant action
if (action === "addParticipant") {
const resolvedChatGuid = await resolveChatGuid();
const address = readStringParam(params, "address") ?? readStringParam(params, "participant");
if (!address) {
throw new Error("BlueBubbles addParticipant requires address or participant parameter.");
}
await addBlueBubblesParticipant(resolvedChatGuid, address, opts);
return jsonResult({ ok: true, added: address, chatGuid: resolvedChatGuid });
}
// Handle removeParticipant action
if (action === "removeParticipant") {
const resolvedChatGuid = await resolveChatGuid();
const address = readStringParam(params, "address") ?? readStringParam(params, "participant");
if (!address) {
throw new Error("BlueBubbles removeParticipant requires address or participant parameter.");
}
await removeBlueBubblesParticipant(resolvedChatGuid, address, opts);
return jsonResult({ ok: true, removed: address, chatGuid: resolvedChatGuid });
}
// Handle leaveGroup action
if (action === "leaveGroup") {
const resolvedChatGuid = await resolveChatGuid();
await leaveBlueBubblesChat(resolvedChatGuid, opts);
return jsonResult({ ok: true, left: resolvedChatGuid });
}
// Handle sendAttachment action
if (action === "sendAttachment") {
const to = readStringParam(params, "to", { required: true });
const filename = readStringParam(params, "filename", { required: true });
const caption = readStringParam(params, "caption");
const contentType = readStringParam(params, "contentType") ?? readStringParam(params, "mimeType");
// Buffer can come from params.buffer (base64) or params.path (file path)
const base64Buffer = readStringParam(params, "buffer");
const filePath = readStringParam(params, "path") ?? readStringParam(params, "filePath");
let buffer: Uint8Array;
if (base64Buffer) {
// Decode base64 to buffer
buffer = Uint8Array.from(atob(base64Buffer), (c) => c.charCodeAt(0));
} else if (filePath) {
// Read file from path (will be handled by caller providing buffer)
throw new Error("BlueBubbles sendAttachment: filePath not supported in action, provide buffer as base64.");
} else {
throw new Error("BlueBubbles sendAttachment requires buffer (base64) parameter.");
}
const result = await sendBlueBubblesAttachment({
to,
buffer,
filename,
contentType: contentType ?? undefined,
caption: caption ?? undefined,
opts,
});
return jsonResult({ ok: true, messageId: result.messageId });
}
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
},
};

View File

@@ -0,0 +1,240 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { downloadBlueBubblesAttachment } from "./attachments.js";
import type { BlueBubblesAttachment } from "./types.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,
};
}),
}));
const mockFetch = vi.fn();
describe("downloadBlueBubblesAttachment", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("throws when guid is missing", async () => {
const attachment: BlueBubblesAttachment = {};
await expect(
downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test-password",
}),
).rejects.toThrow("guid is required");
});
it("throws when guid is empty string", async () => {
const attachment: BlueBubblesAttachment = { guid: " " };
await expect(
downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test-password",
}),
).rejects.toThrow("guid is required");
});
it("throws when serverUrl is missing", async () => {
const attachment: BlueBubblesAttachment = { guid: "att-123" };
await expect(downloadBlueBubblesAttachment(attachment, {})).rejects.toThrow(
"serverUrl is required",
);
});
it("throws when password is missing", async () => {
const attachment: BlueBubblesAttachment = { guid: "att-123" };
await expect(
downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
}),
).rejects.toThrow("password is required");
});
it("downloads attachment successfully", async () => {
const mockBuffer = new Uint8Array([1, 2, 3, 4]);
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers({ "content-type": "image/png" }),
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
});
const attachment: BlueBubblesAttachment = { guid: "att-123" };
const result = await downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test-password",
});
expect(result.buffer).toEqual(mockBuffer);
expect(result.contentType).toBe("image/png");
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/attachment/att-123/download"),
expect.objectContaining({ method: "GET" }),
);
});
it("includes password in URL query", async () => {
const mockBuffer = new Uint8Array([1, 2, 3, 4]);
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers({ "content-type": "image/jpeg" }),
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
});
const attachment: BlueBubblesAttachment = { guid: "att-456" };
await downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "my-secret-password",
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("password=my-secret-password");
});
it("encodes guid in URL", async () => {
const mockBuffer = new Uint8Array([1]);
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers(),
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
});
const attachment: BlueBubblesAttachment = { guid: "att/with/special chars" };
await downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test",
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("att%2Fwith%2Fspecial%20chars");
});
it("throws on non-ok response", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
text: () => Promise.resolve("Attachment not found"),
});
const attachment: BlueBubblesAttachment = { guid: "att-missing" };
await expect(
downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("download failed (404): Attachment not found");
});
it("throws when attachment exceeds max bytes", async () => {
const largeBuffer = new Uint8Array(10 * 1024 * 1024);
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers(),
arrayBuffer: () => Promise.resolve(largeBuffer.buffer),
});
const attachment: BlueBubblesAttachment = { guid: "att-large" };
await expect(
downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test",
maxBytes: 5 * 1024 * 1024,
}),
).rejects.toThrow("too large");
});
it("uses default max bytes when not specified", async () => {
const largeBuffer = new Uint8Array(9 * 1024 * 1024);
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers(),
arrayBuffer: () => Promise.resolve(largeBuffer.buffer),
});
const attachment: BlueBubblesAttachment = { guid: "att-large" };
await expect(
downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("too large");
});
it("uses attachment mimeType as fallback when response has no content-type", async () => {
const mockBuffer = new Uint8Array([1, 2, 3]);
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers(),
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
});
const attachment: BlueBubblesAttachment = {
guid: "att-789",
mimeType: "video/mp4",
};
const result = await downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(result.contentType).toBe("video/mp4");
});
it("prefers response content-type over attachment mimeType", async () => {
const mockBuffer = new Uint8Array([1, 2, 3]);
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers({ "content-type": "image/webp" }),
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
});
const attachment: BlueBubblesAttachment = {
guid: "att-xyz",
mimeType: "image/png",
};
const result = await downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(result.contentType).toBe("image/webp");
});
it("resolves credentials from config when opts not provided", async () => {
const mockBuffer = new Uint8Array([1]);
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers(),
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
});
const attachment: BlueBubblesAttachment = { guid: "att-config" };
const result = await downloadBlueBubblesAttachment(attachment, {
cfg: {
channels: {
bluebubbles: {
serverUrl: "http://config-server:5678",
password: "config-password",
},
},
},
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("config-server:5678");
expect(calledUrl).toContain("password=config-password");
expect(result.buffer).toEqual(new Uint8Array([1]));
});
});

View File

@@ -1,9 +1,13 @@
import crypto from "node:crypto";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { resolveChatGuidForTarget } from "./send.js";
import { parseBlueBubblesTarget, normalizeBlueBubblesHandle } from "./targets.js";
import {
blueBubblesFetchWithTimeout,
buildBlueBubblesApiUrl,
type BlueBubblesAttachment,
type BlueBubblesSendTarget,
} from "./types.js";
export type BlueBubblesAttachmentOpts = {
@@ -55,3 +59,154 @@ export async function downloadBlueBubblesAttachment(
}
return { buffer: buf, contentType: contentType ?? attachment.mimeType ?? undefined };
}
export type SendBlueBubblesAttachmentResult = {
messageId: string;
};
function resolveSendTarget(raw: string): BlueBubblesSendTarget {
const parsed = parseBlueBubblesTarget(raw);
if (parsed.kind === "handle") {
return {
kind: "handle",
address: normalizeBlueBubblesHandle(parsed.to),
service: parsed.service,
};
}
if (parsed.kind === "chat_id") {
return { kind: "chat_id", chatId: parsed.chatId };
}
if (parsed.kind === "chat_guid") {
return { kind: "chat_guid", chatGuid: parsed.chatGuid };
}
return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier };
}
function extractMessageId(payload: unknown): string {
if (!payload || typeof payload !== "object") return "unknown";
const record = payload as Record<string, unknown>;
const data = record.data && typeof record.data === "object" ? (record.data as Record<string, unknown>) : null;
const candidates = [
record.messageId,
record.guid,
record.id,
data?.messageId,
data?.guid,
data?.id,
];
for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
if (typeof candidate === "number" && Number.isFinite(candidate)) return String(candidate);
}
return "unknown";
}
/**
* Send an attachment via BlueBubbles API.
* Supports sending media files (images, videos, audio, documents) to a chat.
*/
export async function sendBlueBubblesAttachment(params: {
to: string;
buffer: Uint8Array;
filename: string;
contentType?: string;
caption?: string;
opts?: BlueBubblesAttachmentOpts;
}): Promise<SendBlueBubblesAttachmentResult> {
const { to, buffer, filename, contentType, caption, opts = {} } = params;
const { baseUrl, password } = resolveAccount(opts);
const target = resolveSendTarget(to);
const chatGuid = await resolveChatGuidForTarget({
baseUrl,
password,
timeoutMs: opts.timeoutMs,
target,
});
if (!chatGuid) {
throw new Error(
"BlueBubbles attachment send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.",
);
}
const url = buildBlueBubblesApiUrl({
baseUrl,
path: "/api/v1/message/attachment",
password,
});
// Build FormData with the attachment
const boundary = `----BlueBubblesFormBoundary${crypto.randomUUID().replace(/-/g, "")}`;
const parts: Uint8Array[] = [];
const encoder = new TextEncoder();
// Helper to add a form field
const addField = (name: string, value: string) => {
parts.push(encoder.encode(`--${boundary}\r\n`));
parts.push(encoder.encode(`Content-Disposition: form-data; name="${name}"\r\n\r\n`));
parts.push(encoder.encode(`${value}\r\n`));
};
// Helper to add a file field
const addFile = (name: string, fileBuffer: Uint8Array, fileName: string, mimeType?: string) => {
parts.push(encoder.encode(`--${boundary}\r\n`));
parts.push(
encoder.encode(
`Content-Disposition: form-data; name="${name}"; filename="${fileName}"\r\n`,
),
);
parts.push(encoder.encode(`Content-Type: ${mimeType ?? "application/octet-stream"}\r\n\r\n`));
parts.push(fileBuffer);
parts.push(encoder.encode("\r\n"));
};
// Add required fields
addFile("attachment", buffer, filename, contentType);
addField("chatGuid", chatGuid);
addField("name", filename);
addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`);
addField("method", "private-api");
// Add optional caption
if (caption) {
addField("message", caption);
}
// Close the multipart body
parts.push(encoder.encode(`--${boundary}--\r\n`));
// Combine all parts into a 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();
throw new Error(`BlueBubbles attachment send failed (${res.status}): ${errorText || "unknown"}`);
}
const responseBody = await res.text();
if (!responseBody) return { messageId: "ok" };
try {
const parsed = JSON.parse(responseBody) as unknown;
return { messageId: extractMessageId(parsed) };
} catch {
return { messageId: "ok" };
}
}

View File

@@ -20,6 +20,7 @@ import {
import { BlueBubblesConfigSchema } from "./config-schema.js";
import { probeBlueBubbles } from "./probe.js";
import { sendMessageBlueBubbles } from "./send.js";
import { sendBlueBubblesAttachment } from "./attachments.js";
import { normalizeBlueBubblesHandle } from "./targets.js";
import { bluebubblesMessageActions } from "./actions.js";
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
@@ -40,8 +41,13 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
meta,
capabilities: {
chatTypes: ["direct", "group"],
media: false,
media: true,
reactions: true,
edit: true,
unsend: true,
reply: true,
effects: true,
groupManagement: true,
},
reload: { configPrefixes: ["channels.bluebubbles"] },
configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema),
@@ -210,8 +216,34 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
});
return { channel: "bluebubbles", ...result };
},
sendMedia: async () => {
throw new Error("BlueBubbles media delivery is not supported yet.");
sendMedia: async ({ cfg, to, mediaPath, mediaBuffer, contentType, filename, caption, accountId }) => {
// Prefer buffer if provided, otherwise read from path
let buffer: Uint8Array;
if (mediaBuffer) {
buffer = mediaBuffer;
} else if (mediaPath) {
const fs = await import("node:fs/promises");
buffer = new Uint8Array(await fs.readFile(mediaPath));
} else {
throw new Error("BlueBubbles media delivery requires mediaPath or mediaBuffer.");
}
// Resolve filename from path if not provided
const resolvedFilename = filename ?? (mediaPath ? mediaPath.split("/").pop() ?? "attachment" : "attachment");
const result = await sendBlueBubblesAttachment({
to,
buffer,
filename: resolvedFilename,
contentType: contentType ?? undefined,
caption: caption ?? undefined,
opts: {
cfg: cfg as ClawdbotConfig,
accountId: accountId ?? undefined,
},
});
return { channel: "bluebubbles", ...result };
},
},
status: {

View File

@@ -0,0 +1,318 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.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,
};
}),
}));
const mockFetch = vi.fn();
describe("chat", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("markBlueBubblesChatRead", () => {
it("does nothing when chatGuid is empty", async () => {
await markBlueBubblesChatRead("", {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(mockFetch).not.toHaveBeenCalled();
});
it("does nothing when chatGuid is whitespace", async () => {
await markBlueBubblesChatRead(" ", {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(mockFetch).not.toHaveBeenCalled();
});
it("throws when serverUrl is missing", async () => {
await expect(markBlueBubblesChatRead("chat-guid", {})).rejects.toThrow(
"serverUrl is required",
);
});
it("throws when password is missing", async () => {
await expect(
markBlueBubblesChatRead("chat-guid", {
serverUrl: "http://localhost:1234",
}),
).rejects.toThrow("password is required");
});
it("marks chat as read successfully", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await markBlueBubblesChatRead("iMessage;-;+15551234567", {
serverUrl: "http://localhost:1234",
password: "test-password",
});
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/chat/iMessage%3B-%3B%2B15551234567/read"),
expect.objectContaining({ method: "POST" }),
);
});
it("includes password in URL query", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await markBlueBubblesChatRead("chat-123", {
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: 404,
text: () => Promise.resolve("Chat not found"),
});
await expect(
markBlueBubblesChatRead("missing-chat", {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("read failed (404): Chat not found");
});
it("trims chatGuid before using", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await markBlueBubblesChatRead(" chat-with-spaces ", {
serverUrl: "http://localhost:1234",
password: "test",
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("/api/v1/chat/chat-with-spaces/read");
expect(calledUrl).not.toContain("%20chat");
});
it("resolves credentials from config", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await markBlueBubblesChatRead("chat-123", {
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");
});
});
describe("sendBlueBubblesTyping", () => {
it("does nothing when chatGuid is empty", async () => {
await sendBlueBubblesTyping("", true, {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(mockFetch).not.toHaveBeenCalled();
});
it("does nothing when chatGuid is whitespace", async () => {
await sendBlueBubblesTyping(" ", false, {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(mockFetch).not.toHaveBeenCalled();
});
it("throws when serverUrl is missing", async () => {
await expect(sendBlueBubblesTyping("chat-guid", true, {})).rejects.toThrow(
"serverUrl is required",
);
});
it("throws when password is missing", async () => {
await expect(
sendBlueBubblesTyping("chat-guid", true, {
serverUrl: "http://localhost:1234",
}),
).rejects.toThrow("password is required");
});
it("sends typing start with POST method", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesTyping("iMessage;-;+15551234567", true, {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/chat/iMessage%3B-%3B%2B15551234567/typing"),
expect.objectContaining({ method: "POST" }),
);
});
it("sends typing stop with DELETE method", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesTyping("iMessage;-;+15551234567", false, {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/chat/iMessage%3B-%3B%2B15551234567/typing"),
expect.objectContaining({ method: "DELETE" }),
);
});
it("includes password in URL query", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesTyping("chat-123", true, {
serverUrl: "http://localhost:1234",
password: "typing-secret",
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("password=typing-secret");
});
it("throws on non-ok response", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
text: () => Promise.resolve("Internal error"),
});
await expect(
sendBlueBubblesTyping("chat-123", true, {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("typing failed (500): Internal error");
});
it("trims chatGuid before using", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesTyping(" trimmed-chat ", true, {
serverUrl: "http://localhost:1234",
password: "test",
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("/api/v1/chat/trimmed-chat/typing");
});
it("encodes special characters in chatGuid", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesTyping("iMessage;+;group@chat.com", true, {
serverUrl: "http://localhost:1234",
password: "test",
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("iMessage%3B%2B%3Bgroup%40chat.com");
});
it("resolves credentials from config", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesTyping("chat-123", true, {
cfg: {
channels: {
bluebubbles: {
serverUrl: "http://typing-server:8888",
password: "typing-pass",
},
},
},
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("typing-server:8888");
expect(calledUrl).toContain("password=typing-pass");
});
it("can start and stop typing in sequence", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
})
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesTyping("chat-123", true, {
serverUrl: "http://localhost:1234",
password: "test",
});
await sendBlueBubblesTyping("chat-123", false, {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch.mock.calls[0][1].method).toBe("POST");
expect(mockFetch.mock.calls[1][1].method).toBe("DELETE");
});
});
});

View File

@@ -64,3 +64,219 @@ export async function sendBlueBubblesTyping(
throw new Error(`BlueBubbles typing failed (${res.status}): ${errorText || "unknown"}`);
}
}
/**
* Edit a message via BlueBubbles API.
* Requires macOS 13 (Ventura) or higher with Private API enabled.
*/
export async function editBlueBubblesMessage(
messageGuid: string,
newText: string,
opts: BlueBubblesChatOpts & { partIndex?: number; backwardsCompatMessage?: string } = {},
): Promise<void> {
const trimmedGuid = messageGuid.trim();
if (!trimmedGuid) throw new Error("BlueBubbles edit requires messageGuid");
const trimmedText = newText.trim();
if (!trimmedText) throw new Error("BlueBubbles edit requires newText");
const { baseUrl, password } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({
baseUrl,
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),
},
opts.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`BlueBubbles edit failed (${res.status}): ${errorText || "unknown"}`);
}
}
/**
* Unsend (retract) a message via BlueBubbles API.
* Requires macOS 13 (Ventura) or higher with Private API enabled.
*/
export async function unsendBlueBubblesMessage(
messageGuid: string,
opts: BlueBubblesChatOpts & { partIndex?: number } = {},
): Promise<void> {
const trimmedGuid = messageGuid.trim();
if (!trimmedGuid) throw new Error("BlueBubbles unsend requires messageGuid");
const { baseUrl, password } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/unsend`,
password,
});
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"}`);
}
}
/**
* Rename a group chat via BlueBubbles API.
*/
export async function renameBlueBubblesChat(
chatGuid: string,
displayName: string,
opts: BlueBubblesChatOpts = {},
): Promise<void> {
const trimmedGuid = chatGuid.trim();
if (!trimmedGuid) throw new Error("BlueBubbles rename requires chatGuid");
const { baseUrl, password } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}`,
password,
});
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"}`);
}
}
/**
* Add a participant to a group chat via BlueBubbles API.
*/
export async function addBlueBubblesParticipant(
chatGuid: string,
address: string,
opts: BlueBubblesChatOpts = {},
): Promise<void> {
const trimmedGuid = chatGuid.trim();
if (!trimmedGuid) throw new Error("BlueBubbles addParticipant requires chatGuid");
const trimmedAddress = address.trim();
if (!trimmedAddress) throw new Error("BlueBubbles addParticipant requires address");
const { baseUrl, password } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`,
password,
});
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"}`);
}
}
/**
* Remove a participant from a group chat via BlueBubbles API.
*/
export async function removeBlueBubblesParticipant(
chatGuid: string,
address: string,
opts: BlueBubblesChatOpts = {},
): Promise<void> {
const trimmedGuid = chatGuid.trim();
if (!trimmedGuid) throw new Error("BlueBubbles removeParticipant requires chatGuid");
const trimmedAddress = address.trim();
if (!trimmedAddress) throw new Error("BlueBubbles removeParticipant requires address");
const { baseUrl, password } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`,
password,
});
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"}`);
}
}
/**
* Leave a group chat via BlueBubbles API.
*/
export async function leaveBlueBubblesChat(
chatGuid: string,
opts: BlueBubblesChatOpts = {},
): Promise<void> {
const trimmedGuid = chatGuid.trim();
if (!trimmedGuid) throw new Error("BlueBubbles leaveChat requires chatGuid");
const { baseUrl, password } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({
baseUrl,
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

@@ -5,6 +5,15 @@ 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(),
})
.optional();

View File

@@ -0,0 +1,393 @@
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,
};
}),
}));
const mockFetch = vi.fn();
describe("reactions", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("sendBlueBubblesReaction", () => {
it("throws when chatGuid is empty", async () => {
await expect(
sendBlueBubblesReaction({
chatGuid: "",
messageGuid: "msg-123",
emoji: "love",
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
}),
).rejects.toThrow("chatGuid");
});
it("throws when messageGuid is empty", async () => {
await expect(
sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "",
emoji: "love",
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
}),
).rejects.toThrow("messageGuid");
});
it("throws when emoji is empty", async () => {
await expect(
sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "",
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
}),
).rejects.toThrow("emoji or name");
});
it("throws when serverUrl is missing", async () => {
await expect(
sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "love",
opts: {},
}),
).rejects.toThrow("serverUrl is required");
});
it("throws when password is missing", async () => {
await expect(
sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "love",
opts: {
serverUrl: "http://localhost:1234",
},
}),
).rejects.toThrow("password is required");
});
it("throws for unsupported reaction type", async () => {
await expect(
sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "unsupported",
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
}),
).rejects.toThrow("Unsupported BlueBubbles reaction");
});
describe("reaction type normalization", () => {
const testCases = [
{ input: "love", expected: "love" },
{ input: "like", expected: "like" },
{ input: "dislike", expected: "dislike" },
{ input: "laugh", expected: "laugh" },
{ input: "emphasize", expected: "emphasize" },
{ input: "question", expected: "question" },
{ input: "heart", expected: "love" },
{ input: "thumbs_up", expected: "like" },
{ input: "thumbs-down", expected: "dislike" },
{ input: "thumbs_down", expected: "dislike" },
{ input: "haha", expected: "laugh" },
{ input: "lol", expected: "laugh" },
{ input: "emphasis", expected: "emphasize" },
{ input: "exclaim", expected: "emphasize" },
{ input: "❤️", expected: "love" },
{ input: "❤", expected: "love" },
{ input: "♥️", expected: "love" },
{ input: "😍", expected: "love" },
{ input: "👍", expected: "like" },
{ input: "👎", expected: "dislike" },
{ input: "😂", expected: "laugh" },
{ input: "🤣", expected: "laugh" },
{ input: "😆", expected: "laugh" },
{ input: "‼️", expected: "emphasize" },
{ input: "‼", expected: "emphasize" },
{ input: "❗", expected: "emphasize" },
{ input: "❓", expected: "question" },
{ input: "❔", expected: "question" },
{ input: "LOVE", expected: "love" },
{ input: "Like", expected: "like" },
];
for (const { input, expected } of testCases) {
it(`normalizes "${input}" to "${expected}"`, async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: input,
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.reaction).toBe(expected);
});
}
});
it("sends reaction successfully", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "iMessage;-;+15551234567",
messageGuid: "msg-uuid-123",
emoji: "love",
opts: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
});
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/message/react"),
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
}),
);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.chatGuid).toBe("iMessage;-;+15551234567");
expect(body.selectedMessageGuid).toBe("msg-uuid-123");
expect(body.reaction).toBe("love");
expect(body.partIndex).toBe(0);
});
it("includes password in URL query", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "like",
opts: {
serverUrl: "http://localhost:1234",
password: "my-react-password",
},
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("password=my-react-password");
});
it("sends reaction removal with dash prefix", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "love",
remove: true,
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.reaction).toBe("-love");
});
it("strips leading dash from emoji when remove flag is set", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "-love",
remove: true,
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.reaction).toBe("-love");
});
it("uses custom partIndex when provided", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "laugh",
partIndex: 3,
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.partIndex).toBe(3);
});
it("throws on non-ok response", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 400,
text: () => Promise.resolve("Invalid reaction type"),
});
await expect(
sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "like",
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
}),
).rejects.toThrow("reaction failed (400): Invalid reaction type");
});
it("resolves credentials from config", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "emphasize",
opts: {
cfg: {
channels: {
bluebubbles: {
serverUrl: "http://react-server:7777",
password: "react-pass",
},
},
},
},
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("react-server:7777");
expect(calledUrl).toContain("password=react-pass");
});
it("trims chatGuid and messageGuid", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: " chat-with-spaces ",
messageGuid: " msg-with-spaces ",
emoji: "question",
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.chatGuid).toBe("chat-with-spaces");
expect(body.selectedMessageGuid).toBe("msg-with-spaces");
});
describe("reaction removal aliases", () => {
it("handles emoji-based removal", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "👍",
remove: true,
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.reaction).toBe("-like");
});
it("handles text alias removal", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "haha",
remove: true,
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.reaction).toBe("-laugh");
});
});
});
});

View File

@@ -0,0 +1,587 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { sendMessageBlueBubbles, resolveChatGuidForTarget } from "./send.js";
import type { BlueBubblesSendTarget } from "./types.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,
};
}),
}));
const mockFetch = vi.fn();
describe("send", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("resolveChatGuidForTarget", () => {
it("returns chatGuid directly for chat_guid target", async () => {
const target: BlueBubblesSendTarget = {
kind: "chat_guid",
chatGuid: "iMessage;-;+15551234567",
};
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
expect(result).toBe("iMessage;-;+15551234567");
expect(mockFetch).not.toHaveBeenCalled();
});
it("queries chats to resolve chat_id target", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{ id: 123, guid: "iMessage;-;chat123", participants: [] },
{ id: 456, guid: "iMessage;-;chat456", participants: [] },
],
}),
});
const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 456 };
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
expect(result).toBe("iMessage;-;chat456");
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/chat/query"),
expect.objectContaining({ method: "POST" }),
);
});
it("queries chats to resolve chat_identifier target", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
identifier: "chat123@group.imessage",
guid: "iMessage;-;chat123",
participants: [],
},
],
}),
});
const target: BlueBubblesSendTarget = {
kind: "chat_identifier",
chatIdentifier: "chat123@group.imessage",
};
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
expect(result).toBe("iMessage;-;chat123");
});
it("resolves handle target by matching participant", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15559999999",
participants: [{ address: "+15559999999" }],
},
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
});
const target: BlueBubblesSendTarget = {
kind: "handle",
address: "+15551234567",
service: "imessage",
};
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
expect(result).toBe("iMessage;-;+15551234567");
});
it("returns null when chat not found", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [] }),
});
const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 999 };
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
expect(result).toBeNull();
});
it("handles API error gracefully", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
});
const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 123 };
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
expect(result).toBeNull();
});
it("paginates through chats to find match", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: Array(500)
.fill(null)
.map((_, i) => ({
id: i,
guid: `chat-${i}`,
participants: [],
})),
}),
})
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [{ id: 555, guid: "found-chat", participants: [] }],
}),
});
const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 555 };
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
expect(result).toBe("found-chat");
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("normalizes handle addresses for matching", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;test@example.com",
participants: [{ address: "Test@Example.COM" }],
},
],
}),
});
const target: BlueBubblesSendTarget = {
kind: "handle",
address: "test@example.com",
service: "auto",
};
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
expect(result).toBe("iMessage;-;test@example.com");
});
it("extracts guid from various response formats", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
chatGuid: "format1-guid",
id: 100,
participants: [],
},
],
}),
});
const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 100 };
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
expect(result).toBe("format1-guid");
});
});
describe("sendMessageBlueBubbles", () => {
beforeEach(() => {
mockFetch.mockReset();
});
it("throws when text is empty", async () => {
await expect(
sendMessageBlueBubbles("+15551234567", "", {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("requires text");
});
it("throws when text is whitespace only", async () => {
await expect(
sendMessageBlueBubbles("+15551234567", " ", {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("requires text");
});
it("throws when serverUrl is missing", async () => {
await expect(sendMessageBlueBubbles("+15551234567", "Hello", {})).rejects.toThrow(
"serverUrl is required",
);
});
it("throws when password is missing", async () => {
await expect(
sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
}),
).rejects.toThrow("password is required");
});
it("throws when chatGuid cannot be resolved", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: [] }),
});
await expect(
sendMessageBlueBubbles("+15559999999", "Hello", {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("chatGuid not found");
});
it("sends message successfully", async () => {
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-123" },
}),
),
});
const result = await sendMessageBlueBubbles("+15551234567", "Hello world!", {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(result.messageId).toBe("msg-uuid-123");
expect(mockFetch).toHaveBeenCalledTimes(2);
const sendCall = mockFetch.mock.calls[1];
expect(sendCall[0]).toContain("/api/v1/message/text");
const body = JSON.parse(sendCall[1].body);
expect(body.chatGuid).toBe("iMessage;-;+15551234567");
expect(body.message).toBe("Hello world!");
expect(body.method).toBeUndefined();
});
it("uses private-api when reply metadata is present", async () => {
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-124" },
}),
),
});
const result = await sendMessageBlueBubbles("+15551234567", "Replying", {
serverUrl: "http://localhost:1234",
password: "test",
replyToMessageGuid: "reply-guid-123",
replyToPartIndex: 1,
});
expect(result.messageId).toBe("msg-uuid-124");
expect(mockFetch).toHaveBeenCalledTimes(2);
const sendCall = mockFetch.mock.calls[1];
const body = JSON.parse(sendCall[1].body);
expect(body.method).toBe("private-api");
expect(body.selectedMessageGuid).toBe("reply-guid-123");
expect(body.partIndex).toBe(1);
});
it("sends message with chat_guid target directly", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { messageId: "direct-msg-123" },
}),
),
});
const result = await sendMessageBlueBubbles(
"chat_guid:iMessage;-;direct-chat",
"Direct message",
{
serverUrl: "http://localhost:1234",
password: "test",
},
);
expect(result.messageId).toBe("direct-msg-123");
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("handles send failure", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: false,
status: 500,
text: () => Promise.resolve("Internal server error"),
});
await expect(
sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("send failed (500)");
});
it("handles empty response body", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(result.messageId).toBe("ok");
});
it("handles invalid JSON response body", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve("not valid json"),
});
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(result.messageId).toBe("ok");
});
it("extracts messageId from various response formats", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
id: "numeric-id-456",
}),
),
});
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(result.messageId).toBe("numeric-id-456");
});
it("resolves credentials from config", async () => {
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-123" } })),
});
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
cfg: {
channels: {
bluebubbles: {
serverUrl: "http://config-server:5678",
password: "config-pass",
},
},
},
});
expect(result.messageId).toBe("msg-123");
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("config-server:5678");
});
it("includes tempGuid in request payload", async () => {
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" } })),
});
await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
password: "test",
});
const sendCall = mockFetch.mock.calls[1];
const body = JSON.parse(sendCall[1].body);
expect(body.tempGuid).toBeDefined();
expect(typeof body.tempGuid).toBe("string");
expect(body.tempGuid.length).toBeGreaterThan(0);
});
});
});

View File

@@ -15,12 +15,42 @@ export type BlueBubblesSendOpts = {
accountId?: string;
timeoutMs?: number;
cfg?: ClawdbotConfig;
/** Message GUID to reply to (reply threading) */
replyToMessageGuid?: string;
/** Part index for reply (default: 0) */
replyToPartIndex?: number;
/** Effect ID or short name for message effects (e.g., "slam", "balloons") */
effectId?: string;
};
export type BlueBubblesSendResult = {
messageId: string;
};
/** Maps short effect names to full Apple effect IDs */
const EFFECT_MAP: Record<string, string> = {
// Bubble effects
slam: "com.apple.MobileSMS.expressivesend.impact",
loud: "com.apple.MobileSMS.expressivesend.loud",
gentle: "com.apple.MobileSMS.expressivesend.gentle",
invisible: "com.apple.MobileSMS.expressivesend.invisibleink",
// Screen effects
echo: "com.apple.messages.effect.CKEchoEffect",
spotlight: "com.apple.messages.effect.CKSpotlightEffect",
balloons: "com.apple.messages.effect.CKHappyBirthdayEffect",
confetti: "com.apple.messages.effect.CKConfettiEffect",
love: "com.apple.messages.effect.CKHeartEffect",
lasers: "com.apple.messages.effect.CKLasersEffect",
fireworks: "com.apple.messages.effect.CKFireworksEffect",
celebration: "com.apple.messages.effect.CKSparklesEffect",
};
function resolveEffectId(raw?: string): string | undefined {
if (!raw) return undefined;
const trimmed = raw.trim().toLowerCase();
return EFFECT_MAP[trimmed] ?? raw;
}
function resolveSendTarget(raw: string): BlueBubblesSendTarget {
const parsed = parseBlueBubblesTarget(raw);
if (parsed.kind === "handle") {
@@ -227,12 +257,27 @@ export async function sendMessageBlueBubbles(
"BlueBubbles send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.",
);
}
const effectId = resolveEffectId(opts.effectId);
const needsPrivateApi = Boolean(opts.replyToMessageGuid || effectId);
const payload: Record<string, unknown> = {
chatGuid,
tempGuid: crypto.randomUUID(),
message: trimmedText,
method: "apple-script",
};
if (needsPrivateApi) {
payload.method = "private-api";
}
// Add reply threading support
if (opts.replyToMessageGuid) {
payload.selectedMessageGuid = opts.replyToMessageGuid;
payload.partIndex = typeof opts.replyToPartIndex === "number" ? opts.replyToPartIndex : 0;
}
// Add message effects support
if (effectId) {
payload.effectId = effectId;
}
const url = buildBlueBubblesApiUrl({
baseUrl,

View File

@@ -49,6 +49,15 @@ export type BlueBubblesAccountConfig = {
export type BlueBubblesActionConfig = {
reactions?: boolean;
edit?: boolean;
unsend?: boolean;
reply?: boolean;
sendWithEffect?: boolean;
renameGroup?: boolean;
addParticipant?: boolean;
removeParticipant?: boolean;
leaveGroup?: boolean;
sendAttachment?: boolean;
};
export type BlueBubblesConfig = {