feat: refactor BlueBubbles media handling by introducing a dedicated media send function and optimizing message processing for media attachments
This commit is contained in:
committed by
Peter Steinberger
parent
b0b42b4e14
commit
c331bdc27d
@@ -1,6 +1,3 @@
|
|||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
import type { ChannelAccountSnapshot, ChannelPlugin, ClawdbotConfig } from "clawdbot/plugin-sdk";
|
import type { ChannelAccountSnapshot, ChannelPlugin, ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
import {
|
import {
|
||||||
applyAccountNameToChannelSection,
|
applyAccountNameToChannelSection,
|
||||||
@@ -25,7 +22,6 @@ import {
|
|||||||
import { BlueBubblesConfigSchema } from "./config-schema.js";
|
import { BlueBubblesConfigSchema } from "./config-schema.js";
|
||||||
import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
|
import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
|
||||||
import { sendMessageBlueBubbles } from "./send.js";
|
import { sendMessageBlueBubbles } from "./send.js";
|
||||||
import { sendBlueBubblesAttachment } from "./attachments.js";
|
|
||||||
import {
|
import {
|
||||||
looksLikeBlueBubblesTargetId,
|
looksLikeBlueBubblesTargetId,
|
||||||
normalizeBlueBubblesHandle,
|
normalizeBlueBubblesHandle,
|
||||||
@@ -34,7 +30,7 @@ import {
|
|||||||
import { bluebubblesMessageActions } from "./actions.js";
|
import { bluebubblesMessageActions } from "./actions.js";
|
||||||
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
|
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
|
||||||
import { blueBubblesOnboardingAdapter } from "./onboarding.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).
|
// Use core registry meta for consistency (Gate A: core registry).
|
||||||
// BlueBubbles is positioned before imessage per Gate C preference.
|
// BlueBubbles is positioned before imessage per Gate C preference.
|
||||||
@@ -43,37 +39,6 @@ const meta = {
|
|||||||
order: 75,
|
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> = {
|
export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||||
id: "bluebubbles",
|
id: "bluebubbles",
|
||||||
meta,
|
meta,
|
||||||
@@ -272,64 +237,17 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
filename?: string;
|
filename?: string;
|
||||||
caption?: string;
|
caption?: string;
|
||||||
};
|
};
|
||||||
const core = getBlueBubblesRuntime();
|
|
||||||
const resolvedCaption = caption ?? text;
|
const resolvedCaption = caption ?? text;
|
||||||
|
const result = await sendBlueBubblesMedia({
|
||||||
let buffer: Uint8Array;
|
cfg: cfg as ClawdbotConfig,
|
||||||
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({
|
|
||||||
to,
|
to,
|
||||||
buffer,
|
mediaUrl,
|
||||||
filename: resolvedFilename ?? "attachment",
|
mediaPath,
|
||||||
contentType: resolvedContentType ?? undefined,
|
mediaBuffer,
|
||||||
|
contentType,
|
||||||
|
filename,
|
||||||
caption: resolvedCaption ?? undefined,
|
caption: resolvedCaption ?? undefined,
|
||||||
opts: {
|
accountId: accountId ?? undefined,
|
||||||
cfg: cfg as ClawdbotConfig,
|
|
||||||
accountId: accountId ?? undefined,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { channel: "bluebubbles", ...result };
|
return { channel: "bluebubbles", ...result };
|
||||||
|
|||||||
120
extensions/bluebubbles/src/media-send.ts
Normal file
120
extensions/bluebubbles/src/media-send.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1081,6 +1081,45 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
expect(callArgs.ctx.Body).toContain("[Replying to +15550000000 id:msg-0]");
|
expect(callArgs.ctx.Body).toContain("[Replying to +15550000000 id:msg-0]");
|
||||||
expect(callArgs.ctx.Body).toContain("original message");
|
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", () => {
|
describe("ack reactions", () => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
|||||||
import { downloadBlueBubblesAttachment } from "./attachments.js";
|
import { downloadBlueBubblesAttachment } from "./attachments.js";
|
||||||
import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender, normalizeBlueBubblesHandle } from "./targets.js";
|
import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender, normalizeBlueBubblesHandle } from "./targets.js";
|
||||||
import { resolveAckReaction } from "../../../src/agents/identity.js";
|
import { resolveAckReaction } from "../../../src/agents/identity.js";
|
||||||
|
import { sendBlueBubblesMedia } from "./media-send.js";
|
||||||
import type { BlueBubblesAccountConfig, BlueBubblesAttachment } from "./types.js";
|
import type { BlueBubblesAccountConfig, BlueBubblesAttachment } from "./types.js";
|
||||||
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
||||||
import { getBlueBubblesRuntime } from "./runtime.js";
|
import { getBlueBubblesRuntime } from "./runtime.js";
|
||||||
@@ -299,9 +300,15 @@ function extractReplyMetadata(message: Record<string, unknown>): {
|
|||||||
typeof associatedType === "number" && REACTION_TYPE_MAP.has(associatedType);
|
typeof associatedType === "number" && REACTION_TYPE_MAP.has(associatedType);
|
||||||
|
|
||||||
const replyToId = directReplyId ?? (!isReactionAssociation ? associatedGuid : undefined);
|
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 {
|
return {
|
||||||
replyToId: replyToId?.trim() || undefined,
|
replyToId: (replyToId ?? fallbackReplyId)?.trim() || undefined,
|
||||||
replyToBody: replyToBody?.trim() || undefined,
|
replyToBody: replyToBody?.trim() || undefined,
|
||||||
replyToSender: normalizedSender || undefined,
|
replyToSender: normalizedSender || undefined,
|
||||||
};
|
};
|
||||||
@@ -1351,6 +1358,29 @@ async function processMessage(
|
|||||||
cfg: config,
|
cfg: config,
|
||||||
dispatcherOptions: {
|
dispatcherOptions: {
|
||||||
deliver: async (payload) => {
|
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 =
|
const textLimit =
|
||||||
account.config.textChunkLimit && account.config.textChunkLimit > 0
|
account.config.textChunkLimit && account.config.textChunkLimit > 0
|
||||||
? account.config.textChunkLimit
|
? account.config.textChunkLimit
|
||||||
|
|||||||
Reference in New Issue
Block a user