fix(web): annotate group replies with sender
This commit is contained in:
@@ -29,6 +29,7 @@
|
|||||||
### Bug Fixes
|
### 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.
|
- 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).
|
- `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.
|
- MIME sniffing and redirect handling for downloads/hosted media.
|
||||||
- Response prefix applied to heartbeat alerts; heartbeat array payloads handled for both providers.
|
- Response prefix applied to heartbeat alerts; heartbeat array payloads handled for both providers.
|
||||||
- Tau RPC typing exposes `signal`/`killed`; NDJSON parsers normalized across agents.
|
- Tau RPC typing exposes `signal`/`killed`; NDJSON parsers normalized across agents.
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -38,6 +38,7 @@ Install from npm (global): `npm install -g warelay` (Node 22+). Then choose **on
|
|||||||
## Main Features
|
## Main Features
|
||||||
- **Two providers:** Twilio (default) for reliable delivery + status; Web provider for quick personal sends/receives via QR login.
|
- **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.
|
- **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.
|
- 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.
|
- **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.
|
- **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.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.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. |
|
| `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.mode` | `"text"` \| `"command"` (default: —) | Reply style. |
|
||||||
| `inbound.reply.text` | `string` (default: —) | Used when `mode=text`; templating supported. |
|
| `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. |
|
| `inbound.reply.command` | `string[]` (default: —) | Argv for `mode=command`; each element templated. Stdout (trimmed) is sent. |
|
||||||
|
|||||||
@@ -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
|
## What’s implemented (2025-12-03)
|
||||||
- Web provider only; Twilio untouched.
|
- Mentions required by default: real WhatsApp @-mentions (via `mentionedJids`), regex patterns, or the bot’s E.164 anywhere in the text all count.
|
||||||
- Default-safe: no unsolicited group replies unless mentioned.
|
- Group allowlist bypass: we still enforce `allowFrom` on the participant at inbox ingest, but group JIDs themselves no longer block replies.
|
||||||
- Preserve existing direct-chat behavior and batching.
|
- 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]`.
|
||||||
|
- 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 for Clawd UK (+447511247203)
|
||||||
- **Config**: Add `inbound.groupChat` with:
|
Add a `groupChat` block to `~/.warelay/warelay.json` so display-name pings work even when WhatsApp strips the visual `@` in the text body:
|
||||||
- `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 (`<id>@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.
|
|
||||||
|
|
||||||
## Implementation Steps
|
```json5
|
||||||
1) Config/schema/docs
|
{
|
||||||
- Extend `WarelayConfig` + Zod schema with `inbound.groupChat`.
|
"inbound": {
|
||||||
- Add defaults and README config table entry.
|
"groupChat": {
|
||||||
2) Inbound plumbing (`src/web/inbound.ts`)
|
"requireMention": true,
|
||||||
- Detect groups, surface `chatId`, `chatType`, `senderJid`, `senderE164`, `senderName`, and `mentionedJids`.
|
"historyLimit": 50,
|
||||||
- Apply `allowFrom` to participant; keep mark-read with participant.
|
"mentionPatterns": [
|
||||||
3) Auto-reply loop (`src/web/auto-reply.ts`)
|
"@?clawd",
|
||||||
- Key batching/history by conversation id (group vs direct).
|
"@?clawd\\s*uk",
|
||||||
- Implement mention gating and context injection from history.
|
"@?clawdbot",
|
||||||
- Clear history after reply; cap history length.
|
"\\+?447511247203"
|
||||||
- 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.
|
```
|
||||||
|
|
||||||
## Open Questions / TODO
|
Notes:
|
||||||
- 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.
|
- The regexes are case-insensitive; they cover `@clawd`, `@clawd uk`, `clawdbot`, and the raw number with or without `+`/spaces.
|
||||||
- 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.
|
- 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: <groupJid>` 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:<jid>` in `sessions.json`; a missing entry just means the group hasn’t triggered a run yet.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { applyTemplate, type TemplateContext } from "./templating.js";
|
|||||||
import {
|
import {
|
||||||
formatToolAggregate,
|
formatToolAggregate,
|
||||||
shortenMeta,
|
shortenMeta,
|
||||||
|
shortenPath,
|
||||||
TOOL_RESULT_FLUSH_COUNT,
|
TOOL_RESULT_FLUSH_COUNT,
|
||||||
TOOL_RESULT_DEBOUNCE_MS,
|
TOOL_RESULT_DEBOUNCE_MS,
|
||||||
} from "./tool-meta.js";
|
} from "./tool-meta.js";
|
||||||
@@ -439,7 +440,7 @@ export async function runCommandReply(
|
|||||||
const textBlocks = Array.isArray(msg.content)
|
const textBlocks = Array.isArray(msg.content)
|
||||||
? (msg.content as Array<{ type?: string; text?: string }>)
|
? (msg.content as Array<{ type?: string; text?: string }>)
|
||||||
.filter((c) => c?.type === "text" && typeof c.text === "string")
|
.filter((c) => c?.type === "text" && typeof c.text === "string")
|
||||||
.map((c) => c.text.trim())
|
.map((c) => (c.text ?? "").trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
if (textBlocks.length === 0) return;
|
if (textBlocks.length === 0) return;
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import {
|
|||||||
} from "./templating.js";
|
} from "./templating.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 { triggerWarelayRestart } from "../infra/restart.js";
|
|
||||||
|
|
||||||
export type { GetReplyOptions, ReplyPayload } from "./types.js";
|
export type { GetReplyOptions, ReplyPayload } from "./types.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const TOOL_RESULT_DEBOUNCE_MS = 500;
|
export const TOOL_RESULT_DEBOUNCE_MS = 500;
|
||||||
export const TOOL_RESULT_FLUSH_COUNT = 5;
|
export const TOOL_RESULT_FLUSH_COUNT = 5;
|
||||||
|
|
||||||
function shortenPath(p: string): string {
|
export function shortenPath(p: string): string {
|
||||||
const home = process.env.HOME;
|
const home = process.env.HOME;
|
||||||
if (home && (p === home || p.startsWith(`${home}/`)))
|
if (home && (p === home || p.startsWith(`${home}/`)))
|
||||||
return p.replace(home, "~");
|
return p.replace(home, "~");
|
||||||
|
|||||||
@@ -1068,6 +1068,7 @@ describe("web auto-reply", () => {
|
|||||||
expect(payload.Body).toContain("Chat messages since your last reply");
|
expect(payload.Body).toContain("Chat messages since your last reply");
|
||||||
expect(payload.Body).toContain("Alice: hello group");
|
expect(payload.Body).toContain("Alice: hello group");
|
||||||
expect(payload.Body).toContain("@bot ping");
|
expect(payload.Body).toContain("@bot ping");
|
||||||
|
expect(payload.Body).toContain("[from: Bob (+222)]");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits heartbeat logs with connection metadata", async () => {
|
it("emits heartbeat logs with connection metadata", async () => {
|
||||||
|
|||||||
@@ -716,6 +716,12 @@ export async function monitorWebProvider(
|
|||||||
.join("\\n");
|
.join("\\n");
|
||||||
combinedBody = `[Chat messages since your last reply - for context]\\n${historyText}\\n\\n[Current message - respond to this]\\n${buildLine(latest)}`;
|
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
|
// Clear stored history after using it
|
||||||
groupHistories.set(conversationId, []);
|
groupHistories.set(conversationId, []);
|
||||||
}
|
}
|
||||||
@@ -812,10 +818,14 @@ export async function monitorWebProvider(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fromDisplay =
|
||||||
|
latest.chatType === "group"
|
||||||
|
? conversationId
|
||||||
|
: latest.from ?? "unknown";
|
||||||
if (isVerbose()) {
|
if (isVerbose()) {
|
||||||
console.log(
|
console.log(
|
||||||
success(
|
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 {
|
} else {
|
||||||
@@ -827,7 +837,7 @@ export async function monitorWebProvider(
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
danger(`Failed sending web auto-reply to ${from}: ${String(err)}`),
|
danger(`Failed sending web auto-reply to ${latest.from ?? conversationId}: ${String(err)}`),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user