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
This commit is contained in:
Matt Suiche 2026-01-26 14:34:23 +04:00
parent 6859e1e6a6
commit 4e515ceb89
9 changed files with 138 additions and 0 deletions

View File

@ -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<string, { dms?: ChannelDmsConfig }>;
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;
}

View File

@ -8,6 +8,8 @@ export type GroupChatConfig = {
export type DmConfig = {
historyLimit?: number;
/** Custom system prompt for this DM conversation. */
systemPrompt?: string;
};
export type QueueConfig = {

View File

@ -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<string, DmConfig>;
/** Per-group config overrides keyed by group ID. */
groups?: Record<string, SignalGroupConfig>;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */

View File

@ -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. */

View File

@ -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();

View File

@ -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(),

View File

@ -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(),

View File

@ -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({

View File

@ -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) {