feat: enhance BlueBubbles media and message handling by adding reply context support and improving outbound message ID tracking

This commit is contained in:
Tyler Yust
2026-01-20 01:53:41 -08:00
committed by Peter Steinberger
parent c331bdc27d
commit d029ceab1c
8 changed files with 96 additions and 9 deletions

View File

@@ -170,6 +170,8 @@ export async function sendBlueBubblesAttachment(params: {
// Add optional caption
if (caption) {
addField("message", caption);
addField("text", caption);
addField("caption", caption);
}
// Close the multipart body

View File

@@ -229,7 +229,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
return { channel: "bluebubbles", ...result };
},
sendMedia: async (ctx) => {
const { cfg, to, text, mediaUrl, accountId } = ctx;
const { cfg, to, text, mediaUrl, accountId, replyToId } = ctx;
const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as {
mediaPath?: string;
mediaBuffer?: Uint8Array;
@@ -247,6 +247,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
contentType,
filename,
caption: resolvedCaption ?? undefined,
replyToId: replyToId ?? null,
accountId: accountId ?? undefined,
});

View File

@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { sendBlueBubblesAttachment } from "./attachments.js";
import { sendMessageBlueBubbles } from "./send.js";
import { getBlueBubblesRuntime } from "./runtime.js";
const HTTP_URL_RE = /^https?:\/\//i;
@@ -46,6 +47,7 @@ export async function sendBlueBubblesMedia(params: {
contentType?: string;
filename?: string;
caption?: string;
replyToId?: string | null;
accountId?: string;
}) {
const {
@@ -57,6 +59,7 @@ export async function sendBlueBubblesMedia(params: {
contentType,
filename,
caption,
replyToId,
accountId,
} = params;
const core = getBlueBubblesRuntime();
@@ -106,15 +109,25 @@ export async function sendBlueBubblesMedia(params: {
}
}
return sendBlueBubblesAttachment({
const attachmentResult = await sendBlueBubblesAttachment({
to,
buffer,
filename: resolvedFilename ?? "attachment",
contentType: resolvedContentType ?? undefined,
caption: caption ?? undefined,
opts: {
cfg,
accountId,
},
});
const trimmedCaption = caption?.trim();
if (trimmedCaption) {
await sendMessageBlueBubbles(to, trimmedCaption, {
cfg,
accountId,
replyToMessageGuid: replyToId?.trim() || undefined,
});
}
return attachmentResult;
}

View File

@@ -1396,6 +1396,55 @@ describe("BlueBubbles webhook monitor", () => {
});
});
describe("outbound message ids", () => {
it("enqueues system event for outbound message id", async () => {
mockEnqueueSystemEvent.mockClear();
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
});
const account = createMockAccount();
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: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
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(mockEnqueueSystemEvent).toHaveBeenCalledWith(
"BlueBubbles sent message id: msg-123",
expect.objectContaining({
sessionKey: "agent:main:bluebubbles:dm:+15551234567",
}),
);
});
});
describe("reaction events", () => {
it("enqueues system event for reaction added", async () => {
mockEnqueueSystemEvent.mockClear();

View File

@@ -1316,6 +1316,15 @@ async function processMessage(
? formatBlueBubblesChatTarget({ chatGuid: chatGuidForActions })
: message.senderId;
const maybeEnqueueOutboundMessageId = (messageId?: string) => {
const trimmed = messageId?.trim();
if (!trimmed || trimmed === "ok" || trimmed === "unknown") return;
core.system.enqueueSystemEvent(`BlueBubbles sent message id: ${trimmed}`, {
sessionKey: route.sessionKey,
contextKey: `bluebubbles:outbound:${outboundTarget}:${trimmed}`,
});
};
const ctxPayload = {
Body: body,
BodyForAgent: body,
@@ -1368,13 +1377,15 @@ async function processMessage(
for (const mediaUrl of mediaList) {
const caption = first ? payload.text : undefined;
first = false;
await sendBlueBubblesMedia({
const result = await sendBlueBubblesMedia({
cfg: config,
to: outboundTarget,
mediaUrl,
caption: caption ?? undefined,
replyToId: payload.replyToId ?? null,
accountId: account.accountId,
});
maybeEnqueueOutboundMessageId(result.messageId);
sentMessage = true;
statusSink?.({ lastOutboundAt: Date.now() });
}
@@ -1391,11 +1402,12 @@ async function processMessage(
for (const chunk of chunks) {
const replyToMessageGuid =
typeof payload.replyToId === "string" ? payload.replyToId.trim() : "";
await sendMessageBlueBubbles(outboundTarget, chunk, {
const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
cfg: config,
accountId: account.accountId,
replyToMessageGuid: replyToMessageGuid || undefined,
});
maybeEnqueueOutboundMessageId(result.messageId);
sentMessage = true;
statusSink?.({ lastOutboundAt: Date.now() });
}

View File

@@ -82,7 +82,7 @@ describe("queue followups", () => {
});
const first = await getReplyFromConfig(
{ Body: "first", From: "+1001", To: "+2000" },
{ Body: "first", From: "+1001", To: "+2000", MessageSid: "m-1" },
{},
cfg,
);
@@ -105,7 +105,11 @@ describe("queue followups", () => {
await Promise.resolve();
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(2);
expect(prompts.some((p) => p.includes("[Queued messages while agent was busy]"))).toBe(true);
const queuedPrompt = prompts.find((p) =>
p.includes("[Queued messages while agent was busy]"),
);
expect(queuedPrompt).toBeTruthy();
expect(queuedPrompt).toContain("[message_id: m-1]");
});
});

View File

@@ -377,9 +377,14 @@ export async function runPreparedReply(
const sessionIdFinal = sessionId ?? crypto.randomUUID();
const sessionFile = resolveSessionFilePath(sessionIdFinal, sessionEntry);
const queueBodyBase = [threadStarterNote, baseBodyFinal].filter(Boolean).join("\n\n");
const queuedBody = mediaNote
? [mediaNote, mediaReplyHint, queueBodyBase].filter(Boolean).join("\n").trim()
const queueMessageId = sessionCtx.MessageSid?.trim();
const queueMessageIdHint = queueMessageId ? `[message_id: ${queueMessageId}]` : "";
const queueBodyWithId = queueMessageIdHint
? `${queueBodyBase}\n${queueMessageIdHint}`
: queueBodyBase;
const queuedBody = mediaNote
? [mediaNote, mediaReplyHint, queueBodyWithId].filter(Boolean).join("\n").trim()
: queueBodyWithId;
const resolvedQueue = resolveQueueSettings({
cfg,
channel: sessionCtx.Provider,

View File

@@ -492,6 +492,7 @@ const BlueBubblesActionSchema = z
reply: z.boolean().optional(),
sendWithEffect: z.boolean().optional(),
renameGroup: z.boolean().optional(),
setGroupIcon: z.boolean().optional(),
addParticipant: z.boolean().optional(),
removeParticipant: z.boolean().optional(),
leaveGroup: z.boolean().optional(),