fix(bluebubbles): include sender identity in group chat envelopes (#16326)

* fix(bluebubbles): include sender identity in group chat envelopes

Use formatInboundEnvelope (matching iMessage/Signal pattern) so group
messages show the group label in the envelope header and include the
sender name in the message body. ConversationLabel now resolves to the
group name for groups instead of being undefined.

Fixes #16210

Co-authored-by: zerone0x <hi@trine.dev>

* fix(bluebubbles): use finalizeInboundContext and set BodyForAgent to raw text

Wrap ctxPayload with finalizeInboundContext (matching iMessage/Signal/
every other channel) so field normalization, ChatType, ConversationLabel
fallback, and MediaType alignment are applied consistently.

Change BodyForAgent from the envelope-formatted body to rawBody so the
agent prompt receives clean message text instead of the [BlueBubbles ...]
envelope wrapper.

Co-authored-by: zerone0x <hi@trine.dev>

* docs: add changelog entry for BlueBubbles group sender fix (#16326)

* fix(bluebubbles): include id in fromLabel matching formatInboundFromLabel

Align fromLabel output with the shared formatInboundFromLabel pattern:
groups get 'GroupName id:peerId', DMs get 'Name id:senderId' when the
name differs from the id. Addresses PR review feedback.

Co-authored-by: zerone0x <hi@trine.dev>

---------

Co-authored-by: zerone0x <hi@trine.dev>
This commit is contained in:
Christian Klotz
2026-02-14 18:17:26 +00:00
committed by GitHub
parent 3369ef5aef
commit df7464ddf6
3 changed files with 160 additions and 8 deletions

View File

@@ -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.

View File

@@ -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;

View File

@@ -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<string, unknown>) => 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();