fix(web): allow group replies past allowFrom

This commit is contained in:
Peter Steinberger
2025-12-03 13:08:54 +00:00
parent 4c3635a7c0
commit 8204351d67
6 changed files with 73 additions and 9 deletions

View File

@@ -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:

View File

@@ -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 isnt 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.

View File

@@ -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(

View File

@@ -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) {

View File

@@ -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: {

View File

@@ -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<string, unknown> } {
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<typeof loadConfig>,
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;
}