From 2977b296e6663d7e4f07fc58c32f5013e71c83ef Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 19:28:59 +0000 Subject: [PATCH] feat(messages): add whatsapp messagePrefix and responsePrefix auto --- docs/concepts/messages.md | 2 +- docs/gateway/configuration.md | 13 +++--- docs/providers/whatsapp.md | 2 +- src/agents/identity.test.ts | 65 +++++++++++++++++++++++++++++ src/agents/identity.ts | 13 ++++-- src/auto-reply/reply/route-reply.ts | 4 +- src/config/types.ts | 18 +++++++- src/config/zod-schema.ts | 2 + src/web/accounts.ts | 5 +++ src/web/auto-reply.ts | 4 +- 10 files changed, 112 insertions(+), 16 deletions(-) create mode 100644 src/agents/identity.test.ts diff --git a/docs/concepts/messages.md b/docs/concepts/messages.md index 7f06ae7a4..707edf13c 100644 --- a/docs/concepts/messages.md +++ b/docs/concepts/messages.md @@ -77,7 +77,7 @@ Details: [Thinking + reasoning directives](/tools/thinking) and [Token use](/tok ## Prefixes, threading, and replies Outbound message formatting is centralized in `messages`: -- `messages.responsePrefix` and `messages.messagePrefix` +- `messages.responsePrefix` (outbound prefix) and `whatsapp.messagePrefix` (WhatsApp inbound prefix) - Reply threading via `replyToMode` and per-provider defaults Details: [Configuration](/gateway/configuration#messages) and provider docs. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 67044d2d4..6d29ef009 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -926,8 +926,7 @@ See [Messages](/concepts/messages) for queueing, sessions, and streaming context ```json5 { messages: { - messagePrefix: "[clawdbot]", - responsePrefix: "🦞", + responsePrefix: "🦞", // or "auto" ackReaction: "👀", ackReactionScope: "group-mentions" } @@ -938,11 +937,13 @@ See [Messages](/concepts/messages) for queueing, sessions, and streaming context streaming, final replies) across providers unless already present. If `messages.responsePrefix` is unset, no prefix is applied by default. +Set it to `"auto"` to derive `[{identity.name}]` for the routed agent (when set). -If `messages.messagePrefix` is unset, the default stays **unchanged**: -`"[clawdbot]"` when `whatsapp.allowFrom` is empty, otherwise `""` (no prefix). -When using `"[clawdbot]"`, Clawdbot will instead use `[{identity.name}]` when -the routed agent has `identity.name` set. +WhatsApp inbound prefix is configured via `whatsapp.messagePrefix` (deprecated: +`messages.messagePrefix`). Default stays **unchanged**: `"[clawdbot]"` when +`whatsapp.allowFrom` is empty, otherwise `""` (no prefix). When using +`"[clawdbot]"`, Clawdbot will instead use `[{identity.name}]` when the routed +agent has `identity.name` set. `ackReaction` sends a best-effort emoji reaction to acknowledge inbound messages on providers that support reactions (Slack/Discord/Telegram). Defaults to the diff --git a/docs/providers/whatsapp.md b/docs/providers/whatsapp.md index f11c98ab6..c78182131 100644 --- a/docs/providers/whatsapp.md +++ b/docs/providers/whatsapp.md @@ -197,7 +197,7 @@ Behavior: - `whatsapp.actions.reactions` (gate WhatsApp tool reactions). - `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) - `messages.groupChat.historyLimit` -- `messages.messagePrefix` (inbound prefix) +- `whatsapp.messagePrefix` (inbound prefix; per-account: `whatsapp.accounts..messagePrefix`; deprecated: `messages.messagePrefix`) - `messages.responsePrefix` (outbound prefix) - `agents.defaults.mediaMaxMb` - `agents.defaults.heartbeat.every` diff --git a/src/agents/identity.test.ts b/src/agents/identity.test.ts new file mode 100644 index 000000000..2efe04cfb --- /dev/null +++ b/src/agents/identity.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import type { ClawdbotConfig } from "../config/config.js"; +import { resolveMessagePrefix, resolveResponsePrefix } from "./identity.js"; + +describe("message prefix resolution", () => { + it("returns configured messagePrefix override", () => { + const cfg: ClawdbotConfig = {}; + expect( + resolveMessagePrefix(cfg, "main", { + configured: "[x]", + hasAllowFrom: true, + }), + ).toBe("[x]"); + expect( + resolveMessagePrefix(cfg, "main", { + configured: "", + hasAllowFrom: false, + }), + ).toBe(""); + }); + + it("defaults messagePrefix based on allowFrom + identity", () => { + const cfg: ClawdbotConfig = { + agents: { list: [{ id: "main", identity: { name: "Richbot" } }] }, + }; + expect(resolveMessagePrefix(cfg, "main", { hasAllowFrom: true })).toBe(""); + expect(resolveMessagePrefix(cfg, "main", { hasAllowFrom: false })).toBe( + "[Richbot]", + ); + }); + + it("falls back to [clawdbot] when identity is missing", () => { + const cfg: ClawdbotConfig = {}; + expect(resolveMessagePrefix(cfg, "main", { hasAllowFrom: false })).toBe( + "[clawdbot]", + ); + }); +}); + +describe("response prefix resolution", () => { + it("does not apply any default when unset", () => { + const cfg: ClawdbotConfig = { + agents: { list: [{ id: "main", identity: { name: "Richbot" } }] }, + }; + expect(resolveResponsePrefix(cfg, "main")).toBeUndefined(); + }); + + it("returns explicit responsePrefix when set", () => { + const cfg: ClawdbotConfig = { messages: { responsePrefix: "PFX" } }; + expect(resolveResponsePrefix(cfg, "main")).toBe("PFX"); + }); + + it("supports responsePrefix: auto (identity-derived opt-in)", () => { + const withIdentity: ClawdbotConfig = { + agents: { list: [{ id: "main", identity: { name: "Richbot" } }] }, + messages: { responsePrefix: "auto" }, + }; + expect(resolveResponsePrefix(withIdentity, "main")).toBe("[Richbot]"); + + const withoutIdentity: ClawdbotConfig = { + messages: { responsePrefix: "auto" }, + }; + expect(resolveResponsePrefix(withoutIdentity, "main")).toBeUndefined(); + }); +}); diff --git a/src/agents/identity.ts b/src/agents/identity.ts index 764f736e8..8efb8605b 100644 --- a/src/agents/identity.ts +++ b/src/agents/identity.ts @@ -32,9 +32,9 @@ export function resolveIdentityNamePrefix( export function resolveMessagePrefix( cfg: ClawdbotConfig, agentId: string, - opts?: { hasAllowFrom?: boolean; fallback?: string }, + opts?: { configured?: string; hasAllowFrom?: boolean; fallback?: string }, ): string { - const configured = cfg.messages?.messagePrefix; + const configured = opts?.configured ?? cfg.messages?.messagePrefix; if (configured !== undefined) return configured; const hasAllowFrom = opts?.hasAllowFrom === true; @@ -47,10 +47,15 @@ export function resolveMessagePrefix( export function resolveResponsePrefix( cfg: ClawdbotConfig, - _agentId: string, + agentId: string, ): string | undefined { const configured = cfg.messages?.responsePrefix; - if (configured !== undefined) return configured; + if (configured !== undefined) { + if (configured === "auto") { + return resolveIdentityNamePrefix(cfg, agentId); + } + return configured; + } return undefined; } diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index 0b25b1740..0258ef797 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -69,7 +69,9 @@ export async function routeReply( cfg, resolveAgentIdFromSessionKey(params.sessionKey), ).responsePrefix - : cfg.messages?.responsePrefix; + : cfg.messages?.responsePrefix === "auto" + ? undefined + : cfg.messages?.responsePrefix; const normalized = normalizeReplyPayload(payload, { responsePrefix, }); diff --git a/src/config/types.ts b/src/config/types.ts index c80a231b1..c59d35a6e 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -111,6 +111,11 @@ export type WhatsAppActionConfig = { export type WhatsAppConfig = { /** Optional per-account WhatsApp configuration (multi-account). */ accounts?: Record; + /** + * Inbound message prefix (WhatsApp only). + * Default: `[{agents.list[].identity.name}]` (or `[clawdbot]`) when allowFrom is empty, else `""`. + */ + messagePrefix?: string; /** Direct message access policy (default: pairing). */ dmPolicy?: DmPolicy; /** @@ -152,6 +157,8 @@ export type WhatsAppAccountConfig = { name?: string; /** If false, do not start this WhatsApp account provider. Default: true. */ enabled?: boolean; + /** Inbound message prefix override for this account (WhatsApp only). */ + messagePrefix?: string; /** Override auth directory (Baileys multi-file auth state). */ authDir?: string; /** Direct message access policy (default: pairing). */ @@ -931,8 +938,15 @@ export type AudioConfig = { }; export type MessagesConfig = { - messagePrefix?: string; // Prefix added to all inbound messages (default: "[{agents.list[].identity.name}]" or "[clawdbot]" if no allowFrom, else "") - responsePrefix?: string; // Prefix auto-added to all outbound replies (default: none) + /** @deprecated Use `whatsapp.messagePrefix` (WhatsApp-only inbound prefix). */ + messagePrefix?: string; + /** + * Prefix auto-added to all outbound replies. + * - string: explicit prefix + * - special value: `"auto"` derives `[{agents.list[].identity.name}]` for the routed agent (when set) + * Default: none + */ + responsePrefix?: string; groupChat?: GroupChatConfig; queue?: QueueConfig; /** Emoji reaction used to acknowledge inbound messages (empty disables). */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 5dec868fb..711f60643 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -1229,6 +1229,7 @@ export const ClawdbotSchema = z.object({ .object({ name: z.string().optional(), enabled: z.boolean().optional(), + messagePrefix: z.string().optional(), /** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */ authDir: z.string().optional(), dmPolicy: DmPolicySchema.optional().default("pairing"), @@ -1268,6 +1269,7 @@ export const ClawdbotSchema = z.object({ ) .optional(), dmPolicy: DmPolicySchema.optional().default("pairing"), + messagePrefix: z.string().optional(), selfChatMode: z.boolean().optional(), allowFrom: z.array(z.string()).optional(), groupAllowFrom: z.array(z.string()).optional(), diff --git a/src/web/accounts.ts b/src/web/accounts.ts index 131e427a8..4d71c2a0f 100644 --- a/src/web/accounts.ts +++ b/src/web/accounts.ts @@ -15,6 +15,7 @@ export type ResolvedWhatsAppAccount = { accountId: string; name?: string; enabled: boolean; + messagePrefix?: string; authDir: string; isLegacyAuthDir: boolean; selfChatMode?: boolean; @@ -111,6 +112,10 @@ export function resolveWhatsAppAccount(params: { accountId, name: accountCfg?.name?.trim() || undefined, enabled, + messagePrefix: + accountCfg?.messagePrefix ?? + params.cfg.whatsapp?.messagePrefix ?? + params.cfg.messages?.messagePrefix, authDir, isLegacyAuthDir: isLegacy, selfChatMode: accountCfg?.selfChatMode ?? params.cfg.whatsapp?.selfChatMode, diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index f22148864..52218ca6e 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -784,6 +784,7 @@ export async function monitorWebProvider( ...baseCfg, whatsapp: { ...baseCfg.whatsapp, + messagePrefix: account.messagePrefix, allowFrom: account.allowFrom, groupAllowFrom: account.groupAllowFrom, groupPolicy: account.groupPolicy, @@ -1039,8 +1040,9 @@ export async function monitorWebProvider( }; const buildLine = (msg: WebInboundMsg, agentId: string) => { - // Build message prefix: explicit config > identity name > default based on allowFrom + // WhatsApp inbound prefix: whatsapp.messagePrefix > legacy messages.messagePrefix > identity/defaults const messagePrefix = resolveMessagePrefix(cfg, agentId, { + configured: cfg.whatsapp?.messagePrefix, hasAllowFrom: (cfg.whatsapp?.allowFrom?.length ?? 0) > 0, }); const prefixStr = messagePrefix ? `${messagePrefix} ` : "";