From 8204351d67cabeac3c462b28cc66a5c9137c3884 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 3 Dec 2025 13:08:54 +0000 Subject: [PATCH] fix(web): allow group replies past allowFrom --- AGENTS.md | 1 + CHANGELOG.md | 2 ++ src/auto-reply/reply.ts | 5 ++++- src/globals.ts | 10 ++++++++- src/index.core.test.ts | 19 +++++++++++++++++ src/web/auto-reply.ts | 45 ++++++++++++++++++++++++++++++++++------- 6 files changed, 73 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 735fe9f70..459197c42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,7 @@ ## Agent-Specific Notes - Relay is managed by launchctl (label `com.steipete.warelay`). After code changes restart with `launchctl kickstart -k gui/$UID/com.steipete.warelay` and verify via `launchctl list | grep warelay`. Use tmux only if you spin up a temporary relay yourself and clean it up afterward. - Also read the shared guardrails at `~/Projects/oracle/AGENTS.md` and `~/Projects/agent-scripts/AGENTS.MD` before making changes; align with any cross-repo rules noted there. +- When asked to open a “session” file, open the Pi/Tau session logs under `~/.pi/agent/sessions/warelay/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. ## Exclamation Mark Escaping Workaround The Claude Code Bash tool escapes `!` to `\!` in command arguments. When using `warelay send` with messages containing exclamation marks, use heredoc syntax: diff --git a/CHANGELOG.md b/CHANGELOG.md index d7deca83a..58e042ffe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ - Media server blocks symlinks and enforces path containment; logging rotates daily and prunes >24h. ### 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). - 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/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index ebe65e97f..ef0c6dd49 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -348,6 +348,9 @@ export async function getReplyFromConfig( const from = (ctx.From ?? "").replace(/^whatsapp:/, ""); const to = (ctx.To ?? "").replace(/^whatsapp:/, ""); const isSamePhone = from && to && from === to; + const isGroup = + typeof ctx.From === "string" && + (ctx.From.includes("@g.us") || ctx.From.startsWith("group:")); const abortKey = sessionKey ?? (from || undefined) ?? (to || undefined); const rawBodyNormalized = ( sessionCtx.BodyStripped ?? sessionCtx.Body ?? "" @@ -362,7 +365,7 @@ export async function getReplyFromConfig( // Same-phone mode (self-messaging) is always allowed if (isSamePhone) { logVerbose(`Allowing same-phone mode: from === to (${from})`); - } else if (Array.isArray(allowFrom) && allowFrom.length > 0) { + } else if (!isGroup && Array.isArray(allowFrom) && allowFrom.length > 0) { // Support "*" as wildcard to allow all senders if (!allowFrom.includes("*") && !allowFrom.includes(from)) { logVerbose( diff --git a/src/globals.ts b/src/globals.ts index 5efe15ec5..c422dc9d9 100644 --- a/src/globals.ts +++ b/src/globals.ts @@ -1,4 +1,5 @@ import chalk from "chalk"; +import { getLogger } from "./logging.js"; let globalVerbose = false; let globalYes = false; @@ -12,7 +13,14 @@ export function isVerbose() { } export function logVerbose(message: string) { - if (globalVerbose) console.log(chalk.gray(message)); + if (globalVerbose) { + console.log(chalk.gray(message)); + try { + getLogger().debug({ message }, "verbose"); + } catch { + // ignore logger failures to avoid breaking verbose printing + } + } } export function setYes(v: boolean) { diff --git a/src/index.core.test.ts b/src/index.core.test.ts index 3a633324e..ae70814b0 100644 --- a/src/index.core.test.ts +++ b/src/index.core.test.ts @@ -145,6 +145,25 @@ describe("config and templating", () => { expect(result?.text).toBe("Reply: test"); }); + it("getReplyFromConfig allows group chats even when not in allowFrom", async () => { + const cfg = { + inbound: { + allowFrom: ["+9999"], + reply: { + mode: "text" as const, + text: "Group: {{From}}", + }, + }, + }; + + const result = await index.getReplyFromConfig( + { Body: "hello", From: "120363422899103675@g.us", To: "+4475" }, + undefined, + cfg, + ); + expect(result?.text).toBe("Group: 120363422899103675@g.us"); + }); + it("getReplyFromConfig rejects non-same-phone when not in allowFrom", async () => { const cfg = { inbound: { diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 80891beb9..7748c63b2 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -98,6 +98,12 @@ function isBotMentioned( msg: WebInboundMsg, mentionCfg: MentionConfig, ): boolean { + const clean = (text: string) => + text + // Remove zero-width and directionality markers WhatsApp injects around display names + .replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "") + .toLowerCase(); + if (msg.mentionedJids?.length) { const normalizedMentions = msg.mentionedJids .map((jid) => jidToE164(jid) ?? jid) @@ -109,13 +115,14 @@ function isBotMentioned( if (normalizedMentions.includes(bareSelf)) return true; } } - if (mentionCfg.mentionRegexes.some((re) => re.test(msg.body))) return true; + const bodyClean = clean(msg.body); + if (mentionCfg.mentionRegexes.some((re) => re.test(bodyClean))) return true; // Fallback: detect body containing our own number (with or without +, spacing) if (msg.selfE164) { const selfDigits = msg.selfE164.replace(/\D/g, ""); if (selfDigits) { - const bodyDigits = msg.body.replace(/[^\d]/g, ""); + const bodyDigits = bodyClean.replace(/[^\d]/g, ""); if (bodyDigits.includes(selfDigits)) return true; const bodyNoSpace = msg.body.replace(/[\s-]/g, ""); const pattern = new RegExp(`\\+?${selfDigits}`, "i"); @@ -126,6 +133,24 @@ function isBotMentioned( return false; } +function debugMention( + msg: WebInboundMsg, + mentionCfg: MentionConfig, +): { wasMentioned: boolean; details: Record } { + const result = isBotMentioned(msg, mentionCfg); + const details = { + from: msg.from, + body: msg.body, + bodyClean: msg.body + .replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "") + .toLowerCase(), + mentionedJids: msg.mentionedJids ?? null, + selfJid: msg.selfJid ?? null, + selfE164: msg.selfE164 ?? null, + }; + return { wasMentioned: result, details }; +} + export function resolveReplyHeartbeatMinutes( cfg: ReturnType, overrideMinutes?: number, @@ -669,7 +694,6 @@ export async function monitorWebProvider( const batch = pendingBatches.get(conversationId); if (!batch || batch.messages.length === 0) return; if (getQueueSize() > 0) { - // Wait until command queue is free to run the combined prompt. batch.timer = setTimeout(() => void processBatch(conversationId), 150); return; } @@ -814,8 +838,6 @@ export async function monitorWebProvider( const bucket = pendingBatches.get(key) ?? { messages: [] }; bucket.messages.push(msg); pendingBatches.set(key, bucket); - - // Process immediately when queue is free; otherwise wait until it drains. if (getQueueSize() === 0) { await processBatch(key); } else { @@ -859,10 +881,19 @@ export async function monitorWebProvider( while (history.length > groupHistoryLimit) history.shift(); groupHistories.set(conversationId, history); - const wasMentioned = isBotMentioned(msg, mentionConfig); + const mentionDebug = debugMention(msg, mentionConfig); + replyLogger.debug( + { + conversationId, + wasMentioned: mentionDebug.wasMentioned, + ...mentionDebug.details, + }, + "group mention debug", + ); + const wasMentioned = mentionDebug.wasMentioned; if (mentionConfig.requireMention && !wasMentioned) { logVerbose( - `Group message stored for context (no mention detected) in ${conversationId}`, + `Group message stored for context (no mention detected) in ${conversationId}: ${msg.body}`, ); return; }