From 15e468f5ddd20912e94c691068d5750efd361651 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 22 Dec 2025 19:32:12 +0100 Subject: [PATCH] feat: add group chat activation mode --- docs/configuration.md | 8 ++- docs/group-messages.md | 10 +++- src/agents/system-prompt.ts | 1 + src/auto-reply/reply.ts | 23 +++++++- src/auto-reply/tokens.ts | 2 + src/config/config.ts | 6 ++ src/config/group-chat.ts | 11 ++++ src/web/auto-reply.test.ts | 76 +++++++++++++++++++++++++ src/web/auto-reply.ts | 109 +++++++++++++++++++++++++++++++++--- 9 files changed, 232 insertions(+), 14 deletions(-) create mode 100644 src/auto-reply/tokens.ts create mode 100644 src/config/group-chat.ts diff --git a/docs/configuration.md b/docs/configuration.md index a4e5b9e10..81834a68a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -73,13 +73,13 @@ Allowlist of E.164 phone numbers that may trigger auto-replies. ### `inbound.groupChat` -Group messages default to **require mention** (either metadata mention or regex patterns). +Group messages default to **require mention** (either metadata mention or regex patterns). You can switch to always-on activation. ```json5 { inbound: { groupChat: { - requireMention: true, + activation: "mention", // mention | always mentionPatterns: ["@clawd", "clawdbot", "clawd"], historyLimit: 50 } @@ -87,6 +87,10 @@ Group messages default to **require mention** (either metadata mention or regex } ``` +Notes: +- `activation` defaults to `mention`. +- `requireMention` is still supported for backwards compatibility (`false` ≈ `activation: "always"`). + ### `inbound.workspace` Sets the **single global workspace directory** used by the agent for file operations. diff --git a/docs/group-messages.md b/docs/group-messages.md index 0ae9b3252..2d6de89c5 100644 --- a/docs/group-messages.md +++ b/docs/group-messages.md @@ -8,13 +8,13 @@ read_when: Goal: let Clawd sit in WhatsApp groups, wake up only when pinged, and keep that thread separate from the personal DM session. ## What’s implemented (2025-12-03) -- Mentions required by default: real WhatsApp @-mentions (via `mentionedJids`), regex patterns, or the bot’s E.164 anywhere in the text all count. +- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token (see below). - Group allowlist bypass: we still enforce `allowFrom` on the participant at inbox ingest, but group JIDs themselves no longer block replies. - Per-group sessions: session keys look like `group:` so commands such as `/verbose on` or `/think:high` are scoped to that group; personal DM state is untouched. Heartbeats are skipped for group threads. - Context injection: last N (default 50) group messages are prefixed under `[Chat messages since your last reply - for context]`, with the triggering line under `[Current message - respond to this]`. - Sender surfacing: every group batch now ends with `[from: Sender Name (+E164)]` so Pi knows who is speaking. - Ephemeral/view-once: we unwrap those before extracting text/mentions, so pings inside them still trigger. -- New session primer: on the first turn of a group session we now prepend a short blurb to the model like `You are replying inside the WhatsApp group "". Group members: +44..., +43..., … Address the specific sender noted in the message context.` If metadata isn’t available we still tell the agent it’s a group chat. +- New session primer: on the first turn of a group session we now prepend a short blurb to the model like `You are replying inside the WhatsApp group "". Group members: Alice (+44...), Bob (+43...), … Activation: trigger-only … Address the specific sender noted in the message context.` If metadata isn’t available we still tell the agent it’s a group chat. ## Config for Clawd UK (+447700900123) Add a `groupChat` block to `~/.clawdis/clawdis.json` so display-name pings work even when WhatsApp strips the visual `@` in the text body: @@ -23,7 +23,7 @@ Add a `groupChat` block to `~/.clawdis/clawdis.json` so display-name pings work { "inbound": { "groupChat": { - "requireMention": true, + "activation": "mention", "historyLimit": 50, "mentionPatterns": [ "@?clawd", @@ -40,6 +40,10 @@ Notes: - The regexes are case-insensitive; they cover `@clawd`, `@clawd uk`, `clawdbot`, and the raw number with or without `+`/spaces. - WhatsApp still sends canonical mentions via `mentionedJids` when someone taps the contact, so the number fallback is rarely needed but is a good safety net. +### Always-on mode + +Set `"activation": "always"` to wake on every group message. In this mode the agent is instructed to return `NO_REPLY` (exact token) when it decides no reply is necessary, and Clawdis will suppress the outbound message. + ## How to use 1) Add Clawd UK (`+447700900123`) to the group. 2) Say `@clawd …` (or `@clawd uk`, `@clawdbot`, or include the number). Anyone in the group can trigger it. diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 99a080cb3..7e4ef6698 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -29,6 +29,7 @@ export function buildAgentSystemPromptAppend(params: { "", "## Messaging Safety", "Never send streaming/partial replies to external messaging surfaces; only final replies should be delivered there.", + "Clawdis handles message transport automatically; respond normally and your reply will be delivered to the current chat.", "", "## Heartbeats", 'If you receive a heartbeat poll (a user message containing just "HEARTBEAT"), and there is nothing that needs attention, reply exactly:', diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 7c4c1ca95..b450968a0 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -26,6 +26,7 @@ import { type SessionEntry, saveSessionStore, } from "../config/sessions.js"; +import { resolveGroupChatActivation } from "../config/group-chat.js"; import { logVerbose } from "../globals.js"; import { buildProviderSummary } from "../infra/provider-summary.js"; import { triggerClawdisRestart } from "../infra/restart.js"; @@ -43,6 +44,7 @@ import { } from "./thinking.js"; import { isAudio, transcribeInboundAudio } from "./transcription.js"; import type { GetReplyOptions, ReplyPayload } from "./types.js"; +import { SILENT_REPLY_TOKEN } from "./tokens.js"; export type { GetReplyOptions, ReplyPayload } from "./types.js"; @@ -583,6 +585,7 @@ export async function getReplyFromConfig( const groupIntro = isFirstTurnInSession && sessionCtx.ChatType === "group" ? (() => { + const activation = resolveGroupChatActivation(cfg); const subject = sessionCtx.GroupSubject?.trim(); const members = sessionCtx.GroupMembers?.trim(); const subjectLine = subject @@ -591,7 +594,25 @@ export async function getReplyFromConfig( const membersLine = members ? `Group members: ${members}.` : undefined; - return [subjectLine, membersLine] + const activationLine = + activation === "always" + ? "Activation: always-on (you receive every group message)." + : "Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included)."; + const silenceLine = + activation === "always" + ? `If no response is needed, reply with exactly "${SILENT_REPLY_TOKEN}" (no other text) so Clawdis stays silent.` + : undefined; + const cautionLine = + activation === "always" + ? "Be extremely selective: reply only when you are directly addressed, asked a question, or can add clear value. Otherwise stay silent." + : undefined; + return [ + subjectLine, + membersLine, + activationLine, + silenceLine, + cautionLine, + ] .filter(Boolean) .join(" ") .concat( diff --git a/src/auto-reply/tokens.ts b/src/auto-reply/tokens.ts new file mode 100644 index 000000000..917ddd095 --- /dev/null +++ b/src/auto-reply/tokens.ts @@ -0,0 +1,2 @@ +export const HEARTBEAT_TOKEN = "HEARTBEAT_OK"; +export const SILENT_REPLY_TOKEN = "NO_REPLY"; diff --git a/src/config/config.ts b/src/config/config.ts index 9f0bb55ea..b8c705212 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -73,7 +73,10 @@ export type TelegramConfig = { webhookPath?: string; }; +export type GroupChatActivationMode = "mention" | "always"; + export type GroupChatConfig = { + activation?: GroupChatActivationMode; requireMention?: boolean; mentionPatterns?: string[]; historyLimit?: number; @@ -289,6 +292,9 @@ const ClawdisSchema = z.object({ timestampPrefix: z.union([z.boolean(), z.string()]).optional(), groupChat: z .object({ + activation: z + .union([z.literal("mention"), z.literal("always")]) + .optional(), requireMention: z.boolean().optional(), mentionPatterns: z.array(z.string()).optional(), historyLimit: z.number().int().positive().optional(), diff --git a/src/config/group-chat.ts b/src/config/group-chat.ts new file mode 100644 index 000000000..5363436a1 --- /dev/null +++ b/src/config/group-chat.ts @@ -0,0 +1,11 @@ +import type { ClawdisConfig, GroupChatActivationMode } from "./config.js"; + +export function resolveGroupChatActivation( + cfg?: ClawdisConfig, +): GroupChatActivationMode { + const groupChat = cfg?.inbound?.groupChat; + if (groupChat?.activation === "always") return "always"; + if (groupChat?.activation === "mention") return "mention"; + if (groupChat?.requireMention === false) return "always"; + return "mention"; +} diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index cf44185be..40d9736e8 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -19,6 +19,7 @@ import * as commandQueue from "../process/command-queue.js"; import { HEARTBEAT_PROMPT, HEARTBEAT_TOKEN, + SILENT_REPLY_TOKEN, monitorWebProvider, resolveHeartbeatRecipients, resolveReplyHeartbeatMinutes, @@ -1431,6 +1432,81 @@ describe("web auto-reply", () => { expect(payload.Body).toContain("[from: Bob (+222)]"); }); + it("supports always-on group activation with silent token and preserves history", async () => { + const sendMedia = vi.fn(); + const reply = vi.fn().mockResolvedValue(undefined); + const sendComposing = vi.fn(); + const resolver = vi + .fn() + .mockResolvedValueOnce({ text: SILENT_REPLY_TOKEN }) + .mockResolvedValueOnce({ text: "ok" }); + + setLoadConfigMock(() => ({ + inbound: { + groupChat: { activation: "always", mentionPatterns: ["@clawd"] }, + }, + })); + + let capturedOnMessage: + | ((msg: import("./inbound.js").WebInboundMessage) => Promise) + | undefined; + const listenerFactory = async (opts: { + onMessage: ( + msg: import("./inbound.js").WebInboundMessage, + ) => Promise; + }) => { + capturedOnMessage = opts.onMessage; + return { close: vi.fn() }; + }; + + await monitorWebProvider(false, listenerFactory, false, resolver); + expect(capturedOnMessage).toBeDefined(); + + await capturedOnMessage?.({ + body: "first", + from: "123@g.us", + conversationId: "123@g.us", + chatId: "123@g.us", + chatType: "group", + to: "+2", + id: "g-always-1", + senderE164: "+111", + senderName: "Alice", + selfE164: "+999", + sendComposing, + reply, + sendMedia, + }); + + expect(resolver).toHaveBeenCalledTimes(1); + expect(reply).not.toHaveBeenCalled(); + + await capturedOnMessage?.({ + body: "second", + from: "123@g.us", + conversationId: "123@g.us", + chatId: "123@g.us", + chatType: "group", + to: "+2", + id: "g-always-2", + senderE164: "+222", + senderName: "Bob", + selfE164: "+999", + sendComposing, + reply, + sendMedia, + }); + + expect(resolver).toHaveBeenCalledTimes(2); + const payload = resolver.mock.calls[1][0]; + expect(payload.Body).toContain("Chat messages since your last reply"); + expect(payload.Body).toContain("Alice: first"); + expect(payload.Body).toContain("Bob: second"); + expect(reply).toHaveBeenCalledTimes(1); + + resetLoadConfigMock(); + }); + it("ignores JID mentions in self-chat mode (group chats)", async () => { const sendMedia = vi.fn(); const reply = vi.fn().mockResolvedValue(undefined); diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 69e9795b1..c809cc88b 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -2,8 +2,13 @@ import { chunkText } from "../auto-reply/chunk.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; import type { ReplyPayload } from "../auto-reply/types.js"; +import { + HEARTBEAT_TOKEN, + SILENT_REPLY_TOKEN, +} from "../auto-reply/tokens.js"; import { waitForever } from "../cli/wait.js"; import { loadConfig } from "../config/config.js"; +import { resolveGroupChatActivation } from "../config/group-chat.js"; import { DEFAULT_IDLE_MINUTES, loadSessionStore, @@ -77,8 +82,8 @@ const formatDuration = (ms: number) => ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`; const DEFAULT_REPLY_HEARTBEAT_MINUTES = 30; -export const HEARTBEAT_TOKEN = "HEARTBEAT_OK"; export const HEARTBEAT_PROMPT = "HEARTBEAT"; +export { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN }; export type WebProviderStatus = { running: boolean; @@ -110,7 +115,9 @@ type MentionConfig = { function buildMentionConfig(cfg: ReturnType): MentionConfig { const gc = cfg.inbound?.groupChat; - const requireMention = gc?.requireMention !== false; // default true + const activation = resolveGroupChatActivation(cfg); + const requireMention = + activation === "always" ? false : gc?.requireMention !== false; // default true const mentionRegexes = gc?.mentionPatterns ?.map((p) => { @@ -207,6 +214,14 @@ export function stripHeartbeatToken(raw?: string) { }; } +function isSilentReply(payload?: ReplyPayload): boolean { + if (!payload) return false; + const text = payload.text?.trim(); + if (!text || text !== SILENT_REPLY_TOKEN) return false; + if (payload.mediaUrl || payload.mediaUrls?.length) return false; + return true; +} + export async function runWebHeartbeatOnce(opts: { cfg?: ReturnType; to: string; @@ -760,6 +775,7 @@ export async function monitorWebProvider( string, Array<{ sender: string; body: string; timestamp?: number }> >(); + const groupMemberNames = new Map>(); const sleep = tuning.sleep ?? ((ms: number, signal?: AbortSignal) => @@ -773,6 +789,60 @@ export async function monitorWebProvider( }), ); + const noteGroupMember = ( + conversationId: string, + e164?: string, + name?: string, + ) => { + if (!e164 || !name) return; + const normalized = normalizeE164(e164); + const key = normalized ?? e164; + if (!key) return; + let roster = groupMemberNames.get(conversationId); + if (!roster) { + roster = new Map(); + groupMemberNames.set(conversationId, roster); + } + roster.set(key, name); + }; + + const formatGroupMembers = ( + participants: string[] | undefined, + roster: Map | undefined, + fallbackE164?: string, + ) => { + const seen = new Set(); + const ordered: string[] = []; + if (participants?.length) { + for (const entry of participants) { + if (!entry) continue; + const normalized = normalizeE164(entry) ?? entry; + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + ordered.push(normalized); + } + } + if (roster) { + for (const entry of roster.keys()) { + const normalized = normalizeE164(entry) ?? entry; + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + ordered.push(normalized); + } + } + if (ordered.length === 0 && fallbackE164) { + const normalized = normalizeE164(fallbackE164) ?? fallbackE164; + if (normalized) ordered.push(normalized); + } + if (ordered.length === 0) return undefined; + return ordered + .map((entry) => { + const name = roster?.get(entry); + return name ? `${name} (${entry})` : entry; + }) + .join(", "); + }; + // Avoid noisy MaxListenersExceeded warnings in test environments where // multiple gateway instances may be constructed. const currentMaxListeners = process.getMaxListeners?.() ?? 10; @@ -843,6 +913,7 @@ export async function monitorWebProvider( emitStatus(); const conversationId = msg.conversationId ?? msg.from; let combinedBody = buildLine(msg); + let shouldClearGroupHistory = false; if (msg.chatType === "group") { const history = groupHistories.get(conversationId) ?? []; @@ -867,8 +938,7 @@ export async function monitorWebProvider( ? `${msg.senderName} (${msg.senderE164})` : (msg.senderName ?? msg.senderE164 ?? "Unknown"); combinedBody = `${combinedBody}\\n[from: ${senderLabel}]`; - // Clear stored history after using it - groupHistories.set(conversationId, []); + shouldClearGroupHistory = true; } // Echo detection uses combined body so we don't respond twice. @@ -933,6 +1003,7 @@ export async function monitorWebProvider( } const responsePrefix = cfg.inbound?.responsePrefix; + let didSendReply = false; let toolSendChain: Promise = Promise.resolve(); const sendToolResult = (payload: ReplyPayload) => { if ( @@ -942,6 +1013,7 @@ export async function monitorWebProvider( ) { return; } + if (isSilentReply(payload)) return; const toolPayload: ReplyPayload = { ...payload }; if ( responsePrefix && @@ -961,6 +1033,7 @@ export async function monitorWebProvider( connectionId, skipLog: true, }); + didSendReply = true; if (toolPayload.text) { recentlySent.add(toolPayload.text); if (recentlySent.size > MAX_RECENT_MESSAGES) { @@ -987,7 +1060,11 @@ export async function monitorWebProvider( MediaType: msg.mediaType, ChatType: msg.chatType, GroupSubject: msg.groupSubject, - GroupMembers: msg.groupParticipants?.join(", "), + GroupMembers: formatGroupMembers( + msg.groupParticipants, + groupMemberNames.get(conversationId), + msg.senderE164, + ), SenderName: msg.senderName, SenderE164: msg.senderE164, Surface: "whatsapp", @@ -1004,14 +1081,24 @@ export async function monitorWebProvider( : [replyResult] : []; - if (replyList.length === 0) { - logVerbose("Skipping auto-reply: no text/media returned from resolver"); + const sendableReplies = replyList.filter( + (payload) => !isSilentReply(payload), + ); + + if (sendableReplies.length === 0) { + await toolSendChain; + if (shouldClearGroupHistory && didSendReply) { + groupHistories.set(conversationId, []); + } + logVerbose( + "Skipping auto-reply: silent token or no text/media returned from resolver", + ); return; } await toolSendChain; - for (const replyPayload of replyList) { + for (const replyPayload of sendableReplies) { if ( responsePrefix && replyPayload.text && @@ -1029,6 +1116,7 @@ export async function monitorWebProvider( replyLogger, connectionId, }); + didSendReply = true; if (replyPayload.text) { recentlySent.add(replyPayload.text); @@ -1065,6 +1153,10 @@ export async function monitorWebProvider( ); } } + + if (shouldClearGroupHistory && didSendReply) { + groupHistories.set(conversationId, []); + } }; const listener = await (listenerFactory ?? monitorWebInbox)({ @@ -1096,6 +1188,7 @@ export async function monitorWebProvider( } if (msg.chatType === "group") { + noteGroupMember(conversationId, msg.senderE164, msg.senderName); const history = groupHistories.get(conversationId) ?? ([] as Array<{ sender: string; body: string; timestamp?: number }>);