From edc894f6c78fd1ee2fc628313e9d8bbb757900ec Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 3 Dec 2025 13:25:34 +0000 Subject: [PATCH] fix(web): annotate group replies with sender --- CHANGELOG.md | 1 + README.md | 14 +++++ docs/group-messages.md | 98 ++++++++++++++++----------------- src/auto-reply/command-reply.ts | 3 +- src/auto-reply/reply.ts | 1 - src/auto-reply/tool-meta.ts | 2 +- src/web/auto-reply.test.ts | 1 + src/web/auto-reply.ts | 14 ++++- 8 files changed, 79 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ccc9c75e..07d0882aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ ### Bug Fixes - Web group chats now bypass the second `allowFrom` check (we still enforce it on the group participant at inbox ingest), so mentioned group messages reply even when the group JID isn’t in your allowlist. - `logVerbose` also writes to the configured Pino logger at debug level (without breaking stdout). +- Group auto-replies now append the triggering sender (`[from: Name (+E164)]`) to the batch body so agents can address the right person in group chats. - MIME sniffing and redirect handling for downloads/hosted media. - Response prefix applied to heartbeat alerts; heartbeat array payloads handled for both providers. - Tau RPC typing exposes `signal`/`killed`; NDJSON parsers normalized across agents. diff --git a/README.md b/README.md index a68eb4e30..f6f23d78f 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Install from npm (global): `npm install -g warelay` (Node 22+). Then choose **on ## Main Features - **Two providers:** Twilio (default) for reliable delivery + status; Web provider for quick personal sends/receives via QR login. - **Auto-replies:** Static templates or external commands (Claude-aware), with per-sender or global sessions and `/new` resets. +- **Group chats (web):** Replies only when mentioned, keep group sessions separate from DMs, inject recent group history, and suffix the triggering sender (`[from: Name (+E164)]`) so your agent knows who spoke. - Claude setup guide: see `docs/claude-config.md` for the exact Claude CLI configuration we support. - **Webhook in one go:** `warelay webhook --ingress tailscale` enables Tailscale Funnel, runs the webhook server, and updates the Twilio sender callback URL. - **Polling fallback:** `relay` polls Twilio when webhooks aren’t available; works headless. @@ -198,6 +199,19 @@ warelay supports running on the same phone number you message from—you chat wi | `inbound.groupChat.requireMention` | `boolean` (default: `true`) | When `true`, group chats only trigger replies if the bot is mentioned. | | `inbound.groupChat.mentionPatterns` | `string[]` (default: `[]`) | Extra regex patterns to detect mentions (e.g., `"@mybot"`). | | `inbound.groupChat.historyLimit` | `number` (default: `50`) | Max recent group messages kept for context before the next reply. | + +Example group config for Clawd UK (`+447511247203`): + +```json5 +{ + inbound: { + groupChat: { + requireMention: true, + mentionPatterns: ["@?clawd", "@?clawd\\s*uk", "@?clawdbot", "\\+?447511247203"] + } + } +} +``` | `inbound.reply.mode` | `"text"` \| `"command"` (default: —) | Reply style. | | `inbound.reply.text` | `string` (default: —) | Used when `mode=text`; templating supported. | | `inbound.reply.command` | `string[]` (default: —) | Argv for `mode=command`; each element templated. Stdout (trimmed) is sent. | diff --git a/docs/group-messages.md b/docs/group-messages.md index 653e8790a..9ff624a06 100644 --- a/docs/group-messages.md +++ b/docs/group-messages.md @@ -1,55 +1,53 @@ -# Group Messages Plan +# Group messages (web provider) -Goal: Enable warelay’s web provider to participate in WhatsApp group chats, replying only when mentioned and using recent group context. Keep personal (1:1) sessions separate from group sessions. +Goal: let Clawd sit in WhatsApp groups, wake up only when pinged, and keep that thread separate from the personal DM session. -## Scope & Constraints -- Web provider only; Twilio untouched. -- Default-safe: no unsolicited group replies unless mentioned. -- Preserve existing direct-chat behavior and batching. +## 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. +- 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 Tau/Claude know who is speaking. +- Ephemeral/view-once: we unwrap those before extracting text/mentions, so pings inside them still trigger. -## Design Decisions -- **Config**: Add `inbound.groupChat` with: - - `requireMention` (default: `true`) - - `mentionPatterns` (array of regex strings; optional) - - `historyLimit` (default: 50) -- **Conversation identity**: - - Direct chats keyed by E.164 (`+123`). - - Group chats keyed by raw group JID (`@g.us`) and labeled `chatType: "group"`. -- **Mention detection**: - - Trust Baileys `contextInfo.mentionedJid` vs our own self JID. - - Fallback regex match on body using `mentionPatterns`. -- **Group context**: - - Maintain per-group ring buffer of messages since last bot reply (cap `historyLimit`). - - When mentioned, prepend `[Chat messages since your last reply]` section with `sender: body`, then current message. - - Clear buffer after replying. -- **Gating**: - - If `requireMention` and no mention detected, store in buffer only; no reply. - - Allow opt-out via `requireMention: false`. -- **Allow list**: - - Group chats ignore `inbound.allowFrom` so anyone in the group can trigger a reply; we still record the sender E.164 for context. - - Direct chats keep enforcing `inbound.allowFrom` (same-phone bypass preserved). -- **Heartbeats**: - - Skip reply heartbeats when the last inbound was a group chat; connection heartbeat still runs. -- **Sessions**: - - Session key uses group conversation id so group threads don’t collide with personal sessions. +## Config for Clawd UK (+447511247203) +Add a `groupChat` block to `~/.warelay/warelay.json` so display-name pings work even when WhatsApp strips the visual `@` in the text body: -## Implementation Steps -1) Config/schema/docs - - Extend `WarelayConfig` + Zod schema with `inbound.groupChat`. - - Add defaults and README config table entry. -2) Inbound plumbing (`src/web/inbound.ts`) - - Detect groups, surface `chatId`, `chatType`, `senderJid`, `senderE164`, `senderName`, and `mentionedJids`. - - Apply `allowFrom` to participant; keep mark-read with participant. -3) Auto-reply loop (`src/web/auto-reply.ts`) - - Key batching/history by conversation id (group vs direct). - - Implement mention gating and context injection from history. - - Clear history after reply; cap history length. - - Guard heartbeats for groups. - - Ensure session key uses conversation id for groups. -4) Tests - - Inbound: group passthrough + allowFrom on participant + mention capture. - - Auto-reply: mention gating, history accumulation/clear, batching by group, session separation, heartbeat skip for groups. +```json5 +{ + "inbound": { + "groupChat": { + "requireMention": true, + "historyLimit": 50, + "mentionPatterns": [ + "@?clawd", + "@?clawd\\s*uk", + "@?clawdbot", + "\\+?447511247203" + ] + } + } +} +``` -## Open Questions / TODO -- Should we expose a configurable bot self-name for pattern defaults (e.g., auto-generate `mentionPatterns` from selfJid/local number)? For now, rely on explicit config + WhatsApp mentions. -- Do we need a max age for stored history (time-based) in addition to count-based cap? Default to count-only unless it becomes noisy. +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. + +## How to use +1) Add Clawd UK (`+447511247203`) to the group. +2) Say `@clawd …` (or `@clawd uk`, `@clawdbot`, or include the number). Anyone in the group can trigger it. +3) The agent prompt will include recent group context plus the trailing `[from: …]` marker so it can address the right person. +4) Session-level directives (`/verbose on`, `/think:high`, `/new`) apply only to that group’s session; your personal DM session remains independent. + +## Testing / verification +- Automated: `pnpm test -- src/web/auto-reply.test.ts --runInBand` (covers mention gating, history injection, sender suffix). +- Manual smoke: + - Send an `@clawd` ping in the group and confirm a reply that references the sender name. + - Send a second ping and verify the history block is included then cleared on the next turn. + - Check `/tmp/warelay/warelay.log` at level `trace` (run relay with `--verbose`) to see `inbound web message (batched)` entries showing `from: ` and the `[from: …]` suffix. + +## Known considerations +- Heartbeats are intentionally skipped for groups to avoid noisy broadcasts. +- Echo suppression uses the combined batch string; if you send identical text twice without mentions, only the first will get a response. +- Session store entries will appear as `group:` in `sessions.json`; a missing entry just means the group hasn’t triggered a run yet. diff --git a/src/auto-reply/command-reply.ts b/src/auto-reply/command-reply.ts index fc29d7494..6cd6559c5 100644 --- a/src/auto-reply/command-reply.ts +++ b/src/auto-reply/command-reply.ts @@ -15,6 +15,7 @@ import { applyTemplate, type TemplateContext } from "./templating.js"; import { formatToolAggregate, shortenMeta, + shortenPath, TOOL_RESULT_FLUSH_COUNT, TOOL_RESULT_DEBOUNCE_MS, } from "./tool-meta.js"; @@ -439,7 +440,7 @@ export async function runCommandReply( const textBlocks = Array.isArray(msg.content) ? (msg.content as Array<{ type?: string; text?: string }>) .filter((c) => c?.type === "text" && typeof c.text === "string") - .map((c) => c.text.trim()) + .map((c) => (c.text ?? "").trim()) .filter(Boolean) : []; if (textBlocks.length === 0) return; diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index ef0c6dd49..20a69ae17 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -26,7 +26,6 @@ import { } from "./templating.js"; import { isAudio, transcribeInboundAudio } from "./transcription.js"; import type { GetReplyOptions, ReplyPayload } from "./types.js"; -import { triggerWarelayRestart } from "../infra/restart.js"; export type { GetReplyOptions, ReplyPayload } from "./types.js"; diff --git a/src/auto-reply/tool-meta.ts b/src/auto-reply/tool-meta.ts index 1e52b64ef..994542ee5 100644 --- a/src/auto-reply/tool-meta.ts +++ b/src/auto-reply/tool-meta.ts @@ -1,7 +1,7 @@ export const TOOL_RESULT_DEBOUNCE_MS = 500; export const TOOL_RESULT_FLUSH_COUNT = 5; -function shortenPath(p: string): string { +export function shortenPath(p: string): string { const home = process.env.HOME; if (home && (p === home || p.startsWith(`${home}/`))) return p.replace(home, "~"); diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index 7a44499f0..d992fd174 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -1068,6 +1068,7 @@ describe("web auto-reply", () => { expect(payload.Body).toContain("Chat messages since your last reply"); expect(payload.Body).toContain("Alice: hello group"); expect(payload.Body).toContain("@bot ping"); + expect(payload.Body).toContain("[from: Bob (+222)]"); }); it("emits heartbeat logs with connection metadata", async () => { diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 7748c63b2..bbbbfcf50 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -716,6 +716,12 @@ export async function monitorWebProvider( .join("\\n"); combinedBody = `[Chat messages since your last reply - for context]\\n${historyText}\\n\\n[Current message - respond to this]\\n${buildLine(latest)}`; } + // Always surface who sent the triggering message so the agent can address them. + const senderLabel = + latest.senderName && latest.senderE164 + ? `${latest.senderName} (${latest.senderE164})` + : latest.senderName ?? latest.senderE164 ?? "Unknown"; + combinedBody = `${combinedBody}\\n[from: ${senderLabel}]`; // Clear stored history after using it groupHistories.set(conversationId, []); } @@ -812,10 +818,14 @@ export async function monitorWebProvider( } } + const fromDisplay = + latest.chatType === "group" + ? conversationId + : latest.from ?? "unknown"; if (isVerbose()) { console.log( success( - `↩️ Auto-replied to ${from} (web${replyPayload.mediaUrl || replyPayload.mediaUrls?.length ? ", media" : ""}; batched ${messages.length})`, + `↩️ Auto-replied to ${fromDisplay} (web${replyPayload.mediaUrl || replyPayload.mediaUrls?.length ? ", media" : ""}; batched ${messages.length})`, ), ); } else { @@ -827,7 +837,7 @@ export async function monitorWebProvider( } } catch (err) { console.error( - danger(`Failed sending web auto-reply to ${from}: ${String(err)}`), + danger(`Failed sending web auto-reply to ${latest.from ?? conversationId}: ${String(err)}`), ); } }