feat: refactor BlueBubbles media handling by introducing a dedicated media send function and optimizing message processing for media attachments

This commit is contained in:
Tyler Yust
2026-01-20 01:43:54 -08:00
committed by Peter Steinberger
parent b0b42b4e14
commit c331bdc27d
4 changed files with 199 additions and 92 deletions

View File

@@ -1,6 +1,3 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { ChannelAccountSnapshot, ChannelPlugin, ClawdbotConfig } from "clawdbot/plugin-sdk";
import {
applyAccountNameToChannelSection,
@@ -25,7 +22,6 @@ import {
import { BlueBubblesConfigSchema } from "./config-schema.js";
import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
import { sendMessageBlueBubbles } from "./send.js";
import { sendBlueBubblesAttachment } from "./attachments.js";
import {
looksLikeBlueBubblesTargetId,
normalizeBlueBubblesHandle,
@@ -34,7 +30,7 @@ import {
import { bluebubblesMessageActions } from "./actions.js";
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
import { blueBubblesOnboardingAdapter } from "./onboarding.js";
import { getBlueBubblesRuntime } from "./runtime.js";
import { sendBlueBubblesMedia } from "./media-send.js";
// Use core registry meta for consistency (Gate A: core registry).
// BlueBubbles is positioned before imessage per Gate C preference.
@@ -43,37 +39,6 @@ const meta = {
order: 75,
};
const HTTP_URL_RE = /^https?:\/\//i;
function resolveLocalMediaPath(source: string): string {
if (!source.startsWith("file://")) return source;
try {
return fileURLToPath(source);
} catch {
throw new Error(`Invalid file:// URL: ${source}`);
}
}
function resolveFilenameFromSource(source?: string): string | undefined {
if (!source) return undefined;
if (source.startsWith("file://")) {
try {
return path.basename(fileURLToPath(source)) || undefined;
} catch {
return undefined;
}
}
if (HTTP_URL_RE.test(source)) {
try {
return path.basename(new URL(source).pathname) || undefined;
} catch {
return undefined;
}
}
const base = path.basename(source);
return base || undefined;
}
export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
id: "bluebubbles",
meta,
@@ -272,64 +237,17 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
filename?: string;
caption?: string;
};
const core = getBlueBubblesRuntime();
const resolvedCaption = caption ?? text;
let buffer: Uint8Array;
let resolvedContentType = contentType ?? undefined;
let resolvedFilename = filename ?? undefined;
if (mediaBuffer) {
buffer = mediaBuffer;
if (!resolvedContentType) {
const hint = mediaPath ?? mediaUrl;
const detected = await core.media.detectMime({
buffer: Buffer.isBuffer(mediaBuffer) ? mediaBuffer : Buffer.from(mediaBuffer),
filePath: hint,
});
resolvedContentType = detected ?? undefined;
}
if (!resolvedFilename) {
resolvedFilename = resolveFilenameFromSource(mediaPath ?? mediaUrl);
}
} else {
const source = mediaPath ?? mediaUrl;
if (!source) {
throw new Error("BlueBubbles media delivery requires mediaUrl, mediaPath, or mediaBuffer.");
}
if (HTTP_URL_RE.test(source)) {
const fetched = await core.channel.media.fetchRemoteMedia({ url: source });
buffer = fetched.buffer;
resolvedContentType = resolvedContentType ?? fetched.contentType ?? undefined;
resolvedFilename = resolvedFilename ?? fetched.fileName;
} else {
const localPath = resolveLocalMediaPath(source);
const fs = await import("node:fs/promises");
const data = await fs.readFile(localPath);
buffer = new Uint8Array(data);
if (!resolvedContentType) {
const detected = await core.media.detectMime({
buffer: data,
filePath: localPath,
});
resolvedContentType = detected ?? undefined;
}
if (!resolvedFilename) {
resolvedFilename = resolveFilenameFromSource(localPath);
}
}
}
const result = await sendBlueBubblesAttachment({
const result = await sendBlueBubblesMedia({
cfg: cfg as ClawdbotConfig,
to,
buffer,
filename: resolvedFilename ?? "attachment",
contentType: resolvedContentType ?? undefined,
mediaUrl,
mediaPath,
mediaBuffer,
contentType,
filename,
caption: resolvedCaption ?? undefined,
opts: {
cfg: cfg as ClawdbotConfig,
accountId: accountId ?? undefined,
},
accountId: accountId ?? undefined,
});
return { channel: "bluebubbles", ...result };

View File

@@ -0,0 +1,120 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { sendBlueBubblesAttachment } from "./attachments.js";
import { getBlueBubblesRuntime } from "./runtime.js";
const HTTP_URL_RE = /^https?:\/\//i;
function resolveLocalMediaPath(source: string): string {
if (!source.startsWith("file://")) return source;
try {
return fileURLToPath(source);
} catch {
throw new Error(`Invalid file:// URL: ${source}`);
}
}
function resolveFilenameFromSource(source?: string): string | undefined {
if (!source) return undefined;
if (source.startsWith("file://")) {
try {
return path.basename(fileURLToPath(source)) || undefined;
} catch {
return undefined;
}
}
if (HTTP_URL_RE.test(source)) {
try {
return path.basename(new URL(source).pathname) || undefined;
} catch {
return undefined;
}
}
const base = path.basename(source);
return base || undefined;
}
export async function sendBlueBubblesMedia(params: {
cfg: ClawdbotConfig;
to: string;
mediaUrl?: string;
mediaPath?: string;
mediaBuffer?: Uint8Array;
contentType?: string;
filename?: string;
caption?: string;
accountId?: string;
}) {
const {
cfg,
to,
mediaUrl,
mediaPath,
mediaBuffer,
contentType,
filename,
caption,
accountId,
} = params;
const core = getBlueBubblesRuntime();
let buffer: Uint8Array;
let resolvedContentType = contentType ?? undefined;
let resolvedFilename = filename ?? undefined;
if (mediaBuffer) {
buffer = mediaBuffer;
if (!resolvedContentType) {
const hint = mediaPath ?? mediaUrl;
const detected = await core.media.detectMime({
buffer: Buffer.isBuffer(mediaBuffer) ? mediaBuffer : Buffer.from(mediaBuffer),
filePath: hint,
});
resolvedContentType = detected ?? undefined;
}
if (!resolvedFilename) {
resolvedFilename = resolveFilenameFromSource(mediaPath ?? mediaUrl);
}
} else {
const source = mediaPath ?? mediaUrl;
if (!source) {
throw new Error("BlueBubbles media delivery requires mediaUrl, mediaPath, or mediaBuffer.");
}
if (HTTP_URL_RE.test(source)) {
const fetched = await core.channel.media.fetchRemoteMedia({ url: source });
buffer = fetched.buffer;
resolvedContentType = resolvedContentType ?? fetched.contentType ?? undefined;
resolvedFilename = resolvedFilename ?? fetched.fileName;
} else {
const localPath = resolveLocalMediaPath(source);
const fs = await import("node:fs/promises");
const data = await fs.readFile(localPath);
buffer = new Uint8Array(data);
if (!resolvedContentType) {
const detected = await core.media.detectMime({
buffer: data,
filePath: localPath,
});
resolvedContentType = detected ?? undefined;
}
if (!resolvedFilename) {
resolvedFilename = resolveFilenameFromSource(localPath);
}
}
}
return sendBlueBubblesAttachment({
to,
buffer,
filename: resolvedFilename ?? "attachment",
contentType: resolvedContentType ?? undefined,
caption: caption ?? undefined,
opts: {
cfg,
accountId,
},
});
}

View File

@@ -1081,6 +1081,45 @@ describe("BlueBubbles webhook monitor", () => {
expect(callArgs.ctx.Body).toContain("[Replying to +15550000000 id:msg-0]");
expect(callArgs.ctx.Body).toContain("original message");
});
it("falls back to threadOriginatorGuid when reply metadata is absent", async () => {
const account = createMockAccount({ dmPolicy: "open" });
const config: ClawdbotConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "replying now",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
threadOriginatorGuid: "msg-0",
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
expect(callArgs.ctx.ReplyToId).toBe("msg-0");
});
});
describe("ack reactions", () => {

View File

@@ -6,6 +6,7 @@ import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
import { downloadBlueBubblesAttachment } from "./attachments.js";
import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender, normalizeBlueBubblesHandle } from "./targets.js";
import { resolveAckReaction } from "../../../src/agents/identity.js";
import { sendBlueBubblesMedia } from "./media-send.js";
import type { BlueBubblesAccountConfig, BlueBubblesAttachment } from "./types.js";
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
import { getBlueBubblesRuntime } from "./runtime.js";
@@ -299,9 +300,15 @@ function extractReplyMetadata(message: Record<string, unknown>): {
typeof associatedType === "number" && REACTION_TYPE_MAP.has(associatedType);
const replyToId = directReplyId ?? (!isReactionAssociation ? associatedGuid : undefined);
const threadOriginatorGuid = readString(message, "threadOriginatorGuid");
const messageGuid = readString(message, "guid");
const fallbackReplyId =
!replyToId && threadOriginatorGuid && threadOriginatorGuid !== messageGuid
? threadOriginatorGuid
: undefined;
return {
replyToId: replyToId?.trim() || undefined,
replyToId: (replyToId ?? fallbackReplyId)?.trim() || undefined,
replyToBody: replyToBody?.trim() || undefined,
replyToSender: normalizedSender || undefined,
};
@@ -1351,6 +1358,29 @@ async function processMessage(
cfg: config,
dispatcherOptions: {
deliver: async (payload) => {
const mediaList = payload.mediaUrls?.length
? payload.mediaUrls
: payload.mediaUrl
? [payload.mediaUrl]
: [];
if (mediaList.length > 0) {
let first = true;
for (const mediaUrl of mediaList) {
const caption = first ? payload.text : undefined;
first = false;
await sendBlueBubblesMedia({
cfg: config,
to: outboundTarget,
mediaUrl,
caption: caption ?? undefined,
accountId: account.accountId,
});
sentMessage = true;
statusSink?.({ lastOutboundAt: Date.now() });
}
return;
}
const textLimit =
account.config.textChunkLimit && account.config.textChunkLimit > 0
? account.config.textChunkLimit