diff --git a/extensions/bluebubbles/src/attachments.ts b/extensions/bluebubbles/src/attachments.ts index 106a53111..1729f27fe 100644 --- a/extensions/bluebubbles/src/attachments.ts +++ b/extensions/bluebubbles/src/attachments.ts @@ -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 diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index 5b09793cd..fce596465 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -229,7 +229,7 @@ export const bluebubblesPlugin: ChannelPlugin = { 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 = { contentType, filename, caption: resolvedCaption ?? undefined, + replyToId: replyToId ?? null, accountId: accountId ?? undefined, }); diff --git a/extensions/bluebubbles/src/media-send.ts b/extensions/bluebubbles/src/media-send.ts index 025590237..c86ab21c7 100644 --- a/extensions/bluebubbles/src/media-send.ts +++ b/extensions/bluebubbles/src/media-send.ts @@ -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; } diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index f8a8b1eae..e91e88611 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -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(); diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index b13781c47..7133107cc 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -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() }); } diff --git a/src/auto-reply/reply.queue.test.ts b/src/auto-reply/reply.queue.test.ts index 331fe6795..717303322 100644 --- a/src/auto-reply/reply.queue.test.ts +++ b/src/auto-reply/reply.queue.test.ts @@ -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]"); }); }); diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index fab496081..42c68110b 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -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, diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 7147c4fee..906ef5433 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -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(),