feat: add group chat activation mode

main
Peter Steinberger 2025-12-22 19:32:12 +01:00
parent a0dd504991
commit 15e468f5dd
9 changed files with 232 additions and 14 deletions

View File

@ -73,13 +73,13 @@ Allowlist of E.164 phone numbers that may trigger auto-replies.
### `inbound.groupChat` ### `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 ```json5
{ {
inbound: { inbound: {
groupChat: { groupChat: {
requireMention: true, activation: "mention", // mention | always
mentionPatterns: ["@clawd", "clawdbot", "clawd"], mentionPatterns: ["@clawd", "clawdbot", "clawd"],
historyLimit: 50 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` ### `inbound.workspace`
Sets the **single global workspace directory** used by the agent for file operations. Sets the **single global workspace directory** used by the agent for file operations.

View File

@ -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. Goal: let Clawd sit in WhatsApp groups, wake up only when pinged, and keep that thread separate from the personal DM session.
## Whats implemented (2025-12-03) ## Whats implemented (2025-12-03)
- Mentions required by default: real WhatsApp @-mentions (via `mentionedJids`), regex patterns, or the bots 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 bots 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. - 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:<jid>` 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. - Per-group sessions: session keys look like `group:<jid>` 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]`. - 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. - 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. - 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 "<subject>". Group members: +44..., +43..., … Address the specific sender noted in the message context.` If metadata isnt available we still tell the agent its 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 "<subject>". Group members: Alice (+44...), Bob (+43...), … Activation: trigger-only … Address the specific sender noted in the message context.` If metadata isnt available we still tell the agent its a group chat.
## Config for Clawd UK (+447700900123) ## 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: 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": { "inbound": {
"groupChat": { "groupChat": {
"requireMention": true, "activation": "mention",
"historyLimit": 50, "historyLimit": 50,
"mentionPatterns": [ "mentionPatterns": [
"@?clawd", "@?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. - 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. - 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 ## How to use
1) Add Clawd UK (`+447700900123`) to the group. 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. 2) Say `@clawd …` (or `@clawd uk`, `@clawdbot`, or include the number). Anyone in the group can trigger it.

View File

@ -29,6 +29,7 @@ export function buildAgentSystemPromptAppend(params: {
"", "",
"## Messaging Safety", "## Messaging Safety",
"Never send streaming/partial replies to external messaging surfaces; only final replies should be delivered there.", "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", "## Heartbeats",
'If you receive a heartbeat poll (a user message containing just "HEARTBEAT"), and there is nothing that needs attention, reply exactly:', 'If you receive a heartbeat poll (a user message containing just "HEARTBEAT"), and there is nothing that needs attention, reply exactly:',

View File

@ -26,6 +26,7 @@ import {
type SessionEntry, type SessionEntry,
saveSessionStore, saveSessionStore,
} from "../config/sessions.js"; } from "../config/sessions.js";
import { resolveGroupChatActivation } from "../config/group-chat.js";
import { logVerbose } from "../globals.js"; import { logVerbose } from "../globals.js";
import { buildProviderSummary } from "../infra/provider-summary.js"; import { buildProviderSummary } from "../infra/provider-summary.js";
import { triggerClawdisRestart } from "../infra/restart.js"; import { triggerClawdisRestart } from "../infra/restart.js";
@ -43,6 +44,7 @@ import {
} from "./thinking.js"; } from "./thinking.js";
import { isAudio, transcribeInboundAudio } from "./transcription.js"; import { isAudio, transcribeInboundAudio } from "./transcription.js";
import type { GetReplyOptions, ReplyPayload } from "./types.js"; import type { GetReplyOptions, ReplyPayload } from "./types.js";
import { SILENT_REPLY_TOKEN } from "./tokens.js";
export type { GetReplyOptions, ReplyPayload } from "./types.js"; export type { GetReplyOptions, ReplyPayload } from "./types.js";
@ -583,6 +585,7 @@ export async function getReplyFromConfig(
const groupIntro = const groupIntro =
isFirstTurnInSession && sessionCtx.ChatType === "group" isFirstTurnInSession && sessionCtx.ChatType === "group"
? (() => { ? (() => {
const activation = resolveGroupChatActivation(cfg);
const subject = sessionCtx.GroupSubject?.trim(); const subject = sessionCtx.GroupSubject?.trim();
const members = sessionCtx.GroupMembers?.trim(); const members = sessionCtx.GroupMembers?.trim();
const subjectLine = subject const subjectLine = subject
@ -591,7 +594,25 @@ export async function getReplyFromConfig(
const membersLine = members const membersLine = members
? `Group members: ${members}.` ? `Group members: ${members}.`
: undefined; : 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) .filter(Boolean)
.join(" ") .join(" ")
.concat( .concat(

2
src/auto-reply/tokens.ts Normal file
View File

@ -0,0 +1,2 @@
export const HEARTBEAT_TOKEN = "HEARTBEAT_OK";
export const SILENT_REPLY_TOKEN = "NO_REPLY";

View File

@ -73,7 +73,10 @@ export type TelegramConfig = {
webhookPath?: string; webhookPath?: string;
}; };
export type GroupChatActivationMode = "mention" | "always";
export type GroupChatConfig = { export type GroupChatConfig = {
activation?: GroupChatActivationMode;
requireMention?: boolean; requireMention?: boolean;
mentionPatterns?: string[]; mentionPatterns?: string[];
historyLimit?: number; historyLimit?: number;
@ -289,6 +292,9 @@ const ClawdisSchema = z.object({
timestampPrefix: z.union([z.boolean(), z.string()]).optional(), timestampPrefix: z.union([z.boolean(), z.string()]).optional(),
groupChat: z groupChat: z
.object({ .object({
activation: z
.union([z.literal("mention"), z.literal("always")])
.optional(),
requireMention: z.boolean().optional(), requireMention: z.boolean().optional(),
mentionPatterns: z.array(z.string()).optional(), mentionPatterns: z.array(z.string()).optional(),
historyLimit: z.number().int().positive().optional(), historyLimit: z.number().int().positive().optional(),

11
src/config/group-chat.ts Normal file
View File

@ -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";
}

View File

@ -19,6 +19,7 @@ import * as commandQueue from "../process/command-queue.js";
import { import {
HEARTBEAT_PROMPT, HEARTBEAT_PROMPT,
HEARTBEAT_TOKEN, HEARTBEAT_TOKEN,
SILENT_REPLY_TOKEN,
monitorWebProvider, monitorWebProvider,
resolveHeartbeatRecipients, resolveHeartbeatRecipients,
resolveReplyHeartbeatMinutes, resolveReplyHeartbeatMinutes,
@ -1431,6 +1432,81 @@ describe("web auto-reply", () => {
expect(payload.Body).toContain("[from: Bob (+222)]"); 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<void>)
| undefined;
const listenerFactory = async (opts: {
onMessage: (
msg: import("./inbound.js").WebInboundMessage,
) => Promise<void>;
}) => {
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 () => { it("ignores JID mentions in self-chat mode (group chats)", async () => {
const sendMedia = vi.fn(); const sendMedia = vi.fn();
const reply = vi.fn().mockResolvedValue(undefined); const reply = vi.fn().mockResolvedValue(undefined);

View File

@ -2,8 +2,13 @@ import { chunkText } from "../auto-reply/chunk.js";
import { formatAgentEnvelope } from "../auto-reply/envelope.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js";
import { getReplyFromConfig } from "../auto-reply/reply.js"; import { getReplyFromConfig } from "../auto-reply/reply.js";
import type { ReplyPayload } from "../auto-reply/types.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 { waitForever } from "../cli/wait.js";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { resolveGroupChatActivation } from "../config/group-chat.js";
import { import {
DEFAULT_IDLE_MINUTES, DEFAULT_IDLE_MINUTES,
loadSessionStore, loadSessionStore,
@ -77,8 +82,8 @@ const formatDuration = (ms: number) =>
ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`; ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`;
const DEFAULT_REPLY_HEARTBEAT_MINUTES = 30; const DEFAULT_REPLY_HEARTBEAT_MINUTES = 30;
export const HEARTBEAT_TOKEN = "HEARTBEAT_OK";
export const HEARTBEAT_PROMPT = "HEARTBEAT"; export const HEARTBEAT_PROMPT = "HEARTBEAT";
export { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN };
export type WebProviderStatus = { export type WebProviderStatus = {
running: boolean; running: boolean;
@ -110,7 +115,9 @@ type MentionConfig = {
function buildMentionConfig(cfg: ReturnType<typeof loadConfig>): MentionConfig { function buildMentionConfig(cfg: ReturnType<typeof loadConfig>): MentionConfig {
const gc = cfg.inbound?.groupChat; 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 = const mentionRegexes =
gc?.mentionPatterns gc?.mentionPatterns
?.map((p) => { ?.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: { export async function runWebHeartbeatOnce(opts: {
cfg?: ReturnType<typeof loadConfig>; cfg?: ReturnType<typeof loadConfig>;
to: string; to: string;
@ -760,6 +775,7 @@ export async function monitorWebProvider(
string, string,
Array<{ sender: string; body: string; timestamp?: number }> Array<{ sender: string; body: string; timestamp?: number }>
>(); >();
const groupMemberNames = new Map<string, Map<string, string>>();
const sleep = const sleep =
tuning.sleep ?? tuning.sleep ??
((ms: number, signal?: AbortSignal) => ((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<string, string> | undefined,
fallbackE164?: string,
) => {
const seen = new Set<string>();
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 // Avoid noisy MaxListenersExceeded warnings in test environments where
// multiple gateway instances may be constructed. // multiple gateway instances may be constructed.
const currentMaxListeners = process.getMaxListeners?.() ?? 10; const currentMaxListeners = process.getMaxListeners?.() ?? 10;
@ -843,6 +913,7 @@ export async function monitorWebProvider(
emitStatus(); emitStatus();
const conversationId = msg.conversationId ?? msg.from; const conversationId = msg.conversationId ?? msg.from;
let combinedBody = buildLine(msg); let combinedBody = buildLine(msg);
let shouldClearGroupHistory = false;
if (msg.chatType === "group") { if (msg.chatType === "group") {
const history = groupHistories.get(conversationId) ?? []; const history = groupHistories.get(conversationId) ?? [];
@ -867,8 +938,7 @@ export async function monitorWebProvider(
? `${msg.senderName} (${msg.senderE164})` ? `${msg.senderName} (${msg.senderE164})`
: (msg.senderName ?? msg.senderE164 ?? "Unknown"); : (msg.senderName ?? msg.senderE164 ?? "Unknown");
combinedBody = `${combinedBody}\\n[from: ${senderLabel}]`; combinedBody = `${combinedBody}\\n[from: ${senderLabel}]`;
// Clear stored history after using it shouldClearGroupHistory = true;
groupHistories.set(conversationId, []);
} }
// Echo detection uses combined body so we don't respond twice. // Echo detection uses combined body so we don't respond twice.
@ -933,6 +1003,7 @@ export async function monitorWebProvider(
} }
const responsePrefix = cfg.inbound?.responsePrefix; const responsePrefix = cfg.inbound?.responsePrefix;
let didSendReply = false;
let toolSendChain: Promise<void> = Promise.resolve(); let toolSendChain: Promise<void> = Promise.resolve();
const sendToolResult = (payload: ReplyPayload) => { const sendToolResult = (payload: ReplyPayload) => {
if ( if (
@ -942,6 +1013,7 @@ export async function monitorWebProvider(
) { ) {
return; return;
} }
if (isSilentReply(payload)) return;
const toolPayload: ReplyPayload = { ...payload }; const toolPayload: ReplyPayload = { ...payload };
if ( if (
responsePrefix && responsePrefix &&
@ -961,6 +1033,7 @@ export async function monitorWebProvider(
connectionId, connectionId,
skipLog: true, skipLog: true,
}); });
didSendReply = true;
if (toolPayload.text) { if (toolPayload.text) {
recentlySent.add(toolPayload.text); recentlySent.add(toolPayload.text);
if (recentlySent.size > MAX_RECENT_MESSAGES) { if (recentlySent.size > MAX_RECENT_MESSAGES) {
@ -987,7 +1060,11 @@ export async function monitorWebProvider(
MediaType: msg.mediaType, MediaType: msg.mediaType,
ChatType: msg.chatType, ChatType: msg.chatType,
GroupSubject: msg.groupSubject, GroupSubject: msg.groupSubject,
GroupMembers: msg.groupParticipants?.join(", "), GroupMembers: formatGroupMembers(
msg.groupParticipants,
groupMemberNames.get(conversationId),
msg.senderE164,
),
SenderName: msg.senderName, SenderName: msg.senderName,
SenderE164: msg.senderE164, SenderE164: msg.senderE164,
Surface: "whatsapp", Surface: "whatsapp",
@ -1004,14 +1081,24 @@ export async function monitorWebProvider(
: [replyResult] : [replyResult]
: []; : [];
if (replyList.length === 0) { const sendableReplies = replyList.filter(
logVerbose("Skipping auto-reply: no text/media returned from resolver"); (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; return;
} }
await toolSendChain; await toolSendChain;
for (const replyPayload of replyList) { for (const replyPayload of sendableReplies) {
if ( if (
responsePrefix && responsePrefix &&
replyPayload.text && replyPayload.text &&
@ -1029,6 +1116,7 @@ export async function monitorWebProvider(
replyLogger, replyLogger,
connectionId, connectionId,
}); });
didSendReply = true;
if (replyPayload.text) { if (replyPayload.text) {
recentlySent.add(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)({ const listener = await (listenerFactory ?? monitorWebInbox)({
@ -1096,6 +1188,7 @@ export async function monitorWebProvider(
} }
if (msg.chatType === "group") { if (msg.chatType === "group") {
noteGroupMember(conversationId, msg.senderE164, msg.senderName);
const history = const history =
groupHistories.get(conversationId) ?? groupHistories.get(conversationId) ??
([] as Array<{ sender: string; body: string; timestamp?: number }>); ([] as Array<{ sender: string; body: string; timestamp?: number }>);