mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-29 16:54:30 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user