bluebubbles: forward per-group systemPrompt into GroupSystemPrompt (#69198)

Forward per-group systemPrompt config into inbound context GroupSystemPrompt so configured group-specific behavioral instructions (for example threaded-reply and tapback conventions) are injected on every turn. Supports "*" wildcard fallback matching the existing requireMention pattern.

Closes #60665.

Co-authored-by: Omar Shahine <omarshahine@users.noreply.github.com>
This commit is contained in:
Omar Shahine
2026-04-20 20:01:03 -07:00
committed by GitHub
parent d1f7f69cd4
commit b5f25de352
8 changed files with 165 additions and 3 deletions

View File

@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
- Agents/compaction: send opt-in start and completion notices during context compaction. (#67830) Thanks @feniix.
- Moonshot/Kimi: default bundled Moonshot setup, web search, and media-understanding surfaces to `kimi-k2.6` while keeping `kimi-k2.5` available for compatibility. (#69477) Thanks @scoootscooob.
- Moonshot/Kimi: allow `thinking.keep = "all"` on `moonshot/kimi-k2.6`, and strip it for other Moonshot models or requests where pinned `tool_choice` disables thinking. (#68816) Thanks @aniaan.
- BlueBubbles/groups: forward per-group `systemPrompt` config into inbound context `GroupSystemPrompt` so configured group-specific behavioral instructions (for example threaded-reply and tapback conventions) are injected on every turn. Supports `"*"` wildcard fallback matching the existing `requireMention` pattern. Closes #60665. (#69198) Thanks @omarshahine.
### Fixes

View File

@@ -1,4 +1,4 @@
ab40431597e9f7c09a9f010f267bab250c7f9c570c4a100776de98869f589a92 config-baseline.json
580abc79677d84fa66cb55e42ea093bfa9681655861166c02dfaa5a313d44310 config-baseline.json
04a82c2208bf69e0a195e7712e3a518a8255c1bb002c31f712cb95003325635b config-baseline.core.json
e239cc20f20f8d0172812bc0ad3ee6df52da88e2e2702e3d03a47e01561132ae config-baseline.channel.json
b695cb31b4c0cf1d31f842f2892e99cc3ff8d84263ae72b72977cae844b81d6e config-baseline.plugin.json
8fb3a1cf5fe56ab8fc2cb46341c3403aed32b0d1f0aaeac0e96cd3599db4f06e config-baseline.plugin.json

View File

@@ -217,6 +217,54 @@ Per-group configuration:
- Uses `allowFrom` and `groupAllowFrom` to determine command authorization.
- Authorized senders can run control commands even without mentioning in groups.
### Per-group system prompt
Each entry under `channels.bluebubbles.groups.*` accepts an optional `systemPrompt` string. The value is injected into the agent's system prompt on every turn that handles a message in that group, so you can set per-group persona or behavioral rules without editing agent prompts:
```json5
{
channels: {
bluebubbles: {
groups: {
"iMessage;-;chat123": {
systemPrompt: "Keep responses under 3 sentences. Mirror the group's casual tone.",
},
},
},
},
}
```
The key matches whatever BlueBubbles reports as `chatGuid` / `chatIdentifier` / numeric `chatId` for the group, and a `"*"` wildcard entry provides a default for every group without an exact match (same pattern used by `requireMention` and per-group tool policies). Exact matches always win over the wildcard. DMs ignore this field; use agent-level or account-level prompt customization instead.
#### Worked example: threaded replies and tapback reactions (Private API)
With the BlueBubbles Private API enabled, inbound messages arrive with short message IDs (for example `[[reply_to:5]]`) and the agent can call `action=reply` to thread into a specific message or `action=react` to drop a tapback. A per-group `systemPrompt` is a reliable way to keep the agent choosing the right tool:
```json5
{
channels: {
bluebubbles: {
groups: {
"iMessage;+;chat-family": {
systemPrompt: [
"When replying in this group, always call action=reply with the",
"[[reply_to:N]] messageId from context so your response threads",
"under the triggering message. Never send a new unlinked message.",
"",
"For short acknowledgements ('ok', 'got it', 'on it'), use",
"action=react with an appropriate tapback emoji (❤️, 👍, 😂, ‼️, ❓)",
"instead of sending a text reply.",
].join(" "),
},
},
},
},
}
```
Tapback reactions and threaded replies both require the BlueBubbles Private API; see [Advanced actions](#advanced-actions) and [Message IDs](#message-ids-short-vs-full) for the underlying mechanics.
## ACP conversation bindings
BlueBubbles chats can be turned into durable ACP workspaces without changing the transport layer.

View File

@@ -30,6 +30,12 @@ const bluebubblesActionSchema = z
const bluebubblesGroupConfigSchema = z.object({
requireMention: z.boolean().optional(),
tools: ToolPolicySchema,
/**
* Free-form directive appended to the system prompt for every turn that
* handles a message in this group. Use it for per-group persona tweaks or
* behavioral rules (reply-threading, tapback conventions, etc.).
*/
systemPrompt: z.string().optional(),
});
const bluebubblesNetworkSchema = z

View File

@@ -1537,6 +1537,14 @@ async function processMessageAfterDedupe(
OriginatingTo: `bluebubbles:${outboundTarget}`,
WasMentioned: effectiveWasMentioned,
CommandAuthorized: commandAuthorized,
// Exact group match wins over the "*" wildcard fallback, matching the
// pattern used by resolveChannelGroupRequireMention/toolsPolicy.
GroupSystemPrompt: isGroup
? normalizeOptionalString(
account.config.groups?.[peerId]?.systemPrompt ??
account.config.groups?.["*"]?.systemPrompt,
)
: undefined,
});
let sentMessage = false;

View File

@@ -593,6 +593,100 @@ describe("BlueBubbles webhook monitor", () => {
expect(callArgs.ctx.GroupMembers).toBe("Alice (+15551234567), Bob (+15557654321)");
});
it("threads per-group systemPrompt into ctx for group messages", async () => {
setupWebhookTarget({
account: createMockAccount({
groups: {
"iMessage;+;chat123456": {
systemPrompt: "Reply in thread with action=reply; ack via action=react.",
},
},
}),
});
const payload = createTimestampedNewMessagePayloadForTest({
text: "hello group",
isGroup: true,
chatGuid: "iMessage;+;chat123456",
chatName: "Family",
participants: [{ address: "+15551234567", displayName: "Alice" }],
});
await dispatchWebhookPayload(payload);
const callArgs = getFirstDispatchCall();
expect(callArgs.ctx.GroupSystemPrompt).toBe(
"Reply in thread with action=reply; ack via action=react.",
);
});
it("falls back to the '*' wildcard systemPrompt when no exact group match", async () => {
setupWebhookTarget({
account: createMockAccount({
groups: {
"*": { systemPrompt: "Default group rule: keep it short." },
},
}),
});
const payload = createTimestampedNewMessagePayloadForTest({
text: "hi group",
isGroup: true,
chatGuid: "iMessage;+;chat-unmapped",
chatName: "Family",
participants: [{ address: "+15551234567", displayName: "Alice" }],
});
await dispatchWebhookPayload(payload);
const callArgs = getFirstDispatchCall();
expect(callArgs.ctx.GroupSystemPrompt).toBe("Default group rule: keep it short.");
});
it("prefers an exact group systemPrompt over the '*' wildcard", async () => {
setupWebhookTarget({
account: createMockAccount({
groups: {
"*": { systemPrompt: "wildcard value" },
"iMessage;+;chat123456": { systemPrompt: "exact value" },
},
}),
});
const payload = createTimestampedNewMessagePayloadForTest({
text: "hi group",
isGroup: true,
chatGuid: "iMessage;+;chat123456",
chatName: "Family",
participants: [{ address: "+15551234567", displayName: "Alice" }],
});
await dispatchWebhookPayload(payload);
const callArgs = getFirstDispatchCall();
expect(callArgs.ctx.GroupSystemPrompt).toBe("exact value");
});
it("omits GroupSystemPrompt for DMs even when the group config would match", async () => {
setupWebhookTarget({
account: createMockAccount({
groups: {
"+15551234567": { systemPrompt: "unused in DM" },
},
}),
});
const payload = createTimestampedNewMessagePayloadForTest({
text: "hi",
isGroup: false,
});
await dispatchWebhookPayload(payload);
const callArgs = getFirstDispatchCall();
expect(callArgs.ctx.GroupSystemPrompt).toBeUndefined();
});
it("does not enrich group participants when the config flag is disabled", async () => {
const resolvePhoneNames = vi.fn(async () => new Map([["5551234567", "Alice Contact"]]));
setupWebhookTarget({

View File

@@ -10,6 +10,11 @@ export type BlueBubblesGroupConfig = {
requireMention?: boolean;
/** Optional tool policy overrides for this group. */
tools?: { allow?: string[]; deny?: string[] };
/**
* Free-form directive appended to the system prompt on every turn that
* handles a message in this group.
*/
systemPrompt?: string;
};
export type BlueBubblesActionConfig = {

View File

@@ -15,7 +15,7 @@ const sourceRoots = ["src/channels", "src/routing", "src/line", "extensions"];
// code should be rejected and migrated to fetchWithSsrFGuard/shared channel helpers.
const allowedRawFetchCallsites = new Set([
bundledPluginCallsite("bluebubbles", "src/test-harness.ts", 132),
bundledPluginCallsite("bluebubbles", "src/types.ts", 189),
bundledPluginCallsite("bluebubbles", "src/types.ts", 194),
bundledPluginCallsite("browser", "src/browser/cdp.helpers.ts", 268),
bundledPluginCallsite("browser", "src/browser/client-fetch.ts", 192),
bundledPluginCallsite("browser", "src/browser/test-fetch.ts", 24),