From 4d5a9657c7a49411b74490062f306059403e41d0 Mon Sep 17 00:00:00 2001 From: Marcelo Paniza Date: Sat, 27 Jun 2026 13:25:06 -0400 Subject: [PATCH] feat(whatsapp): notify the owner when a non-allowlisted sender messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-allowlisted WhatsApp senders are silently dropped at the bridge, so the owner never learns someone tried to reach them — a friend not yet on the list, a new contact. It is invisible and easily mis-debugged as "the bot ignored me". Surface it instead, with a one-tap approval path: - bridge.js emits a deduped `knock` event (sender + a short, sanitized preview) into the message queue instead of only logging `allowlist_mismatch`, and gains POST /allow to add a number to the live allowlist at runtime (persisted to allowlist-runtime.txt next to the session and merged at startup — no restart). - the adapter consumes `knock`: it sends a notice to WHATSAPP_HOME_CHANNEL (" (+) said: — reply `allow `") and intercepts the owner's `allow ` reply (home channel only) to call POST /allow + confirm. Security: the stranger's text is treated as untrusted — only ever shown to the owner as a quoted preview, never dispatched to the agent. Only the home channel can approve. Knock notices are a no-op unless WHATSAPP_HOME_CHANNEL is set, so existing deployments keep silently dropping non-allowlisted senders. Co-Authored-By: Claude Opus 4.8 --- plugins/platforms/whatsapp/adapter.py | 81 +++++++++++++++++++++++++++ scripts/whatsapp-bridge/bridge.js | 64 ++++++++++++++++++++- 2 files changed, 143 insertions(+), 2 deletions(-) diff --git a/plugins/platforms/whatsapp/adapter.py b/plugins/platforms/whatsapp/adapter.py index dc4361213e54..14a89fbee68f 100644 --- a/plugins/platforms/whatsapp/adapter.py +++ b/plugins/platforms/whatsapp/adapter.py @@ -1061,6 +1061,8 @@ async def _poll_messages(self) -> None: if resp.status == 200: messages = await resp.json() for msg_data in messages: + if await self._handle_special_inbound(msg_data): + continue event = await self._build_message_event(msg_data) if event: if event.message_type == MessageType.TEXT: @@ -1139,6 +1141,85 @@ async def _flush_text_batch(self, key: str) -> None: if self._pending_text_batch_tasks.get(key) is current_task: self._pending_text_batch_tasks.pop(key, None) + @staticmethod + def _wa_norm(value) -> str: + """Reduce a WhatsApp jid/number to its bare id (drop device suffix, + domain, leading '+'), mirroring allowlist.js normalizeWhatsAppIdentifier.""" + import re as _re + s = str(value or "").strip() + s = _re.sub(r":.*@", "@", s) + s = _re.sub(r"@.*", "", s) + return s.lstrip("+") + + async def _bridge_allow(self, number: str) -> bool: + """Add a number to the bridge's live allowlist (POST /allow, no restart).""" + import aiohttp + if not self._http_session: + return False + try: + async with self._http_session.post( + f"http://127.0.0.1:{self._bridge_port}/allow", + json={"number": number}, + timeout=aiohttp.ClientTimeout(total=10), + ) as resp: + return resp.status == 200 + except Exception: + return False + + async def _handle_special_inbound(self, data) -> bool: + """Handle bridge 'knock' events (non-allowlisted sender) and the owner's + 'allow ' approval before normal dispatch. + + Returns True when consumed here (skip agent dispatch). A stranger's text + is untrusted: it is only ever shown to the owner as a quoted preview, + never fed to the agent. The owner is the configured WHATSAPP_HOME_CHANNEL. + """ + import os as _os + try: + home_chat = (_os.getenv("WHATSAPP_HOME_CHANNEL") or "").strip() + + # 1) Stranger knocking -> notify the owner's home channel. + if data.get("knock"): + if home_chat: + sender_num = self._wa_norm(data.get("senderNumber") or data.get("senderId") or "") + sender_name = (data.get("senderName") or sender_num or "someone").strip() + preview = (data.get("preview") or "").strip() + note = ( + "\U0001F514 Someone *not on your allowlist* just messaged me:\n" + "• From: " + sender_name + " (+" + sender_num + ")\n" + "• They said: \"" + preview + "\"\n\n" + "Reply `allow " + sender_num + "` to let them through, or ignore this." + ) + try: + await self.send(home_chat, note) + except Exception: + pass + return True # never dispatch a knock to the agent + + # 2) Owner approves: 'allow ' from the home channel only. + body = (data.get("body") or "").strip() + if home_chat and len(body) >= 6 and body[:6].lower() == "allow ": + hc = self._wa_norm(home_chat) + if hc and (self._wa_norm(data.get("chatId") or "") == hc + or self._wa_norm(data.get("senderId") or "") == hc): + import re as _re + m = _re.match(r"^allow\s+\+?([0-9][0-9 \-]{4,})$", body, _re.IGNORECASE) + if m: + number = _re.sub(r"[^0-9]", "", m.group(1)) + ok = await self._bridge_allow(number) + if ok: + reply = "✅ Added +" + number + " to your allowlist — they can reach me now." + else: + reply = "⚠️ Couldn't add +" + number + " right now." + try: + await self.send(home_chat, reply) + except Exception: + pass + return True + except Exception: + pass + return False + async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEvent]: """Build a MessageEvent from bridge message data, downloading images to cache.""" try: diff --git a/scripts/whatsapp-bridge/bridge.js b/scripts/whatsapp-bridge/bridge.js index 4c65740c0174..e02d20e6d48d 100644 --- a/scripts/whatsapp-bridge/bridge.js +++ b/scripts/whatsapp-bridge/bridge.js @@ -23,13 +23,13 @@ import express from 'express'; import { Boom } from '@hapi/boom'; import pino from 'pino'; import path from 'path'; -import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync } from 'fs'; +import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync, appendFileSync } from 'fs'; import { fileURLToPath } from 'url'; import { randomBytes, createHash } from 'crypto'; import { execSync } from 'child_process'; import { tmpdir } from 'os'; import qrcode from 'qrcode-terminal'; -import { matchesAllowedUser, parseAllowedUsers } from './allowlist.js'; +import { matchesAllowedUser, parseAllowedUsers, normalizeWhatsAppIdentifier } from './allowlist.js'; // Parse CLI args const args = process.argv.slice(2); @@ -190,6 +190,52 @@ const logger = pino({ level: 'warn' }); const messageQueue = []; const MAX_QUEUE_SIZE = 100; +// ── Stranger-knocking support ─────────────────────────────────────────────── +// Owner-approved runtime allowlist additions, persisted next to the session so +// they survive a bridge restart, merged into ALLOWED_USERS at startup. +const RUNTIME_ALLOW_FILE = path.join(path.dirname(SESSION_DIR), 'allowlist-runtime.txt'); +try { + if (existsSync(RUNTIME_ALLOW_FILE)) { + for (const line of readFileSync(RUNTIME_ALLOW_FILE, 'utf8').split('\n')) { + const id = normalizeWhatsAppIdentifier(line); + if (id) ALLOWED_USERS.add(id); + } + } +} catch {} +// De-dup "stranger knocking" notices: at most one per sender per window. +const KNOCK_DEDUP_MS = 6 * 60 * 60 * 1000; +const knockSeen = new Map(); +function maybeEmitKnock(msg, senderId, senderNumber, chatId) { + const tsNow = Date.now(); + const last = knockSeen.get(senderId) || 0; + if (tsNow - last < KNOCK_DEDUP_MS) return; + knockSeen.set(senderId, tsNow); + let preview = ''; + try { + const mc = getMessageContent(msg) || {}; + preview = mc.conversation || (mc.extendedTextMessage && mc.extendedTextMessage.text) || ''; + if (!preview) { + if (mc.imageMessage) preview = '[photo]'; + else if (mc.videoMessage) preview = '[video]'; + else if (mc.audioMessage || mc.pttMessage) preview = '[voice note]'; + else if (mc.documentMessage) preview = '[document]'; + else preview = '[message]'; + } + } catch { preview = '[message]'; } + preview = String(preview).replace(/\s+/g, ' ').trim().slice(0, 140); + messageQueue.push({ + knock: true, + messageId: msg.key.id, + chatId, + senderId, + senderNumber, + senderName: msg.pushName || senderNumber, + preview, + timestamp: msg.messageTimestamp, + }); + if (messageQueue.length > MAX_QUEUE_SIZE) messageQueue.shift(); +} + // Track recently sent message IDs to prevent echo-back loops with media const recentlySentIds = new Set(); const MAX_RECENT_IDS = 50; @@ -322,6 +368,9 @@ async function startSocket() { continue; } if (!matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)) { + // Stranger (not on allowlist): emit a deduped "knock" so the gateway + // can alert the owner, then drop. The body never reaches the agent. + try { maybeEmitKnock(msg, senderId, senderNumber, chatId); } catch {} try { console.log(JSON.stringify({ event: 'ignored', @@ -510,6 +559,17 @@ app.get('/messages', (req, res) => { }); // Send a message +// Runtime allowlist add — owner-approved "stranger knocking" sender. +app.post('/allow', (req, res) => { + const raw = (req.body && (req.body.number || req.body.id)) || ''; + const id = normalizeWhatsAppIdentifier(raw); + if (!id) return res.status(400).json({ error: 'number/id required' }); + ALLOWED_USERS.add(id); + try { appendFileSync(RUNTIME_ALLOW_FILE, id + '\n'); } catch {} + try { console.log(JSON.stringify({ event: 'allowlist_add', id })); } catch {} + return res.json({ success: true, id }); +}); + app.post('/send', async (req, res) => { if (!sock || connectionState !== 'connected') { return res.status(503).json({ error: 'Not connected to WhatsApp' });