mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:40:44 +00:00
WhatsApp: add group and direct system prompt support
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -465,6 +465,75 @@ Behavior notes:
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## 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["<groupId>"].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["<peerId>"].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.<id>.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.<id>.enabled`, `accounts.<id>.authDir`, account-level overrides
|
||||
- operations: `configWrites`, `debounceMs`, `web.enabled`, `web.heartbeatSeconds`, `web.reconnect.*`
|
||||
- session behavior: `session.dmScope`, `historyLimit`, `dmHistoryLimit`, `dms.<id>.historyLimit`
|
||||
- prompts: `groups.<id>.systemPrompt`, `groups["*"].systemPrompt`, `direct.<id>.systemPrompt`, `direct["*"].systemPrompt`
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -87,6 +87,7 @@ export function buildWhatsAppInboundContext(params: {
|
||||
conversationId: string;
|
||||
groupHistory?: GroupHistoryEntry[];
|
||||
groupMemberRoster?: Map<string, string>;
|
||||
groupSystemPrompt?: string;
|
||||
msg: WebInboundMsg;
|
||||
route: ReturnType<typeof resolveAgentRoute>;
|
||||
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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
29
extensions/whatsapp/src/system-prompt.ts
Normal file
29
extensions/whatsapp/src/system-prompt.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export function resolveWhatsAppGroupSystemPrompt(params: {
|
||||
accountConfig?: { groups?: Record<string, { systemPrompt?: string }> } | 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<string, { systemPrompt?: string }> } | 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
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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<string, DmConfig>;
|
||||
/** 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<string, WhatsAppGroupConfig>;
|
||||
/** Per-direct-chat prompt overrides keyed by user ID or `*` wildcard. */
|
||||
direct?: Record<string, WhatsAppDirectConfig>;
|
||||
/** Acknowledgment reaction sent immediately upon message receipt. */
|
||||
ackReaction?: WhatsAppAckReactionConfig;
|
||||
/**
|
||||
|
||||
99
src/config/zod-schema.providers-whatsapp.test.ts
Normal file
99
src/config/zod-schema.providers-whatsapp.test.ts
Normal file
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user