Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions plugins/platforms/whatsapp/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 <number>' 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 <number>' 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WHATSAPP_HOME_CHANNEL is a generic notification/cron chat and may be a group. Matching chatId here grants every allowed member of that group authority to add users. Require a verified owner sender identity, or reject group home channels for approval.

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:
Expand Down
64 changes: 62 additions & 2 deletions scripts/whatsapp-bridge/bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

knockSeen has no size limit or expiry cleanup, so each distinct rejected sender leaves an entry for the bridge lifetime. Please use a bounded/expiring cache and cover eviction or expiry.

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;
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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' });
Expand Down