From 4e515ceb89b81a7c9411cd93dbb5b03b3f6880d2 Mon Sep 17 00:00:00 2001 From: Matt Suiche Date: Mon, 26 Jan 2026 14:34:23 +0400 Subject: [PATCH] feat(channels): add systemPrompt support for WhatsApp and Signal Add per-group and per-DM system prompt configuration for WhatsApp and Signal channels, mirroring the existing Telegram implementation. - Add systemPrompt to DmConfig type and schema - Add systemPrompt to WhatsApp group config (both root and account) - Add SignalGroupConfig type with systemPrompt and groups config - Add resolveChannelGroupSystemPrompt() and resolveChannelDmSystemPrompt() helpers in group-policy.ts - Pass GroupSystemPrompt to inbound context in WhatsApp and Signal handlers --- src/config/group-policy.ts | 66 +++++++++++++++++++ src/config/types.messages.ts | 2 + src/config/types.signal.ts | 12 ++++ src/config/types.whatsapp.ts | 4 ++ src/config/zod-schema.core.ts | 1 + src/config/zod-schema.providers-core.ts | 9 +++ src/config/zod-schema.providers-whatsapp.ts | 2 + src/signal/monitor/event-handler.ts | 21 ++++++ src/web/auto-reply/monitor/process-message.ts | 21 ++++++ 9 files changed, 138 insertions(+) diff --git a/src/config/group-policy.ts b/src/config/group-policy.ts index faad3508b..9fec3230e 100644 --- a/src/config/group-policy.ts +++ b/src/config/group-policy.ts @@ -8,6 +8,8 @@ export type GroupPolicyChannel = ChannelId; export type ChannelGroupConfig = { requireMention?: boolean; tools?: GroupToolPolicyConfig; + /** Custom system prompt for this group. */ + systemPrompt?: string; }; export type ChannelGroupPolicy = { @@ -105,3 +107,67 @@ export function resolveChannelGroupToolsPolicy(params: { if (defaultConfig?.tools) return defaultConfig.tools; return undefined; } + +/** + * Resolve group-specific system prompt from channel config. + * Falls back to wildcard (`*`) config if specific group not found. + */ +export function resolveChannelGroupSystemPrompt(params: { + cfg: ClawdbotConfig; + channel: GroupPolicyChannel; + groupId?: string | null; + accountId?: string | null; +}): string | undefined { + const { groupConfig, defaultConfig } = resolveChannelGroupPolicy(params); + if (groupConfig?.systemPrompt?.trim()) return groupConfig.systemPrompt.trim(); + if (defaultConfig?.systemPrompt?.trim()) return defaultConfig.systemPrompt.trim(); + return undefined; +} + +type ChannelDmsConfig = Record< + string, + { historyLimit?: number; systemPrompt?: string } | undefined +>; + +function resolveChannelDms( + cfg: ClawdbotConfig, + channel: GroupPolicyChannel, + accountId?: string | null, +): ChannelDmsConfig | undefined { + const normalizedAccountId = normalizeAccountId(accountId); + const channelConfig = cfg.channels?.[channel] as + | { + accounts?: Record; + dms?: ChannelDmsConfig; + } + | undefined; + if (!channelConfig) return undefined; + const accountDms = + channelConfig.accounts?.[normalizedAccountId]?.dms ?? + channelConfig.accounts?.[ + Object.keys(channelConfig.accounts ?? {}).find( + (key) => key.toLowerCase() === normalizedAccountId.toLowerCase(), + ) ?? "" + ]?.dms; + return accountDms ?? channelConfig.dms; +} + +/** + * Resolve DM-specific system prompt from channel config. + * Falls back to wildcard (`*`) config if specific DM not found. + */ +export function resolveChannelDmSystemPrompt(params: { + cfg: ClawdbotConfig; + channel: GroupPolicyChannel; + dmId?: string | null; + accountId?: string | null; +}): string | undefined { + const dms = resolveChannelDms(params.cfg, params.channel, params.accountId); + if (!dms) return undefined; + const normalizedId = params.dmId?.trim(); + const dmConfig = normalizedId ? dms[normalizedId] : undefined; + const defaultConfig = dms["*"]; + if (dmConfig?.systemPrompt?.trim()) return dmConfig.systemPrompt.trim(); + if (defaultConfig?.systemPrompt?.trim()) return defaultConfig.systemPrompt.trim(); + return undefined; +} diff --git a/src/config/types.messages.ts b/src/config/types.messages.ts index 37ef4e942..5539de256 100644 --- a/src/config/types.messages.ts +++ b/src/config/types.messages.ts @@ -8,6 +8,8 @@ export type GroupChatConfig = { export type DmConfig = { historyLimit?: number; + /** Custom system prompt for this DM conversation. */ + systemPrompt?: string; }; export type QueueConfig = { diff --git a/src/config/types.signal.ts b/src/config/types.signal.ts index 014f62841..b7ab4fe6e 100644 --- a/src/config/types.signal.ts +++ b/src/config/types.signal.ts @@ -6,10 +6,20 @@ import type { } from "./types.base.js"; import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; import type { DmConfig } from "./types.messages.js"; +import type { GroupToolPolicyConfig } from "./types.tools.js"; export type SignalReactionNotificationMode = "off" | "own" | "all" | "allowlist"; export type SignalReactionLevel = "off" | "ack" | "minimal" | "extensive"; +export type SignalGroupConfig = { + /** Require explicit bot mention in group messages (default: true). */ + requireMention?: boolean; + /** Tool policy overrides for this group. */ + tools?: GroupToolPolicyConfig; + /** Custom system prompt for this group. */ + systemPrompt?: string; +}; + export type SignalAccountConfig = { /** Optional display name for this account (used in CLI/UI lists). */ name?: string; @@ -57,6 +67,8 @@ export type SignalAccountConfig = { dmHistoryLimit?: number; /** Per-DM config overrides keyed by user ID. */ dms?: Record; + /** Per-group config overrides keyed by group ID. */ + groups?: Record; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; /** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */ diff --git a/src/config/types.whatsapp.ts b/src/config/types.whatsapp.ts index 84d7379fd..3a817dabc 100644 --- a/src/config/types.whatsapp.ts +++ b/src/config/types.whatsapp.ts @@ -70,6 +70,8 @@ export type WhatsAppConfig = { { requireMention?: boolean; tools?: GroupToolPolicyConfig; + /** Custom system prompt for this group. */ + systemPrompt?: string; } >; /** Acknowledgment reaction sent immediately upon message receipt. */ @@ -135,6 +137,8 @@ export type WhatsAppAccountConfig = { { requireMention?: boolean; tools?: GroupToolPolicyConfig; + /** Custom system prompt for this group. */ + systemPrompt?: string; } >; /** Acknowledgment reaction sent immediately upon message receipt. */ diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 4a8c80bcc..56c1eba3c 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -92,6 +92,7 @@ export const GroupChatSchema = z export const DmConfigSchema = z .object({ historyLimit: z.number().int().min(0).optional(), + systemPrompt: z.string().optional(), }) .strict(); diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 4b1b9338a..528b3638a 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -474,6 +474,14 @@ export const SlackConfigSchema = SlackAccountSchema.extend({ } }); +export const SignalGroupSchema = z + .object({ + requireMention: z.boolean().optional(), + tools: ToolPolicySchema, + systemPrompt: z.string().optional(), + }) + .strict(); + export const SignalAccountSchemaBase = z .object({ name: z.string().optional(), @@ -499,6 +507,7 @@ export const SignalAccountSchemaBase = z historyLimit: z.number().int().min(0).optional(), dmHistoryLimit: z.number().int().min(0).optional(), dms: z.record(z.string(), DmConfigSchema.optional()).optional(), + groups: z.record(z.string(), SignalGroupSchema.optional()).optional(), textChunkLimit: z.number().int().positive().optional(), chunkMode: z.enum(["length", "newline"]).optional(), blockStreaming: z.boolean().optional(), diff --git a/src/config/zod-schema.providers-whatsapp.ts b/src/config/zod-schema.providers-whatsapp.ts index 7266f8bf6..e42045671 100644 --- a/src/config/zod-schema.providers-whatsapp.ts +++ b/src/config/zod-schema.providers-whatsapp.ts @@ -41,6 +41,7 @@ export const WhatsAppAccountSchema = z .object({ requireMention: z.boolean().optional(), tools: ToolPolicySchema, + systemPrompt: z.string().optional(), }) .strict() .optional(), @@ -105,6 +106,7 @@ export const WhatsAppConfigSchema = z .object({ requireMention: z.boolean().optional(), tools: ToolPolicySchema, + systemPrompt: z.string().optional(), }) .strict() .optional(), diff --git a/src/signal/monitor/event-handler.ts b/src/signal/monitor/event-handler.ts index 72195ff78..4ad9b6caf 100644 --- a/src/signal/monitor/event-handler.ts +++ b/src/signal/monitor/event-handler.ts @@ -20,6 +20,10 @@ import { logInboundDrop, logTypingFailure } from "../../channels/logging.js"; import { createReplyPrefixContext } from "../../channels/reply-prefix.js"; import { recordInboundSession } from "../../channels/session.js"; import { createTypingCallbacks } from "../../channels/typing.js"; +import { + resolveChannelDmSystemPrompt, + resolveChannelGroupSystemPrompt, +} from "../../config/group-policy.js"; import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js"; import { danger, logVerbose, shouldLogVerbose } from "../../globals.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; @@ -123,6 +127,22 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { }); } const signalTo = entry.isGroup ? `group:${entry.groupId}` : `signal:${entry.senderRecipient}`; + + // Resolve system prompt from group or DM config + const groupSystemPrompt = entry.isGroup + ? resolveChannelGroupSystemPrompt({ + cfg: deps.cfg, + channel: "signal", + groupId: entry.groupId, + accountId: deps.accountId, + }) + : resolveChannelDmSystemPrompt({ + cfg: deps.cfg, + channel: "signal", + dmId: entry.senderPeerId, + accountId: deps.accountId, + }); + const ctxPayload = finalizeInboundContext({ Body: combinedBody, RawBody: entry.bodyText, @@ -148,6 +168,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { CommandAuthorized: entry.commandAuthorized, OriginatingChannel: "signal" as const, OriginatingTo: signalTo, + GroupSystemPrompt: groupSystemPrompt, }); await recordInboundSession({ diff --git a/src/web/auto-reply/monitor/process-message.ts b/src/web/auto-reply/monitor/process-message.ts index a10b07bcd..d70774e82 100644 --- a/src/web/auto-reply/monitor/process-message.ts +++ b/src/web/auto-reply/monitor/process-message.ts @@ -16,6 +16,10 @@ import { finalizeInboundContext } from "../../../auto-reply/reply/inbound-contex import { toLocationContext } from "../../../channels/location.js"; import { createReplyPrefixContext } from "../../../channels/reply-prefix.js"; import type { loadConfig } from "../../../config/config.js"; +import { + resolveChannelDmSystemPrompt, + resolveChannelGroupSystemPrompt, +} from "../../../config/group-policy.js"; import { readSessionUpdatedAt, recordSessionMetaFromInbound, @@ -255,6 +259,22 @@ export async function processMessage(params: { ? (resolveIdentityNamePrefix(params.cfg, params.route.agentId) ?? "[clawdbot]") : undefined); + // Resolve system prompt from group or DM config + const groupSystemPrompt = + params.msg.chatType === "group" + ? resolveChannelGroupSystemPrompt({ + cfg: params.cfg, + channel: "whatsapp", + groupId: conversationId, + accountId: params.route.accountId, + }) + : resolveChannelDmSystemPrompt({ + cfg: params.cfg, + channel: "whatsapp", + dmId: dmRouteTarget, + accountId: params.route.accountId, + }); + const ctxPayload = finalizeInboundContext({ Body: combinedBody, RawBody: params.msg.body, @@ -288,6 +308,7 @@ export async function processMessage(params: { Surface: "whatsapp", OriginatingChannel: "whatsapp", OriginatingTo: params.msg.from, + GroupSystemPrompt: groupSystemPrompt, }); if (dmRouteTarget) {