diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c1e8929d67..cff84218d47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- BlueBubbles: include sender identity in group chat envelopes and pass clean message text to the agent prompt, aligning with iMessage/Signal formatting. (#16210) Thanks @zerone0x. - Security/Node Host: enforce `system.run` rawCommand/argv consistency to prevent allowlist/approval bypass. Thanks @christos-eth. - CLI: fix lazy core command registration so top-level maintenance commands (`doctor`, `dashboard`, `reset`, `uninstall`) resolve correctly instead of exposing a non-functional `maintenance` placeholder command. - Security/Agents: scope CLI process cleanup to owned child PIDs to avoid killing unrelated processes on shared hosts. Thanks @aether-ai-agent. diff --git a/extensions/bluebubbles/src/monitor-processing.ts b/extensions/bluebubbles/src/monitor-processing.ts index 9d1514fa8e1..8fd7bab2850 100644 --- a/extensions/bluebubbles/src/monitor-processing.ts +++ b/extensions/bluebubbles/src/monitor-processing.ts @@ -506,7 +506,15 @@ export async function processMessage( ? `${rawBody} ${replyTag}` : `${replyTag} ${rawBody}` : rawBody; - const fromLabel = isGroup ? undefined : message.senderName || `user:${message.senderId}`; + // Build fromLabel the same way as iMessage/Signal (formatInboundFromLabel): + // group label + id for groups, sender for DMs. + // The sender identity is included in the envelope body via formatInboundEnvelope. + const senderLabel = message.senderName || `user:${message.senderId}`; + const fromLabel = isGroup + ? `${message.chatName?.trim() || "Group"} id:${peerId}` + : senderLabel !== message.senderId + ? `${senderLabel} id:${message.senderId}` + : senderLabel; const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined; const groupMembers = isGroup ? formatGroupMembers({ @@ -522,13 +530,15 @@ export async function processMessage( storePath, sessionKey: route.sessionKey, }); - const body = core.channel.reply.formatAgentEnvelope({ + const body = core.channel.reply.formatInboundEnvelope({ channel: "BlueBubbles", from: fromLabel, timestamp: message.timestamp, previousTimestamp, envelope: envelopeOptions, body: baseBody, + chatType: isGroup ? "group" : "direct", + sender: { name: message.senderName || undefined, id: message.senderId }, }); let chatGuidForActions = chatGuid; if (!chatGuidForActions && baseUrl && password) { @@ -652,9 +662,9 @@ export async function processMessage( .trim(); }; - const ctxPayload = { + const ctxPayload = core.channel.reply.finalizeInboundContext({ Body: body, - BodyForAgent: body, + BodyForAgent: rawBody, RawBody: rawBody, CommandBody: rawBody, BodyForCommands: rawBody, @@ -689,7 +699,7 @@ export async function processMessage( OriginatingTo: `bluebubbles:${outboundTarget}`, WasMentioned: effectiveWasMentioned, CommandAuthorized: commandAuthorized, - }; + }); let sentMessage = false; let streamingActive = false; diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index 0fe34082b9a..60afacf36e5 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -67,6 +67,7 @@ const mockResolveEnvelopeFormatOptions = vi.fn(() => ({ template: "channel+name+time", })); const mockFormatAgentEnvelope = vi.fn((opts: { body: string }) => opts.body); +const mockFormatInboundEnvelope = vi.fn((opts: { body: string }) => opts.body); const mockChunkMarkdownText = vi.fn((text: string) => [text]); function createMockRuntime(): PluginRuntime { @@ -124,12 +125,13 @@ function createMockRuntime(): PluginRuntime { vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveHumanDelayConfig"], dispatchReplyFromConfig: vi.fn() as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"], - finalizeInboundContext: - vi.fn() as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"], + finalizeInboundContext: vi.fn( + (ctx: Record) => ctx, + ) as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"], formatAgentEnvelope: mockFormatAgentEnvelope as unknown as PluginRuntime["channel"]["reply"]["formatAgentEnvelope"], formatInboundEnvelope: - vi.fn() as unknown as PluginRuntime["channel"]["reply"]["formatInboundEnvelope"], + mockFormatInboundEnvelope as unknown as PluginRuntime["channel"]["reply"]["formatInboundEnvelope"], resolveEnvelopeFormatOptions: mockResolveEnvelopeFormatOptions as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"], }, @@ -1369,6 +1371,145 @@ describe("BlueBubbles webhook monitor", () => { }); }); + describe("group sender identity in envelope", () => { + it("includes sender in envelope body and group label as from for group messages", async () => { + const account = createMockAccount({ groupPolicy: "open" }); + const config: OpenClawConfig = {}; + 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 everyone", + handle: { address: "+15551234567" }, + senderName: "Alice", + isGroup: true, + isFromMe: false, + guid: "msg-1", + chatGuid: "iMessage;+;chat123456", + chatName: "Family Chat", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + // formatInboundEnvelope should be called with group label + id as from, and sender info + expect(mockFormatInboundEnvelope).toHaveBeenCalledWith( + expect.objectContaining({ + from: "Family Chat id:iMessage;+;chat123456", + chatType: "group", + sender: { name: "Alice", id: "+15551234567" }, + }), + ); + // ConversationLabel should be the group label + id, not the sender + const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; + expect(callArgs.ctx.ConversationLabel).toBe("Family Chat id:iMessage;+;chat123456"); + expect(callArgs.ctx.SenderName).toBe("Alice"); + // BodyForAgent should be raw text, not the envelope-formatted body + expect(callArgs.ctx.BodyForAgent).toBe("hello everyone"); + }); + + it("falls back to group:peerId when chatName is missing", async () => { + const account = createMockAccount({ groupPolicy: "open" }); + const config: OpenClawConfig = {}; + 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: true, + isFromMe: false, + guid: "msg-1", + chatGuid: "iMessage;+;chat123456", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(mockFormatInboundEnvelope).toHaveBeenCalledWith( + expect.objectContaining({ + from: expect.stringMatching(/^Group id:/), + chatType: "group", + sender: { name: undefined, id: "+15551234567" }, + }), + ); + }); + + it("uses sender as from label for DM messages", async () => { + const account = createMockAccount(); + const config: OpenClawConfig = {}; + 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" }, + senderName: "Alice", + isGroup: false, + isFromMe: false, + guid: "msg-1", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(mockFormatInboundEnvelope).toHaveBeenCalledWith( + expect.objectContaining({ + from: "Alice id:+15551234567", + chatType: "direct", + sender: { name: "Alice", id: "+15551234567" }, + }), + ); + const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; + expect(callArgs.ctx.ConversationLabel).toBe("Alice id:+15551234567"); + }); + }); + describe("inbound debouncing", () => { it("coalesces text-only then attachment webhook events by messageId", async () => { vi.useFakeTimers();