From c331bdc27d08d822c2b8e8dbe5d0c330177bc6fe Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Tue, 20 Jan 2026 01:43:54 -0800 Subject: [PATCH] feat: refactor BlueBubbles media handling by introducing a dedicated media send function and optimizing message processing for media attachments --- extensions/bluebubbles/src/channel.ts | 100 ++--------------- extensions/bluebubbles/src/media-send.ts | 120 +++++++++++++++++++++ extensions/bluebubbles/src/monitor.test.ts | 39 +++++++ extensions/bluebubbles/src/monitor.ts | 32 +++++- 4 files changed, 199 insertions(+), 92 deletions(-) create mode 100644 extensions/bluebubbles/src/media-send.ts diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index 25290d2a2..5b09793cd 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -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 = { id: "bluebubbles", meta, @@ -272,64 +237,17 @@ export const bluebubblesPlugin: ChannelPlugin = { 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 }; diff --git a/extensions/bluebubbles/src/media-send.ts b/extensions/bluebubbles/src/media-send.ts new file mode 100644 index 000000000..025590237 --- /dev/null +++ b/extensions/bluebubbles/src/media-send.ts @@ -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, + }, + }); +} diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index 03fdc4efc..f8a8b1eae 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -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", () => { diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index 05b28139f..b13781c47 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -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): { 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