From f6a1b733dc03802d5803624d0a8d445370995dbe Mon Sep 17 00:00:00 2001 From: Bluetegu Date: Wed, 1 Apr 2026 17:19:14 +0300 Subject: [PATCH] WhatsApp: add group and direct system prompt support --- docs/channels/groups.md | 4 + docs/channels/whatsapp.md | 70 +++++++++++++ extensions/whatsapp/src/accounts.ts | 2 + .../monitor/inbound-dispatch.test.ts | 38 +++++++ .../auto-reply/monitor/inbound-dispatch.ts | 2 + .../src/auto-reply/monitor/process-message.ts | 17 ++++ extensions/whatsapp/src/system-prompt.ts | 29 ++++++ ...ndled-channel-config-metadata.generated.ts | 36 +++++++ src/config/types.whatsapp.ts | 11 ++- .../zod-schema.providers-whatsapp.test.ts | 99 +++++++++++++++++++ src/config/zod-schema.providers-whatsapp.ts | 11 +++ 11 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 extensions/whatsapp/src/system-prompt.ts create mode 100644 src/config/zod-schema.providers-whatsapp.test.ts diff --git a/docs/channels/groups.md b/docs/channels/groups.md index e841ad92cf2..b2378c44aa9 100644 --- a/docs/channels/groups.md +++ b/docs/channels/groups.md @@ -408,6 +408,10 @@ The agent system prompt includes a group intro on the first turn of a new group - List chats: `imsg chats --limit 20`. - Group replies always go back to the same `chat_id`. +## WhatsApp system prompts + +See [WhatsApp](/channels/whatsapp#system-prompts) for the canonical WhatsApp system prompt rules, including group and direct prompt resolution, wildcard behavior, and account override semantics. + ## WhatsApp specifics See [Group messages](/channels/group-messages) for WhatsApp-only behavior (history injection, mention handling details). diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index c4858168308..f5d20592340 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -465,6 +465,75 @@ Behavior notes: +## System prompts + +WhatsApp supports Telegram-style system prompts for groups and direct chats via the `groups` and `direct` maps. + +Resolution hierarchy for group messages: + +The effective `groups` map is determined first: if the account defines its own `groups`, it fully replaces the root `groups` map (no deep merge). Prompt lookup then runs on the resulting single map: + +1. **Group-specific system prompt** (`groups[""].systemPrompt`): used if the specific group entry defines a `systemPrompt`. +2. **Group wildcard system prompt** (`groups["*"].systemPrompt`): used when the specific group entry is absent or defines no `systemPrompt`. + +Resolution hierarchy for direct messages: + +The effective `direct` map is determined first: if the account defines its own `direct`, it fully replaces the root `direct` map (no deep merge). Prompt lookup then runs on the resulting single map: + +1. **Direct-specific system prompt** (`direct[""].systemPrompt`): used if the specific peer entry defines a `systemPrompt`. +2. **Direct wildcard system prompt** (`direct["*"].systemPrompt`): used when the specific peer entry is absent or defines no `systemPrompt`. + +Note: `dms` remains the lightweight per-DM history override bucket (`dms..historyLimit`); prompt overrides live under `direct`. + +**Difference from Telegram multi-account behavior:** In Telegram, root `groups` is intentionally suppressed for all accounts in a multi-account setup — even accounts that define no `groups` of their own — to prevent a bot from receiving group messages for groups it does not belong to. WhatsApp does not apply this guard: root `groups` and root `direct` are always inherited by accounts that define no account-level override, regardless of how many accounts are configured. In a multi-account WhatsApp setup, if you want per-account group or direct prompts, define the full map under each account explicitly rather than relying on root-level defaults. + +Important behavior: + +- `channels.whatsapp.groups` is both a per-group config map and the chat-level group allowlist. At either the root or account scope, `groups["*"]` means "all groups are admitted" for that scope. +- Only add a wildcard group `systemPrompt` when you already want that scope to admit all groups. If you still want only a fixed set of group IDs to be eligible, do not use `groups["*"]` for the prompt default. Instead, repeat the prompt on each explicitly allowlisted group entry. +- Group admission and sender authorization are separate checks. `groups["*"]` widens the set of groups that can reach group handling, but it does not by itself authorize every sender in those groups. Sender access is still controlled separately by `channels.whatsapp.groupPolicy` and `channels.whatsapp.groupAllowFrom`. +- `channels.whatsapp.direct` does not have the same side effect for DMs. `direct["*"]` only provides a default direct-chat config after a DM is already admitted by `dmPolicy` plus `allowFrom` or pairing-store rules. + +Example: + +```json5 +{ + channels: { + whatsapp: { + groups: { + // Use only if all groups should be admitted at the root scope. + // Applies to all accounts that do not define their own groups map. + "*": { systemPrompt: "Default prompt for all groups." }, + }, + direct: { + // Applies to all accounts that do not define their own direct map. + "*": { systemPrompt: "Default prompt for all direct chats." }, + }, + accounts: { + work: { + groups: { + // This account defines its own groups, so root groups are fully + // replaced. To keep a wildcard, define "*" explicitly here too. + "120363406415684625@g.us": { + requireMention: false, + systemPrompt: "Focus on project management.", + }, + // Use only if all groups should be admitted in this account. + "*": { systemPrompt: "Default prompt for work groups." }, + }, + direct: { + // This account defines its own direct map, so root direct entries are + // fully replaced. To keep a wildcard, define "*" explicitly here too. + "+15551234567": { systemPrompt: "Prompt for a specific work direct chat." }, + "*": { systemPrompt: "Default prompt for work direct chats." }, + }, + }, + }, + }, + }, +} +``` + ## Configuration reference pointers Primary reference: @@ -478,6 +547,7 @@ High-signal WhatsApp fields: - multi-account: `accounts..enabled`, `accounts..authDir`, account-level overrides - operations: `configWrites`, `debounceMs`, `web.enabled`, `web.heartbeatSeconds`, `web.reconnect.*` - session behavior: `session.dmScope`, `historyLimit`, `dmHistoryLimit`, `dms..historyLimit` +- prompts: `groups..systemPrompt`, `groups["*"].systemPrompt`, `direct..systemPrompt`, `direct["*"].systemPrompt` ## Related diff --git a/extensions/whatsapp/src/accounts.ts b/extensions/whatsapp/src/accounts.ts index f1237ef8bf0..024cc09e6df 100644 --- a/extensions/whatsapp/src/accounts.ts +++ b/extensions/whatsapp/src/accounts.ts @@ -36,6 +36,7 @@ export type ResolvedWhatsAppAccount = { ackReaction?: WhatsAppAccountConfig["ackReaction"]; reactionLevel?: WhatsAppAccountConfig["reactionLevel"]; groups?: WhatsAppAccountConfig["groups"]; + direct?: WhatsAppAccountConfig["direct"]; debounceMs?: number; }; @@ -150,6 +151,7 @@ export function resolveWhatsAppAccount(params: { ackReaction: merged.ackReaction, reactionLevel: merged.reactionLevel, groups: merged.groups, + direct: merged.direct, debounceMs: merged.debounceMs, }; } diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts index 85b717dff5f..7f49da45f2e 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts @@ -194,6 +194,44 @@ describe("whatsapp inbound dispatch", () => { expect(ctx.To).toBe("+2000"); }); + it("passes groupSystemPrompt into GroupSystemPrompt for group chats", () => { + const ctx = buildWhatsAppInboundContext({ + combinedBody: "hi", + conversationId: "123@g.us", + groupSystemPrompt: "Specific group prompt", + msg: makeMsg({ from: "123@g.us", chatType: "group", groupParticipants: [] }), + route: makeRoute({ sessionKey: "agent:main:whatsapp:group:123@g.us" }), + sender: { e164: "+15550002222" }, + }); + + expect(ctx.GroupSystemPrompt).toBe("Specific group prompt"); + }); + + it("passes groupSystemPrompt into GroupSystemPrompt for direct chats", () => { + const ctx = buildWhatsAppInboundContext({ + combinedBody: "hi", + conversationId: "+1555", + groupSystemPrompt: "Specific direct prompt", + msg: makeMsg({ from: "+1555", chatType: "direct" }), + route: makeRoute({ sessionKey: "agent:main:whatsapp:direct:+1555" }), + sender: { e164: "+1555" }, + }); + + expect(ctx.GroupSystemPrompt).toBe("Specific direct prompt"); + }); + + it("omits GroupSystemPrompt when groupSystemPrompt is not provided", () => { + const ctx = buildWhatsAppInboundContext({ + combinedBody: "hi", + conversationId: "123@g.us", + msg: makeMsg({ from: "123@g.us", chatType: "group", groupParticipants: [] }), + route: makeRoute({ sessionKey: "agent:main:whatsapp:group:123@g.us" }), + sender: { e164: "+15550002222" }, + }); + + expect(ctx.GroupSystemPrompt).toBeUndefined(); + }); + it("defaults responsePrefix to identity name in self-chats when unset", () => { const responsePrefix = resolveWhatsAppResponsePrefix({ cfg: { diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts index 4f3d4d67891..7c98365a23c 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts @@ -87,6 +87,7 @@ export function buildWhatsAppInboundContext(params: { conversationId: string; groupHistory?: GroupHistoryEntry[]; groupMemberRoster?: Map; + groupSystemPrompt?: string; msg: WebInboundMsg; route: ReturnType; sender: SenderContext; @@ -132,6 +133,7 @@ export function buildWhatsAppInboundContext(params: { SenderE164: params.sender.e164, CommandAuthorized: params.commandAuthorized, WasMentioned: params.msg.wasMentioned, + GroupSystemPrompt: params.groupSystemPrompt, ...(params.msg.location ? toLocationContext(params.msg.location) : {}), Provider: "whatsapp", Surface: "whatsapp", diff --git a/extensions/whatsapp/src/auto-reply/monitor/process-message.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.ts index 90e2a4eeae7..dfcd6e41929 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/process-message.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.ts @@ -1,3 +1,7 @@ +import { + resolveWhatsAppDirectSystemPrompt, + resolveWhatsAppGroupSystemPrompt, +} from "../../system-prompt.js"; import { getPrimaryIdentityId, getSelfIdentity, getSenderIdentity } from "../../identity.js"; import { resolveWhatsAppCommandAuthorized, @@ -227,12 +231,25 @@ export async function processMessage(params: { pipelineResponsePrefix: replyPipeline.responsePrefix, }); + // Resolve combined conversation system prompt using the group or direct surface. + const conversationSystemPrompt = + params.msg.chatType === "group" + ? resolveWhatsAppGroupSystemPrompt({ + accountConfig: account, + groupId: conversationId, + }) + : resolveWhatsAppDirectSystemPrompt({ + accountConfig: account, + peerId: dmRouteTarget ?? params.msg.from, + }); + const ctxPayload = buildWhatsAppInboundContext({ combinedBody, commandAuthorized, conversationId, groupHistory: visibleGroupHistory, groupMemberRoster: params.groupMemberNames.get(params.groupHistoryKey), + groupSystemPrompt: conversationSystemPrompt, msg: params.msg, route: params.route, sender: { diff --git a/extensions/whatsapp/src/system-prompt.ts b/extensions/whatsapp/src/system-prompt.ts new file mode 100644 index 00000000000..ddc5450e8b9 --- /dev/null +++ b/extensions/whatsapp/src/system-prompt.ts @@ -0,0 +1,29 @@ +export function resolveWhatsAppGroupSystemPrompt(params: { + accountConfig?: { groups?: Record } | null; + groupId?: string | null; +}): string | undefined { + if (!params.groupId) { + return undefined; + } + const groups = params.accountConfig?.groups; + return ( + groups?.[params.groupId]?.systemPrompt?.trim() || + groups?.["*"]?.systemPrompt?.trim() || + undefined + ); +} + +export function resolveWhatsAppDirectSystemPrompt(params: { + accountConfig?: { direct?: Record } | null; + peerId?: string | null; +}): string | undefined { + if (!params.peerId) { + return undefined; + } + const direct = params.accountConfig?.direct; + return ( + direct?.[params.peerId]?.systemPrompt?.trim() || + direct?.["*"]?.systemPrompt?.trim() || + undefined + ); +} diff --git a/src/config/bundled-channel-config-metadata.generated.ts b/src/config/bundled-channel-config-metadata.generated.ts index 086f4c9bc92..c50a82e7eb7 100644 --- a/src/config/bundled-channel-config-metadata.generated.ts +++ b/src/config/bundled-channel-config-metadata.generated.ts @@ -15154,6 +15154,24 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ additionalProperties: false, }, }, + systemPrompt: { + type: "string", + }, + }, + additionalProperties: false, + }, + }, + direct: { + type: "object", + propertyNames: { + type: "string", + }, + additionalProperties: { + type: "object", + properties: { + systemPrompt: { + type: "string", + }, }, additionalProperties: false, }, @@ -15405,6 +15423,24 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ additionalProperties: false, }, }, + systemPrompt: { + type: "string", + }, + }, + additionalProperties: false, + }, + }, + direct: { + type: "object", + propertyNames: { + type: "string", + }, + additionalProperties: { + type: "object", + properties: { + systemPrompt: { + type: "string", + }, }, additionalProperties: false, }, diff --git a/src/config/types.whatsapp.ts b/src/config/types.whatsapp.ts index d8bb0aafe6b..ecbb9e04b50 100644 --- a/src/config/types.whatsapp.ts +++ b/src/config/types.whatsapp.ts @@ -25,6 +25,13 @@ export type WhatsAppGroupConfig = { requireMention?: boolean; tools?: GroupToolPolicyConfig; toolsBySender?: GroupToolPolicyBySenderConfig; + /** Optional system prompt for this group. */ + systemPrompt?: string; +}; + +export type WhatsAppDirectConfig = { + /** Optional system prompt for this direct chat. */ + systemPrompt?: string; }; export type WhatsAppAckReactionConfig = { @@ -68,7 +75,7 @@ type WhatsAppSharedConfig = { historyLimit?: number; /** Max DM turns to keep as history context. */ dmHistoryLimit?: number; - /** Per-DM config overrides keyed by user ID. */ + /** Per-DM history overrides keyed by user ID. */ dms?: Record; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; @@ -81,6 +88,8 @@ type WhatsAppSharedConfig = { /** Merge streamed block replies before sending. */ blockStreamingCoalesce?: BlockStreamingCoalesceConfig; groups?: Record; + /** Per-direct-chat prompt overrides keyed by user ID or `*` wildcard. */ + direct?: Record; /** Acknowledgment reaction sent immediately upon message receipt. */ ackReaction?: WhatsAppAckReactionConfig; /** diff --git a/src/config/zod-schema.providers-whatsapp.test.ts b/src/config/zod-schema.providers-whatsapp.test.ts new file mode 100644 index 00000000000..d45b9a93378 --- /dev/null +++ b/src/config/zod-schema.providers-whatsapp.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from "vitest"; +import { WhatsAppConfigSchema, WhatsAppAccountSchema } from "./zod-schema.providers-whatsapp.js"; + +describe("WhatsApp prompt config Zod validation", () => { + it("validates group-level systemPrompt", () => { + const config = { + groups: { + "123@g.us": { + systemPrompt: "This is a work group", + }, + }, + }; + + const result = WhatsAppConfigSchema.safeParse(config); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.groups?.["123@g.us"]?.systemPrompt).toBe("This is a work group"); + } + }); + + it("validates direct-level systemPrompt", () => { + const config = { + direct: { + "+15551234567": { + systemPrompt: "This is a VIP direct chat", + }, + }, + }; + + const result = WhatsAppConfigSchema.safeParse(config); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.direct?.["+15551234567"]?.systemPrompt).toBe("This is a VIP direct chat"); + } + }); + + it("validates combined group and direct prompt surfaces", () => { + const config = { + groups: { + "*": { + systemPrompt: "Default group prompt", + }, + }, + direct: { + "+15551234567": { + systemPrompt: "Direct VIP", + }, + }, + accounts: { + work: { + groups: { + "456@g.us": { + systemPrompt: "Project team", + }, + }, + direct: { + "*": { + systemPrompt: "Work direct default", + }, + }, + }, + }, + }; + + const result = WhatsAppConfigSchema.safeParse(config); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.groups?.["*"]?.systemPrompt).toBe("Default group prompt"); + expect(result.data.direct?.["+15551234567"]?.systemPrompt).toBe("Direct VIP"); + expect(result.data.accounts?.work?.groups?.["456@g.us"]?.systemPrompt).toBe("Project team"); + expect(result.data.accounts?.work?.direct?.["*"]?.systemPrompt).toBe("Work direct default"); + } + }); + + it("validates WhatsAppAccountSchema directly", () => { + const accountConfig = { + name: "Personal Account", + groups: { + "family@g.us": { + systemPrompt: "Keep responses family-friendly", + }, + }, + direct: { + "+15557654321": { + systemPrompt: "Keep responses concise", + }, + }, + }; + + const result = WhatsAppAccountSchema.safeParse(accountConfig); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.groups?.["family@g.us"]?.systemPrompt).toBe( + "Keep responses family-friendly", + ); + expect(result.data.direct?.["+15557654321"]?.systemPrompt).toBe("Keep responses concise"); + } + }); +}); diff --git a/src/config/zod-schema.providers-whatsapp.ts b/src/config/zod-schema.providers-whatsapp.ts index d26f75130bb..7027adfc4b3 100644 --- a/src/config/zod-schema.providers-whatsapp.ts +++ b/src/config/zod-schema.providers-whatsapp.ts @@ -22,12 +22,22 @@ const WhatsAppGroupEntrySchema = z requireMention: z.boolean().optional(), tools: ToolPolicySchema, toolsBySender: ToolPolicyBySenderSchema, + systemPrompt: z.string().optional(), }) .strict() .optional(); const WhatsAppGroupsSchema = z.record(z.string(), WhatsAppGroupEntrySchema).optional(); +const WhatsAppDirectEntrySchema = z + .object({ + systemPrompt: z.string().optional(), + }) + .strict() + .optional(); + +const WhatsAppDirectSchema = z.record(z.string(), WhatsAppDirectEntrySchema).optional(); + const WhatsAppAckReactionSchema = z .object({ emoji: z.string().optional(), @@ -65,6 +75,7 @@ function buildWhatsAppCommonShape(params: { useDefaults: boolean }) { blockStreaming: z.boolean().optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), groups: WhatsAppGroupsSchema, + direct: WhatsAppDirectSchema, ackReaction: WhatsAppAckReactionSchema, reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(), debounceMs: params.useDefaults