refactor(whatsapp): replace bridge with neonize

This commit is contained in:
chengyongru
2026-06-27 11:05:03 +08:00
committed by Xubin Ren
parent 3460ca3cb9
commit 2a9e288dfe
18 changed files with 998 additions and 1438 deletions
-1
View File
@@ -41,7 +41,6 @@ Messages flow through an async `MessageBus` (`nanobot/bus/queue.py`) that decoup
- **Memory** (`nanobot/agent/memory.py`): Session history persistence with Dream two-phase memory consolidation. Uses atomic writes with fsync for durability.
- **Session Management** (`nanobot/session/`): Per-session history, context compaction, TTL-based auto-compaction (`manager.py`), and sustained goal state tracking (`goal_state.py`).
- **Config** (`nanobot/config/schema.py`, `loader.py`): Pydantic-based configuration loaded from `~/.nanobot/config.json`. Supports camelCase aliases for JSON compatibility.
- **Bridge** (`bridge/`): TypeScript services (e.g. WhatsApp bridge) bundled into the wheel via `pyproject.toml` `force-include`.
- **WebUI** (`webui/`): Vite-based React SPA that talks to the gateway over a WebSocket multiplex protocol. The dev server proxies `/api`, `/webui`, `/auth`, and WebSocket traffic to the gateway.
- **API Server** (`nanobot/api/server.py`): OpenAI-compatible HTTP API (`/v1/chat/completions`, `/v1/models`) for programmatic access.
- **Command Router** (`nanobot/command/`): Slash command routing and built-in command handlers.
+16 -23
View File
@@ -1,15 +1,16 @@
FROM node:24-bookworm-slim AS webui-builder
WORKDIR /app
COPY webui/package.json webui/package-lock.json ./webui/
WORKDIR /app/webui
RUN npm ci
COPY webui/ ./
RUN mkdir -p /app/nanobot/web && npm run build
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
# Install Node.js for the WhatsApp bridge
RUN apt-get update && \
apt-get install -y --no-install-recommends curl ca-certificates gnupg git bubblewrap openssh-client && \
mkdir -p /etc/apt/keyrings && \
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_24.x nodistro main" > /etc/apt/sources.list.d/nodesource.list && \
apt-get update && \
apt-get install -y --no-install-recommends nodejs && \
apt-get purge -y gnupg && \
apt-get autoremove -y && \
apt-get install -y --no-install-recommends ca-certificates git bubblewrap openssh-client && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
@@ -17,22 +18,14 @@ WORKDIR /app
# Install Python dependencies first (cached layer). Hatch reads the custom build
# hook from hatch_build.py even for this metadata-only install.
COPY pyproject.toml README.md LICENSE THIRD_PARTY_NOTICES.md hatch_build.py ./
RUN mkdir -p nanobot bridge && touch nanobot/__init__.py && \
uv pip install --system --no-cache . && \
rm -rf nanobot bridge
RUN mkdir -p nanobot && touch nanobot/__init__.py && \
NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install --system --no-cache ".[whatsapp]" && \
rm -rf nanobot
# Copy the full source and install
# Copy the full source and install with the WebUI built in the Node-only stage.
COPY nanobot/ nanobot/
COPY bridge/ bridge/
COPY webui/ webui/
RUN NANOBOT_FORCE_WEBUI_BUILD=1 uv pip install --system --no-cache .
# Build the WhatsApp bridge
WORKDIR /app/bridge
RUN git config --global --add url."https://github.com/".insteadOf ssh://git@github.com/ && \
git config --global --add url."https://github.com/".insteadOf git@github.com: && \
npm install && npm run build
WORKDIR /app
COPY --from=webui-builder /app/nanobot/web/dist/ nanobot/web/dist/
RUN NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install --system --no-cache ".[whatsapp]"
# Create non-root user and config directory
RUN useradd -m -u 1000 -s /bin/bash nanobot && \
+1 -1
View File
@@ -110,7 +110,7 @@
- **2026-05-03** ⚙️ Predictable shell allow-list behavior, isolated chats mid-reply, cleaner interactive retries.
- **2026-05-02** 🐈 LongCat support, smarter token sizing hints, clearer bundled upgrade guidance.
- **2026-05-01** ☁️ Native AWS Bedrock provider, tighter helper handoffs and scoped session files.
- **2026-04-30** 💬 Feishu threads that honor replies and topics, WhatsApp bridge refresh on source edits.
- **2026-04-30** 💬 Feishu threads that honor replies and topics, WhatsApp channel refresh on source edits.
- **2026-04-29** 🚀 Released **v0.1.5.post3** — Smarter threads on Feishu, Discord, Slack, and Teams; **DeepSeek-V4**; Hugging Face & Olostep; choices, `/history`, and steadier long chats. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5.post3) for details.
- **2026-04-28** 🌐 Olostep web search, Hugging Face provider, safer workspace-tool interruptions.
- **2026-04-27** 💬 `/history` command, smarter session replay caps, smoother Discord / Slack threads.
+8 -16
View File
@@ -48,7 +48,7 @@ chmod 600 ~/.nanobot/config.json
},
"whatsapp": {
"enabled": true,
"allowFrom": ["+1234567890"]
"allowFrom": ["1234567890"]
}
}
}
@@ -57,7 +57,7 @@ chmod 600 ~/.nanobot/config.json
**Security Notes:**
- In `v0.1.4.post3` and earlier, an empty `allowFrom` allowed all users. Since `v0.1.4.post4`, empty `allowFrom` denies all access by default — set `["*"]` to explicitly allow everyone.
- Get your Telegram user ID from `@userinfobot`
- Use full phone numbers with country code for WhatsApp
- Use WhatsApp sender IDs as full phone numbers with country code and no leading `+`
- Review access logs regularly for unauthorized access attempts
### 3. Shell Command Execution
@@ -109,10 +109,10 @@ File operations have path traversal protection, but:
- Timeouts are configured to prevent hanging requests
- Consider using a firewall to restrict outbound connections if needed
**WhatsApp Bridge:**
- The bridge binds to `127.0.0.1:3001` (localhost only, not accessible from external network)
- Set `bridgeToken` in config to enable shared-secret authentication between Python and Node.js
- Keep authentication data in `~/.nanobot/whatsapp-auth` secure (mode 0700)
**WhatsApp:**
- The WhatsApp channel uses neonize directly from Python; there is no local Node.js bridge port.
- Keep the neonize session database under `~/.nanobot/whatsapp-auth` secure (mode 0700).
- Use `nanobot channels login whatsapp --force` to remove and recreate the local session database when rotating linked devices.
### 6. Dependency Security
@@ -127,17 +127,9 @@ pip-audit
pip install --upgrade nanobot-ai
```
For Node.js dependencies (WhatsApp bridge):
```bash
cd bridge
npm audit
npm audit fix
```
**Important Notes:**
- Keep `litellm` updated to the latest version for security fixes
- We've updated `ws` to `>=8.17.1` to fix DoS vulnerability
- Run `pip-audit` or `npm audit` regularly
- Run `pip-audit` regularly, including optional channel dependencies such as `nanobot-ai[whatsapp]`
- Subscribe to security advisories for nanobot and its dependencies
### 7. Production Deployment
@@ -238,7 +230,7 @@ If you suspect a security breach:
✅ **Secure Communication**
- HTTPS for all external API calls
- TLS for Telegram API
- WhatsApp bridge: localhost-only binding + optional token auth
- WhatsApp: no local bridge listener; session secrets stay in the local neonize database
## Known Limitations
-26
View File
@@ -1,26 +0,0 @@
{
"name": "nanobot-whatsapp-bridge",
"version": "0.1.0",
"description": "WhatsApp bridge for nanobot using Baileys",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsc && node dist/index.js"
},
"dependencies": {
"@whiskeysockets/baileys": "7.0.0-rc.9",
"ws": "^8.17.1",
"qrcode-terminal": "^0.12.0",
"pino": "^9.0.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"@types/ws": "^8.5.10",
"typescript": "^5.4.0"
},
"engines": {
"node": ">=20.0.0"
}
}
-56
View File
@@ -1,56 +0,0 @@
#!/usr/bin/env node
/**
* nanobot WhatsApp Bridge
*
* This bridge connects WhatsApp Web to nanobot's Python backend
* via WebSocket. It handles authentication, message forwarding,
* and reconnection logic.
*
* Usage:
* npm run build && npm start
*
* Or with custom settings:
* BRIDGE_PORT=3001 AUTH_DIR=~/.nanobot/whatsapp npm start
*/
// Polyfill crypto for Baileys in ESM
import { webcrypto } from 'crypto';
if (!globalThis.crypto) {
(globalThis as any).crypto = webcrypto;
}
import { BridgeServer } from './server.js';
import { homedir } from 'os';
import { join } from 'path';
const PORT = parseInt(process.env.BRIDGE_PORT || '3001', 10);
const AUTH_DIR = process.env.AUTH_DIR || join(homedir(), '.nanobot', 'whatsapp-auth');
const TOKEN = process.env.BRIDGE_TOKEN?.trim();
if (!TOKEN) {
console.error('BRIDGE_TOKEN is required. Start the bridge via nanobot so it can provision a local secret automatically.');
process.exit(1);
}
console.log('🐈 nanobot WhatsApp Bridge');
console.log('========================\n');
const server = new BridgeServer(PORT, AUTH_DIR, TOKEN);
// Handle graceful shutdown
process.on('SIGINT', async () => {
console.log('\n\nShutting down...');
await server.stop();
process.exit(0);
});
process.on('SIGTERM', async () => {
await server.stop();
process.exit(0);
});
// Start the server
server.start().catch((error) => {
console.error('Failed to start bridge:', error);
process.exit(1);
});
-155
View File
@@ -1,155 +0,0 @@
/**
* WebSocket server for Python-Node.js bridge communication.
* Security: binds to 127.0.0.1 only; requires BRIDGE_TOKEN auth; rejects browser Origin headers.
*/
import { WebSocketServer, WebSocket } from 'ws';
import { WhatsAppClient, InboundMessage } from './whatsapp.js';
interface SendCommand {
type: 'send';
to: string;
text: string;
}
interface SendMediaCommand {
type: 'send_media';
to: string;
filePath: string;
mimetype: string;
caption?: string;
fileName?: string;
}
type BridgeCommand = SendCommand | SendMediaCommand;
interface BridgeMessage {
type: 'message' | 'status' | 'qr' | 'error';
[key: string]: unknown;
}
export class BridgeServer {
private wss: WebSocketServer | null = null;
private wa: WhatsAppClient | null = null;
private clients: Set<WebSocket> = new Set();
constructor(private port: number, private authDir: string, private token: string) {}
async start(): Promise<void> {
if (!this.token.trim()) {
throw new Error('BRIDGE_TOKEN is required');
}
// Bind to localhost only — never expose to external network
this.wss = new WebSocketServer({
host: '127.0.0.1',
port: this.port,
verifyClient: (info, done) => {
const origin = info.origin || info.req.headers.origin;
if (origin) {
console.warn(`Rejected WebSocket connection with Origin header: ${origin}`);
done(false, 403, 'Browser-originated WebSocket connections are not allowed');
return;
}
done(true);
},
});
console.log(`🌉 Bridge server listening on ws://127.0.0.1:${this.port}`);
console.log('🔒 Token authentication enabled');
// Initialize WhatsApp client
this.wa = new WhatsAppClient({
authDir: this.authDir,
onMessage: (msg) => this.broadcast({ type: 'message', ...msg }),
onQR: (qr) => this.broadcast({ type: 'qr', qr }),
onStatus: (status) => this.broadcast({ type: 'status', status }),
});
// Handle WebSocket connections
this.wss.on('connection', (ws) => {
// Require auth handshake as first message
const timeout = setTimeout(() => ws.close(4001, 'Auth timeout'), 5000);
ws.once('message', (data) => {
clearTimeout(timeout);
try {
const msg = JSON.parse(data.toString());
if (msg.type === 'auth' && msg.token === this.token) {
console.log('🔗 Python client authenticated');
this.setupClient(ws);
} else {
ws.close(4003, 'Invalid token');
}
} catch {
ws.close(4003, 'Invalid auth message');
}
});
});
// Connect to WhatsApp
await this.wa.connect();
}
private setupClient(ws: WebSocket): void {
this.clients.add(ws);
ws.on('message', async (data) => {
try {
const cmd = JSON.parse(data.toString()) as BridgeCommand;
await this.handleCommand(cmd);
ws.send(JSON.stringify({ type: 'sent', to: cmd.to }));
} catch (error) {
console.error('Error handling command:', error);
ws.send(JSON.stringify({ type: 'error', error: String(error) }));
}
});
ws.on('close', () => {
console.log('🔌 Python client disconnected');
this.clients.delete(ws);
});
ws.on('error', (error) => {
console.error('WebSocket error:', error);
this.clients.delete(ws);
});
}
private async handleCommand(cmd: BridgeCommand): Promise<void> {
if (!this.wa) return;
if (cmd.type === 'send') {
await this.wa.sendMessage(cmd.to, cmd.text);
} else if (cmd.type === 'send_media') {
await this.wa.sendMedia(cmd.to, cmd.filePath, cmd.mimetype, cmd.caption, cmd.fileName);
}
}
private broadcast(msg: BridgeMessage): void {
const data = JSON.stringify(msg);
for (const client of this.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(data);
}
}
}
async stop(): Promise<void> {
// Close all client connections
for (const client of this.clients) {
client.close();
}
this.clients.clear();
// Close WebSocket server
if (this.wss) {
this.wss.close();
this.wss = null;
}
// Disconnect WhatsApp
if (this.wa) {
await this.wa.disconnect();
this.wa = null;
}
}
}
-3
View File
@@ -1,3 +0,0 @@
declare module 'qrcode-terminal' {
export function generate(text: string, options?: { small?: boolean }): void;
}
-360
View File
@@ -1,360 +0,0 @@
/**
* WhatsApp client wrapper using Baileys.
* Based on OpenClaw's working implementation.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import makeWASocket, {
DisconnectReason,
useMultiFileAuthState,
fetchLatestBaileysVersion,
makeCacheableSignalKeyStore,
downloadMediaMessage,
extractMessageContent as baileysExtractMessageContent,
} from '@whiskeysockets/baileys';
import { Boom } from '@hapi/boom';
import qrcode from 'qrcode-terminal';
import pino from 'pino';
import { readFile, writeFile, mkdir } from 'fs/promises';
import { join, basename, resolve, sep } from 'path';
import { randomBytes } from 'crypto';
const VERSION = '0.1.0';
export interface InboundMessage {
id: string;
sender: string;
pn: string;
participant?: string;
content: string;
timestamp: number;
isGroup: boolean;
isForwarded?: boolean;
wasMentioned?: boolean;
isReplyToBot?: boolean;
media?: string[];
}
export interface WhatsAppClientOptions {
authDir: string;
onMessage: (msg: InboundMessage) => void;
onQR: (qr: string) => void;
onStatus: (status: string) => void;
}
export class WhatsAppClient {
private sock: any = null;
private options: WhatsAppClientOptions;
private reconnecting = false;
constructor(options: WhatsAppClientOptions) {
this.options = options;
}
private normalizeJid(jid: string | undefined | null): string {
return (jid || '').trim().toLowerCase().replace(/:\d+(?=@)/g, '');
}
private selfJids(): Set<string> {
return new Set(
[this.sock?.user?.id, this.sock?.user?.lid, this.sock?.user?.jid]
.map((jid) => this.normalizeJid(jid))
.filter(Boolean),
);
}
private messageContextInfos(msg: any): any[] {
const unwrapped = baileysExtractMessageContent(msg?.message);
const containers = [msg?.message, unwrapped];
const infos = containers.flatMap((message) => [
message?.extendedTextMessage?.contextInfo,
message?.imageMessage?.contextInfo,
message?.videoMessage?.contextInfo,
message?.documentMessage?.contextInfo,
message?.audioMessage?.contextInfo,
]);
return infos.filter(Boolean);
}
private botAddressing(msg: any): { wasMentioned: boolean; isReplyToBot: boolean } {
if (!msg?.key?.remoteJid?.endsWith('@g.us')) {
return { wasMentioned: false, isReplyToBot: false };
}
const selfIds = this.selfJids();
const contextInfos = this.messageContextInfos(msg);
const mentioned = contextInfos.flatMap((info) => (
Array.isArray(info?.mentionedJid) ? info.mentionedJid : []
));
const wasMentioned = mentioned.some((jid: string) => selfIds.has(this.normalizeJid(jid)));
const isReplyToBot = contextInfos.some((info) => {
const quotedParticipant = this.normalizeJid(info?.participant);
return Boolean(info?.stanzaId && quotedParticipant && selfIds.has(quotedParticipant));
});
return { wasMentioned, isReplyToBot };
}
private isForwarded(msg: any): boolean {
return this.messageContextInfos(msg).some((info) => Boolean(info?.isForwarded));
}
async connect(): Promise<void> {
const logger = pino({ level: 'silent' });
const { state, saveCreds } = await useMultiFileAuthState(this.options.authDir);
const { version } = await fetchLatestBaileysVersion();
console.log(`Using Baileys version: ${version.join('.')}`);
// Record startup time — messages older than this will be ignored
// to avoid replaying history on reconnect
const startupTimestamp = Math.floor(Date.now() / 1000);
// Create socket following OpenClaw's pattern
this.sock = makeWASocket({
auth: {
creds: state.creds,
keys: makeCacheableSignalKeyStore(state.keys, logger),
},
version,
logger,
printQRInTerminal: false,
browser: ['nanobot', 'cli', VERSION],
syncFullHistory: false,
markOnlineOnConnect: false,
});
// Handle WebSocket errors
if (this.sock.ws && typeof this.sock.ws.on === 'function') {
this.sock.ws.on('error', (err: Error) => {
console.error('WebSocket error:', err.message);
});
}
// Handle connection updates
this.sock.ev.on('connection.update', async (update: any) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
// Display QR code in terminal
console.log('\n📱 Scan this QR code with WhatsApp (Linked Devices):\n');
qrcode.generate(qr, { small: true });
this.options.onQR(qr);
}
if (connection === 'close') {
const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode;
const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
console.log(`Connection closed. Status: ${statusCode}, Will reconnect: ${shouldReconnect}`);
this.options.onStatus('disconnected');
if (shouldReconnect && !this.reconnecting) {
this.reconnecting = true;
console.log('Reconnecting in 5 seconds...');
setTimeout(() => {
this.reconnecting = false;
this.connect();
}, 5000);
}
} else if (connection === 'open') {
console.log('✅ Connected to WhatsApp');
this.options.onStatus('connected');
}
});
// Save credentials on update
this.sock.ev.on('creds.update', saveCreds);
// Handle incoming messages
this.sock.ev.on('messages.upsert', async ({ messages, type }: { messages: any[]; type: string }) => {
if (type !== 'notify') return;
for (const msg of messages) {
if (msg.key.fromMe) continue;
if (msg.key.remoteJid === 'status@broadcast') continue;
// Drop messages older than startup time (avoid replaying history on reconnect)
const msgTimestamp = msg.messageTimestamp as number;
if (msgTimestamp && msgTimestamp < startupTimestamp) continue;
// Send read receipt (blue check) immediately
try {
await this.sock!.readMessages([msg.key]);
} catch (e) {
// Non-fatal: log but don't block message processing
console.error('Failed to send read receipt:', (e as Error).message);
}
const unwrapped = baileysExtractMessageContent(msg.message);
if (!unwrapped) continue;
const content = this.getTextContent(unwrapped);
let fallbackContent: string | null = null;
const mediaPaths: string[] = [];
if (unwrapped.imageMessage) {
fallbackContent = '[Image]';
const path = await this.downloadMedia(msg, unwrapped.imageMessage.mimetype ?? undefined);
if (path) mediaPaths.push(path);
} else if (unwrapped.documentMessage) {
fallbackContent = '[Document]';
const path = await this.downloadMedia(msg, unwrapped.documentMessage.mimetype ?? undefined,
unwrapped.documentMessage.fileName ?? undefined);
if (path) mediaPaths.push(path);
} else if (unwrapped.videoMessage) {
fallbackContent = '[Video]';
const path = await this.downloadMedia(msg, unwrapped.videoMessage.mimetype ?? undefined);
if (path) mediaPaths.push(path);
} else if (unwrapped.audioMessage) {
fallbackContent = '[Voice Message]';
const path = await this.downloadMedia(msg, unwrapped.audioMessage.mimetype ?? undefined);
if (path) mediaPaths.push(path);
} else if (unwrapped.contactMessage) {
// Single shared contact
const displayName = unwrapped.contactMessage.displayName || '';
const vcard = unwrapped.contactMessage.vcard || '';
fallbackContent = `[Contact: ${displayName}]\n${vcard}`;
} else if (unwrapped.contactsArrayMessage) {
// Multiple shared contacts
const vcards = unwrapped.contactsArrayMessage.contacts || [];
const parts = vcards.map((c: any) => {
const name = c.displayName || '';
const vc = c.vcard || '';
return `[Contact: ${name}]\n${vc}`;
});
fallbackContent = parts.join('\n\n');
}
const isForwarded = this.isForwarded(msg);
const finalContent = content || (mediaPaths.length === 0 ? fallbackContent : '') || '';
if (!finalContent && mediaPaths.length === 0) continue;
const isGroup = msg.key.remoteJid?.endsWith('@g.us') || false;
const { wasMentioned, isReplyToBot } = this.botAddressing(msg);
this.options.onMessage({
id: msg.key.id || '',
sender: msg.key.remoteJid || '',
pn: msg.key.remoteJidAlt || '',
...(isGroup && msg.key.participant ? { participant: msg.key.participant } : {}),
content: finalContent,
timestamp: msg.messageTimestamp as number,
isGroup,
...(isForwarded ? { isForwarded } : {}),
...(isGroup ? { wasMentioned: wasMentioned || isReplyToBot, isReplyToBot } : {}),
...(mediaPaths.length > 0 ? { media: mediaPaths } : {}),
});
}
});
}
private async downloadMedia(msg: any, mimetype?: string, fileName?: string): Promise<string | null> {
try {
const mediaDir = join(this.options.authDir, '..', 'media');
await mkdir(mediaDir, { recursive: true });
const buffer = await downloadMediaMessage(msg, 'buffer', {}) as Buffer;
let outFilename: string;
if (fileName) {
const safeName = basename(fileName).replace(/[^a-zA-Z0-9._-]/g, '_');
outFilename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}_${safeName}`;
} else {
const mime = mimetype || 'application/octet-stream';
const ext = '.' + (mime.split('/').pop()?.split(';')[0] || 'bin');
outFilename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}${ext}`;
}
const filepath = resolve(mediaDir, outFilename);
if (!filepath.startsWith(resolve(mediaDir) + sep)) {
throw new Error(`Path traversal blocked: ${outFilename}`);
}
await writeFile(filepath, buffer);
return filepath;
} catch (err) {
console.error('Failed to download media:', err);
return null;
}
}
private getTextContent(message: any): string | null {
// Text message
if (message.conversation) {
return message.conversation;
}
// Extended text (reply, link preview)
if (message.extendedTextMessage?.text) {
return message.extendedTextMessage.text;
}
// Image with optional caption
if (message.imageMessage) {
return message.imageMessage.caption || '';
}
// Video with optional caption
if (message.videoMessage) {
return message.videoMessage.caption || '';
}
// Document with optional caption
if (message.documentMessage) {
return message.documentMessage.caption || '';
}
// Voice/Audio message
if (message.audioMessage) {
return `[Voice Message]`;
}
return null;
}
async sendMessage(to: string, text: string): Promise<void> {
if (!this.sock) {
throw new Error('Not connected');
}
await this.sock.sendMessage(to, { text });
}
async sendMedia(
to: string,
filePath: string,
mimetype: string,
caption?: string,
fileName?: string,
): Promise<void> {
if (!this.sock) {
throw new Error('Not connected');
}
const buffer = await readFile(filePath);
const category = mimetype.split('/')[0];
if (category === 'image') {
await this.sock.sendMessage(to, { image: buffer, caption: caption || undefined, mimetype });
} else if (category === 'video') {
await this.sock.sendMessage(to, { video: buffer, caption: caption || undefined, mimetype });
} else if (category === 'audio') {
await this.sock.sendMessage(to, { audio: buffer, mimetype });
} else {
const name = fileName || basename(filePath);
await this.sock.sendMessage(to, { document: buffer, mimetype, fileName: name });
}
}
async disconnect(): Promise<void> {
if (this.sock) {
this.sock.end(undefined);
this.sock = null;
}
}
}
-16
View File
@@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+25 -14
View File
@@ -303,9 +303,15 @@ nanobot gateway
<details>
<summary><b>WhatsApp</b></summary>
Requires **Node.js ≥18**.
Requires the WhatsApp optional dependencies:
**1. Link device**
```bash
pip install "nanobot-ai[whatsapp]"
# Source checkout:
python -m pip install -e ".[whatsapp]"
```
**1. Link device with QR**
```bash
nanobot channels login whatsapp
@@ -319,30 +325,35 @@ nanobot channels login whatsapp
"channels": {
"whatsapp": {
"enabled": true,
"allowFrom": ["+1234567890"]
"allowFrom": ["1234567890"]
}
}
}
```
**3. Run** (two terminals)
Optional session database path:
```json
{
"channels": {
"whatsapp": {
"databasePath": "~/.nanobot/whatsapp-auth/neonize.db"
}
}
}
```
**3. Run**
```bash
# Terminal 1
nanobot channels login whatsapp
# Terminal 2
nanobot gateway
```
> WhatsApp bridge updates are not applied automatically for existing installations. After upgrading nanobot, rebuild the local bridge with:
> `rm -rf ~/.nanobot/bridge && nanobot channels login whatsapp`
**Optional: static LID mappings**
Modern WhatsApp can deliver a sender's LID instead of their phone number. nanobot
learns the LID→phone mapping at runtime (and reuses the ones the bridge persists on
disk), but you can also seed mappings up front so the phone number resolves from the
learns LID to phone mappings at runtime when both identifiers are present, but you
can also seed mappings up front so the phone number resolves from the
very first message:
```json
@@ -350,7 +361,7 @@ very first message:
"channels": {
"whatsapp": {
"enabled": true,
"allowFrom": ["+1234567890"],
"allowFrom": ["1234567890"],
"lidMappings": { "123456789012345": "1234567890" }
}
}
+2 -3
View File
@@ -326,11 +326,10 @@ python -m pip install -e .
nanobot --version
```
If you use WhatsApp, rebuild the local bridge after upgrading:
If you use WhatsApp from a source checkout, keep the optional dependencies installed:
```bash
rm -rf ~/.nanobot/bridge
nanobot channels login whatsapp
python -m pip install -e ".[whatsapp]"
```
## First-Run Troubleshooting
+562 -314
View File
@@ -1,24 +1,23 @@
"""WhatsApp channel implementation using Node.js bridge."""
"""WhatsApp channel implementation using neonize."""
from __future__ import annotations
import asyncio
import hashlib
import json
import mimetypes
import os
import re
import secrets
import shutil
import subprocess
import time
from collections import OrderedDict
from contextlib import suppress
from pathlib import Path
from typing import Any, Literal
from typing import Any, Literal, NamedTuple
from loguru import logger
from pydantic import Field
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir, get_runtime_subdir
from nanobot.config.schema import Base
@@ -26,45 +25,244 @@ class WhatsAppConfig(Base):
"""WhatsApp channel configuration."""
enabled: bool = False
bridge_url: str = "ws://localhost:3001"
bridge_token: str = ""
allow_from: list[str] = Field(default_factory=list)
group_policy: Literal["open", "mention"] = "open" # "open" responds to all, "mention" only when @mentioned
# Optional static LID->phone mappings, e.g. {"123456789012345": "15551234567"}.
# Useful to resolve a sender's phone number from the very first message instead of
# only after a message that carries both phone and LID. Merged with mappings the
# bridge persists on disk (lid-mapping-*_reverse.json) under the auth directory.
group_policy: Literal["open", "mention"] = "open"
database_path: str = ""
lid_mappings: dict[str, str] = Field(default_factory=dict)
def _bridge_token_path() -> Path:
from nanobot.config.paths import get_runtime_subdir
return get_runtime_subdir("whatsapp-auth") / "bridge-token"
class _NeonizeAPI(NamedTuple):
NewAClient: Any
ConnectedEv: Any
DisconnectedEv: Any
MessageEv: Any
PairStatusEv: Any
build_jid: Any
def _load_or_create_bridge_token(path: Path) -> str:
"""Load a persisted bridge token or create one on first use."""
if path.exists():
token = path.read_text(encoding="utf-8").strip()
if token:
return token
class _MediaInfo(NamedTuple):
kind: str
message: Any
mimetype: str
filename: str
is_voice: bool = False
path.parent.mkdir(parents=True, exist_ok=True)
token = secrets.token_urlsafe(32)
path.write_text(token, encoding="utf-8")
with suppress(OSError):
path.chmod(0o600)
return token
_NEONIZE_API: _NeonizeAPI | None = None
_JID_RE = re.compile(r"^(?P<user>[^@]+)@(?P<server>[^@]+)$")
def _default_database_path() -> Path:
return get_runtime_subdir("whatsapp-auth") / "neonize.db"
def _load_neonize() -> _NeonizeAPI:
global _NEONIZE_API
if _NEONIZE_API is not None:
return _NEONIZE_API
try:
from neonize.aioze.client import NewAClient
from neonize.aioze.events import ConnectedEv, DisconnectedEv, MessageEv, PairStatusEv
from neonize.utils.jid import build_jid
except ImportError as exc:
raise RuntimeError(
'WhatsApp dependencies not installed. Run: pip install "nanobot-ai[whatsapp]"'
) from exc
_NEONIZE_API = _NeonizeAPI(
NewAClient=NewAClient,
ConnectedEv=ConnectedEv,
DisconnectedEv=DisconnectedEv,
MessageEv=MessageEv,
PairStatusEv=PairStatusEv,
build_jid=build_jid,
)
return _NEONIZE_API
def _has_field(message: Any, name: str) -> bool:
if message is None:
return False
has_field = getattr(message, "HasField", None)
if callable(has_field):
try:
return bool(has_field(name))
except ValueError:
pass
list_fields = getattr(message, "ListFields", None)
if callable(list_fields):
try:
return any(getattr(field, "name", "") == name for field, _ in list_fields())
except Exception:
pass
value = getattr(message, name, None)
return value is not None and value != "" and value != b""
def _message_field(message: Any, *names: str) -> Any:
for name in names:
if _has_field(message, name):
return getattr(message, name)
return None
def _safe_attr(obj: Any, name: str, default: Any = None) -> Any:
if obj is None:
return default
return getattr(obj, name, default)
def _jid_to_string(jid: Any) -> str:
if jid is None:
return ""
if isinstance(jid, str):
return jid.strip()
if bool(_safe_attr(jid, "IsEmpty", False)):
return ""
user = str(_safe_attr(jid, "User", "") or "").strip()
server = str(_safe_attr(jid, "Server", "") or "").strip()
if user and server:
return f"{user}@{server}"
return server or user
def _normalize_jid(raw: Any) -> str:
jid = _jid_to_string(raw).strip()
if not jid:
return ""
if jid.endswith("@lid.whatsapp.net"):
return jid[: -len(".whatsapp.net")]
return jid
def _bare_jid(raw: Any) -> str:
jid = _normalize_jid(raw)
if "@" not in jid:
return jid
return jid.split("@", 1)[0].split(":", 1)[0]
def _classify_sender_ids(jids: list[Any]) -> tuple[str, str]:
phone_id = ""
lid_id = ""
for raw in jids:
jid = _normalize_jid(raw)
if not jid:
continue
match = _JID_RE.match(jid)
if match:
user = match.group("user").split(":", 1)[0]
server = match.group("server")
if server in {"s.whatsapp.net", "c.us"}:
phone_id = phone_id or user
elif server in {"lid", "lid.whatsapp.net"}:
lid_id = lid_id or user
continue
if not phone_id:
phone_id = jid
return phone_id, lid_id
def _context_infos(message: Any) -> list[Any]:
infos: list[Any] = []
for container in (
message,
_message_field(message, "extendedTextMessage"),
_message_field(message, "imageMessage"),
_message_field(message, "videoMessage"),
_message_field(message, "audioMessage"),
_message_field(message, "documentMessage"),
_message_field(message, "stickerMessage"),
):
context = _message_field(container, "contextInfo")
if context is not None:
infos.append(context)
return infos
def _message_text(message: Any) -> str:
conversation = str(_safe_attr(message, "conversation", "") or "").strip()
if conversation:
return conversation
extended = _message_field(message, "extendedTextMessage")
text = str(_safe_attr(extended, "text", "") or "").strip()
if text:
return text
for field_name in ("imageMessage", "videoMessage", "documentMessage", "stickerMessage"):
media_message = _message_field(message, field_name)
caption = str(_safe_attr(media_message, "caption", "") or "").strip()
if caption:
return caption
return ""
def _media_message(message: Any) -> _MediaInfo | None:
image = _message_field(message, "imageMessage")
if image is not None:
return _MediaInfo(
kind="image",
message=image,
mimetype=str(_safe_attr(image, "mimetype", "") or "image/jpeg"),
filename=str(_safe_attr(image, "fileName", "") or ""),
)
video = _message_field(message, "videoMessage")
if video is not None:
return _MediaInfo(
kind="video",
message=video,
mimetype=str(_safe_attr(video, "mimetype", "") or "video/mp4"),
filename=str(_safe_attr(video, "fileName", "") or ""),
)
audio = _message_field(message, "audioMessage")
if audio is not None:
return _MediaInfo(
kind="audio",
message=audio,
mimetype=str(_safe_attr(audio, "mimetype", "") or "audio/ogg"),
filename=str(_safe_attr(audio, "fileName", "") or ""),
is_voice=bool(_safe_attr(audio, "PTT", False) or _safe_attr(audio, "ptt", False)),
)
document = _message_field(message, "documentMessage")
if document is not None:
return _MediaInfo(
kind="file",
message=document,
mimetype=str(_safe_attr(document, "mimetype", "") or "application/octet-stream"),
filename=str(
_safe_attr(document, "fileName", "")
or _safe_attr(document, "title", "")
or ""
),
)
sticker = _message_field(message, "stickerMessage")
if sticker is not None:
return _MediaInfo(
kind="sticker",
message=sticker,
mimetype=str(_safe_attr(sticker, "mimetype", "") or "image/webp"),
filename=str(_safe_attr(sticker, "fileName", "") or ""),
)
return None
class WhatsAppChannel(BaseChannel):
"""
WhatsApp channel that connects to a Node.js bridge.
The bridge uses @whiskeysockets/baileys to handle the WhatsApp Web protocol.
Communication between Python and Node.js is via WebSocket.
"""
"""WhatsApp channel using neonize's async WhatsApp client."""
name = "whatsapp"
display_name = "WhatsApp"
@@ -77,208 +275,244 @@ class WhatsAppChannel(BaseChannel):
if isinstance(config, dict):
config = WhatsAppConfig.model_validate(config)
super().__init__(config, bus)
self._ws = None
self._client: Any | None = None
self._connected = False
self._processed_message_ids: OrderedDict[str, None] = OrderedDict()
self._lid_to_phone: dict[str, str] = self._load_lid_mappings()
self._bridge_token: str | None = None
self._lid_to_phone = self._load_lid_mappings()
self._self_jids: set[str] = set()
self._started_at = 0.0
def _database_path(self) -> Path:
configured = self.config.database_path.strip()
return Path(configured).expanduser() if configured else _default_database_path()
def _load_lid_mappings(self) -> dict[str, str]:
"""Seed LID->phone mappings on startup.
Combines two sources so the sender's phone number can be resolved from the
very first message (instead of only after one that carries both phone and LID):
1. Reverse mapping files the bridge persists in the auth directory, named
``lid-mapping-<lid>_reverse.json`` and containing the phone number string.
2. Static ``lid_mappings`` from the channel config (takes precedence).
"""
from nanobot.config.paths import get_runtime_subdir
mapping: dict[str, str] = {}
auth_dir = get_runtime_subdir("whatsapp-auth")
if auth_dir.is_dir():
for path in auth_dir.glob("lid-mapping-*_reverse.json"):
lid = path.name[len("lid-mapping-"):-len("_reverse.json")]
try:
phone = json.loads(path.read_text(encoding="utf-8"))
except Exception:
continue
if isinstance(phone, str) and phone.strip():
mapping[lid] = phone.strip()
for lid, phone in getattr(self.config, "lid_mappings", {}).items():
if isinstance(phone, str) and phone.strip():
mapping[str(lid)] = phone.strip()
for lid, phone in self.config.lid_mappings.items():
phone_text = str(phone).strip()
if phone_text:
mapping[str(lid).strip()] = phone_text
return mapping
def _effective_bridge_token(self) -> str:
"""Resolve the bridge token, generating a local secret when needed."""
if self._bridge_token is not None:
return self._bridge_token
configured = self.config.bridge_token.strip()
if configured:
self._bridge_token = configured
else:
self._bridge_token = _load_or_create_bridge_token(_bridge_token_path())
return self._bridge_token
def _new_client(self) -> Any:
api = _load_neonize()
db_path = self._database_path()
db_path.parent.mkdir(parents=True, exist_ok=True)
return api.NewAClient(str(db_path))
async def login(self, force: bool = False) -> bool:
"""
Set up and run the WhatsApp bridge for QR code login.
db_path = self._database_path()
if force:
self._reset_database(db_path)
client = self._new_client()
login_result = asyncio.get_running_loop().create_future()
self._register_handlers(client, login_result=login_result, handle_messages=False)
This spawns the Node.js bridge process which handles the WhatsApp
authentication flow. The process blocks until the user scans the QR code
or interrupts with Ctrl+C.
"""
try:
bridge_dir = _ensure_bridge_setup()
except RuntimeError:
self.logger.exception("bridge setup failed")
return False
env = {**os.environ}
env["BRIDGE_TOKEN"] = self._effective_bridge_token()
env["AUTH_DIR"] = str(_bridge_token_path().parent)
self.logger.info("Starting WhatsApp bridge for QR login...")
try:
subprocess.run(
[shutil.which("npm"), "start"], cwd=bridge_dir, check=True, env=env
)
except subprocess.CalledProcessError:
return False
self.logger.info("Starting WhatsApp login with neonize...")
connect_task = await client.connect()
self._fail_login_on_connect_task_done(connect_task, login_result)
await login_result
self.logger.info("WhatsApp login complete")
return True
except Exception as exc:
self.logger.error("WhatsApp login failed: {}", exc)
return False
finally:
with suppress(Exception):
await client.stop()
async def start(self) -> None:
"""Start the WhatsApp channel by connecting to the bridge."""
import websockets
bridge_url = self.config.bridge_url
self.logger.info("Connecting to WhatsApp bridge at {}...", bridge_url)
self._running = True
self._started_at = time.time()
client = self._new_client()
self._client = client
self._register_handlers(client, handle_messages=True)
while self._running:
try:
async with websockets.connect(bridge_url) as ws:
self._ws = ws
await ws.send(
json.dumps({"type": "auth", "token": self._effective_bridge_token()})
)
self._connected = True
self.logger.info("Connected to WhatsApp bridge")
# Listen for messages
async for message in ws:
try:
await self._handle_bridge_message(message)
except Exception:
self.logger.exception("Error handling bridge message")
self.logger.info("Connecting WhatsApp channel with neonize...")
await client.connect()
await client.idle()
except asyncio.CancelledError:
break
except Exception as e:
self._connected = False
self._ws = None
self.logger.warning("WhatsApp bridge connection error: {}", e)
if self._running:
self.logger.info("Reconnecting in 5 seconds...")
await asyncio.sleep(5)
async def stop(self) -> None:
"""Stop the WhatsApp channel."""
raise
finally:
self._running = False
self._connected = False
if self._client is client:
self._client = None
with suppress(Exception):
await client.stop()
if self._ws:
await self._ws.close()
self._ws = None
async def stop(self) -> None:
self._running = False
self._connected = False
client = self._client
self._client = None
if client is not None:
await client.stop()
@staticmethod
def _fail_login_on_connect_task_done(
connect_task: asyncio.Task[Any] | None,
login_result: asyncio.Future[None],
) -> None:
if connect_task is None:
return
def _on_done(task: asyncio.Task[Any]) -> None:
try:
exc = task.exception()
except asyncio.CancelledError:
return
if login_result.done():
return
if exc is not None:
login_result.set_exception(exc)
else:
login_result.set_exception(
RuntimeError("WhatsApp connection ended before login completed")
)
connect_task.add_done_callback(_on_done)
async def send(self, msg: OutboundMessage) -> None:
"""Send a message through WhatsApp."""
if not self._ws or not self._connected:
self.logger.warning("WhatsApp bridge not connected")
return
chat_id = msg.chat_id
client = self._client
if client is None or not self._connected:
raise RuntimeError("WhatsApp channel is not connected")
to = self._build_jid(msg.chat_id)
if msg.content:
try:
payload = {"type": "send", "to": chat_id, "text": msg.content}
await self._ws.send(json.dumps(payload, ensure_ascii=False))
except Exception:
self.logger.exception("Error sending message")
raise
await client.send_message(to, msg.content)
for media_path in msg.media or []:
await self._send_media(client, to, media_path)
def _build_jid(self, raw: str) -> Any:
api = _load_neonize()
target = raw.strip()
match = _JID_RE.match(_normalize_jid(target))
if not match:
return api.build_jid(target)
user = match.group("user").split(":", 1)[0]
server = match.group("server")
return api.build_jid(user, server)
async def _send_media(self, client: Any, to: Any, media_path: str) -> None:
path = str(Path(media_path).expanduser())
mime, _ = mimetypes.guess_type(path)
mimetype = mime or "application/octet-stream"
if mimetype.startswith("image/"):
await client.send_image(to, path)
elif mimetype.startswith("video/"):
await client.send_video(to, path)
elif mimetype.startswith("audio/"):
await client.send_audio(to, path)
else:
await client.send_document(
to,
path,
filename=Path(path).name,
mimetype=mimetype,
)
def _register_handlers(
self,
client: Any,
*,
login_result: asyncio.Future[None] | None = None,
handle_messages: bool,
) -> None:
api = _load_neonize()
@client.qr
async def _on_qr(_: Any, qr_data: bytes) -> None:
import segno
self.logger.info("Scan the WhatsApp QR code with Linked Devices")
segno.make_qr(qr_data).terminal(compact=True)
@client.event(api.ConnectedEv)
async def _on_connected(current_client: Any, _: Any) -> None:
self._connected = True
try:
mime, _ = mimetypes.guess_type(media_path)
payload = {
"type": "send_media",
"to": chat_id,
"filePath": media_path,
"mimetype": mime or "application/octet-stream",
"fileName": media_path.rsplit("/", 1)[-1],
}
await self._ws.send(json.dumps(payload, ensure_ascii=False))
await self._remember_self_jids(current_client)
except Exception as exc:
if login_result is not None and not login_result.done():
login_result.set_exception(exc)
raise
if login_result is not None and not login_result.done():
login_result.set_result(None)
self.logger.info("WhatsApp connected")
@client.event(api.DisconnectedEv)
async def _on_disconnected(_: Any, event: Any) -> None:
self._connected = False
if login_result is not None and not login_result.done():
login_result.set_exception(
RuntimeError(f"WhatsApp disconnected before login completed: {event}")
)
self.logger.warning("WhatsApp disconnected: {}", event)
@client.event(api.PairStatusEv)
async def _on_pair_status(_: Any, event: Any) -> None:
error = str(_safe_attr(event, "Error", "") or "")
if error:
exc = RuntimeError(f"WhatsApp pair status error: {error}")
if login_result is not None and not login_result.done():
login_result.set_exception(exc)
raise exc
self.logger.info("WhatsApp pair status: {}", event)
if not handle_messages:
return
@client.event(api.MessageEv)
async def _on_message(current_client: Any, event: Any) -> None:
try:
await self._handle_neonize_message(current_client, event)
except Exception:
self.logger.exception("Error sending media {}", media_path)
self.logger.exception("Error handling WhatsApp message")
raise
async def _handle_bridge_message(self, raw: str) -> None:
"""Handle a message from the bridge."""
try:
data = json.loads(raw)
except json.JSONDecodeError:
self.logger.warning("Invalid JSON from bridge: {}", raw[:100])
async def _remember_self_jids(self, client: Any) -> None:
device = _safe_attr(client, "me")
if device is None:
device = await client.get_me()
for attr in ("JID", "LID"):
jid = _normalize_jid(_safe_attr(device, attr))
if jid:
self._self_jids.add(jid)
self._self_jids.add(_bare_jid(jid))
async def _handle_neonize_message(self, client: Any, event: Any) -> None:
info = _safe_attr(event, "Info")
message = _safe_attr(event, "Message")
source = _safe_attr(info, "MessageSource")
if info is None or message is None or source is None:
raise ValueError("WhatsApp MessageEv is missing Info, Message, or MessageSource")
if bool(_safe_attr(source, "IsFromMe", False)):
return
msg_type = data.get("type")
if msg_type == "message":
# Incoming message from WhatsApp
# Deprecated by whatsapp: old phone number style typically: <phone>@s.whatspp.net
pn = data.get("pn", "")
# New LID sytle typically:
sender = data.get("sender", "")
content = data.get("content", "")
message_id = data.get("id", "")
# Extract just the phone number or lid as chat_id
is_group = data.get("isGroup", False)
was_mentioned = bool(data.get("wasMentioned", False) or data.get("isReplyToBot", False))
if is_group and getattr(self.config, "group_policy", "open") == "mention":
if not was_mentioned:
chat_jid = _normalize_jid(_safe_attr(source, "Chat"))
if not chat_jid:
raise ValueError("WhatsApp message has no chat JID")
if chat_jid == "status@broadcast":
return
# Classify by JID suffix: @s.whatsapp.net = phone, @lid.whatsapp.net = LID
# The bridge's pn/sender fields don't consistently map to phone/LID across versions.
raw_a = pn or ""
participant = data.get("participant", "")
raw_b = participant or sender or ""
id_a = raw_a.split("@")[0] if "@" in raw_a else raw_a
id_b = raw_b.split("@")[0] if "@" in raw_b else raw_b
phone_id = ""
lid_id = ""
for raw, extracted in [(raw_a, id_a), (raw_b, id_b)]:
if "@s.whatsapp.net" in raw:
phone_id = extracted
elif "@lid.whatsapp.net" in raw:
lid_id = extracted
elif extracted and not phone_id:
phone_id = extracted # best guess for bare values
sender_id = phone_id or self._lid_to_phone.get(lid_id, "") or lid_id or id_a or id_b
if not self.is_allowed(sender_id):
timestamp = float(_safe_attr(info, "Timestamp", 0) or 0)
if self._started_at and timestamp and timestamp < self._started_at:
return
is_group = bool(_safe_attr(source, "IsGroup", False))
if is_group and self.config.group_policy == "mention":
if not self._is_addressed_to_bot(message):
return
message_id = str(_safe_attr(info, "ID", "") or "")
if message_id:
if message_id in self._processed_message_ids:
return
@@ -286,137 +520,151 @@ class WhatsAppChannel(BaseChannel):
while len(self._processed_message_ids) > 1000:
self._processed_message_ids.popitem(last=False)
participant_jid = _normalize_jid(_safe_attr(source, "Sender"))
sender_alt_jid = _normalize_jid(_safe_attr(source, "SenderAlt"))
sender_candidates = [sender_alt_jid, participant_jid]
if not is_group:
sender_candidates.append(chat_jid)
phone_id, lid_id = _classify_sender_ids(sender_candidates)
if phone_id and lid_id:
self._lid_to_phone[lid_id] = phone_id
self.logger.info("Sender phone={} lid={} → sender_id={}", phone_id or "(empty)", lid_id or "(empty)", sender_id)
sender_id = phone_id or self._lid_to_phone.get(lid_id, "") or lid_id
if not sender_id:
raise ValueError("WhatsApp message has no resolvable sender ID")
metadata = {
"message_id": message_id or None,
"timestamp": int(timestamp) if timestamp else None,
"is_group": is_group,
"is_forwarded": self._is_forwarded(message),
"participant": participant_jid or None,
"sender_alt": sender_alt_jid or None,
"lid": lid_id or None,
"phone": phone_id or None,
"is_reply_to_bot": self._is_reply_to_bot(message),
}
if not self.is_allowed(sender_id):
self.logger.info(
"Passing unauthorized WhatsApp sender {} to pairing flow "
"(phone={}, lid={}, chat={})",
sender_id,
phone_id or "",
lid_id or "",
chat_jid,
)
await self._handle_message(
sender_id=sender_id,
chat_id=chat_jid,
content=_message_text(message),
media=[],
metadata=metadata,
is_dm=not is_group,
)
return
# Extract media paths (images/documents/videos downloaded by the bridge)
media_paths = data.get("media") or []
# Handle voice transcription if it's a voice message
if content == "[Voice Message]":
if media_paths:
self.logger.info("Transcribing voice message from {}...", sender_id)
transcription = await self.transcribe_audio(media_paths[0])
text = _message_text(message)
media_paths: list[str] = []
media = _media_message(message)
if media is not None:
path = await self._download_media(client, event, media)
if media.kind == "audio" and media.is_voice:
transcription = await self.transcribe_audio(path)
if transcription:
content = transcription
media_paths = []
self.logger.info("Transcribed voice from {}: {}...", sender_id, transcription[:50])
text = transcription
else:
content = "[Voice Message: Transcription failed]"
media_paths.append(path)
text = self._append_media_tag(text, "audio", path)
else:
content = "[Voice Message: Audio not available]"
media_paths.append(path)
text = self._append_media_tag(text, media.kind, path)
# Build content tags matching Telegram's pattern: [image: /path] or [file: /path]
if media_paths:
for p in media_paths:
mime, _ = mimetypes.guess_type(p)
media_type = "image" if mime and mime.startswith("image/") else "file"
media_tag = f"[{media_type}: {p}]"
content = f"{content}\n{media_tag}" if content else media_tag
if not text and not media_paths:
return
await self._handle_message(
sender_id=sender_id,
chat_id=sender, # Use full LID for replies
content=content,
chat_id=chat_jid,
content=text,
media=media_paths,
metadata={
"message_id": message_id,
"timestamp": data.get("timestamp"),
"is_group": data.get("isGroup", False),
"is_forwarded": bool(data.get("isForwarded", False)),
"participant": participant or None,
"is_reply_to_bot": data.get("isReplyToBot", False),
},
metadata=metadata,
is_dm=not is_group,
)
elif msg_type == "status":
# Connection status update
status = data.get("status")
self.logger.info("Status: {}", status)
def _is_addressed_to_bot(self, message: Any) -> bool:
return self._was_mentioned(message) or self._is_reply_to_bot(message)
if status == "connected":
self._connected = True
elif status == "disconnected":
self._connected = False
elif msg_type == "qr":
# QR code for authentication
self.logger.info("Scan QR code in the bridge terminal to connect WhatsApp")
elif msg_type == "error":
self.logger.error("Bridge error: {}", data.get("error"))
def _ensure_bridge_setup() -> Path:
"""
Ensure the WhatsApp bridge is set up and built.
Returns the bridge directory. Raises RuntimeError if npm is not found
or bridge cannot be built.
"""
from nanobot.config.paths import get_bridge_install_dir
user_bridge = get_bridge_install_dir()
stamp_file = user_bridge / ".nanobot-bridge-source-hash"
# Find source bridge
current_file = Path(__file__)
pkg_bridge = current_file.parent.parent / "bridge"
src_bridge = current_file.parent.parent.parent / "bridge"
source = None
if (pkg_bridge / "package.json").exists():
source = pkg_bridge
elif (src_bridge / "package.json").exists():
source = src_bridge
if not source:
raise RuntimeError(
"WhatsApp bridge source not found. "
"Try reinstalling: pip install --force-reinstall nanobot"
def _was_mentioned(self, message: Any) -> bool:
if not self._self_jids:
return False
for context in _context_infos(message):
mentioned = (
_safe_attr(context, "mentionedJID")
or _safe_attr(context, "mentionedJid")
or _safe_attr(context, "mentioned_jid")
or []
)
for jid in mentioned:
normalized = _normalize_jid(jid)
if normalized in self._self_jids or _bare_jid(normalized) in self._self_jids:
return True
return False
def source_hash(root: Path) -> str:
digest = hashlib.sha256()
for path in sorted(root.rglob("*")):
if not path.is_file():
continue
rel = path.relative_to(root)
if rel.parts and rel.parts[0] in {"node_modules", "dist"}:
continue
digest.update(rel.as_posix().encode("utf-8"))
digest.update(b"\0")
digest.update(path.read_bytes())
digest.update(b"\0")
return digest.hexdigest()
def _is_reply_to_bot(self, message: Any) -> bool:
if not self._self_jids:
return False
for context in _context_infos(message):
participant = _normalize_jid(
_safe_attr(context, "participant")
or _safe_attr(context, "Participant")
or ""
)
if participant in self._self_jids or _bare_jid(participant) in self._self_jids:
return True
return False
expected_hash = source_hash(source)
current_hash = stamp_file.read_text().strip() if stamp_file.exists() else None
@staticmethod
def _is_forwarded(message: Any) -> bool:
for context in _context_infos(message):
if bool(_safe_attr(context, "isForwarded", False)):
return True
if int(_safe_attr(context, "forwardingScore", 0) or 0) > 0:
return True
return False
if (user_bridge / "dist" / "index.js").exists() and current_hash == expected_hash:
return user_bridge
async def _download_media(self, client: Any, event: Any, media: _MediaInfo) -> str:
info = _safe_attr(event, "Info")
message_id = str(_safe_attr(info, "ID", "") or "")
path = self._media_path(message_id, media)
await client.download_any(_safe_attr(event, "Message"), str(path))
return str(path)
if (user_bridge / "dist" / "index.js").exists() and current_hash != expected_hash:
logger.info("WhatsApp bridge source changed; rebuilding bridge...")
def _media_path(self, message_id: str, media: _MediaInfo) -> Path:
media_dir = get_media_dir("whatsapp")
safe_id = re.sub(r"[^A-Za-z0-9_.-]+", "_", message_id or str(int(time.time())))
filename = Path(media.filename).name if media.filename else ""
suffix = Path(filename).suffix if filename else ""
if not suffix:
suffix = mimetypes.guess_extension(media.mimetype) or {
"image": ".jpg",
"video": ".mp4",
"audio": ".ogg",
"sticker": ".webp",
}.get(media.kind, ".bin")
return media_dir / f"wa_{safe_id}_{secrets.token_hex(4)}{suffix}"
npm_path = shutil.which("npm")
if not npm_path:
raise RuntimeError("npm not found. Please install Node.js >= 20.")
@staticmethod
def _append_media_tag(text: str, kind: str, path: str) -> str:
label = kind if kind in {"image", "video", "audio", "sticker"} else "file"
tag = f"[{label}: {path}]"
return f"{text}\n{tag}" if text else tag
logger.info("Setting up WhatsApp bridge...")
user_bridge.parent.mkdir(parents=True, exist_ok=True)
if user_bridge.exists():
shutil.rmtree(user_bridge)
shutil.copytree(source, user_bridge, ignore=shutil.ignore_patterns("node_modules", "dist"))
logger.info(" Installing dependencies...")
subprocess.run([npm_path, "install"], cwd=user_bridge, check=True, capture_output=True)
logger.info(" Building...")
subprocess.run([npm_path, "run", "build"], cwd=user_bridge, check=True, capture_output=True)
stamp_file.write_text(expected_hash + "\n")
logger.info("Bridge ready")
return user_bridge
@staticmethod
def _reset_database(path: Path) -> None:
for candidate in (
path,
path.with_suffix(path.suffix + "-shm"),
path.with_suffix(path.suffix + "-wal"),
):
if candidate.exists():
candidate.unlink()
-2
View File
@@ -2,7 +2,6 @@
from nanobot.config.loader import get_config_path, load_config
from nanobot.config.paths import (
get_bridge_install_dir,
get_cli_history_path,
get_cron_dir,
get_data_dir,
@@ -29,6 +28,5 @@ __all__ = [
"get_workspace_path",
"is_default_workspace",
"get_cli_history_path",
"get_bridge_install_dir",
"get_legacy_sessions_dir",
]
-5
View File
@@ -66,11 +66,6 @@ def get_cli_history_path() -> Path:
return Path.home() / ".nanobot" / "history" / "cli_history"
def get_bridge_install_dir() -> Path:
"""Return the shared WhatsApp bridge installation directory."""
return Path.home() / ".nanobot" / "bridge"
def get_legacy_sessions_dir() -> Path:
"""Return the legacy global session directory used for migration fallback."""
return Path.home() / ".nanobot" / "sessions"
+4 -4
View File
@@ -93,6 +93,10 @@ matrix = [
discord = [
"discord.py>=2.5.2,<3.0.0",
]
whatsapp = [
"neonize>=0.3.18.post0,<0.4.0",
"segno>=1.6.1,<2.0.0",
]
langsmith = [
"langsmith>=0.1.0",
]
@@ -150,14 +154,10 @@ packages = ["nanobot"]
[tool.hatch.build.targets.wheel.sources]
"nanobot" = "nanobot"
[tool.hatch.build.targets.wheel.force-include]
"bridge" = "nanobot/bridge"
[tool.hatch.build.targets.sdist]
include = [
"nanobot/",
"nanobot/web/dist/",
"bridge/",
"hatch_build.py",
"README.md",
"LICENSE",
+361 -418
View File
@@ -1,507 +1,450 @@
"""Tests for WhatsApp channel outbound media support."""
from __future__ import annotations
import json
import os
import sys
import types
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.bus.events import OutboundMessage
from nanobot.channels.whatsapp import (
WhatsAppChannel,
_load_or_create_bridge_token,
from nanobot.channels import whatsapp as whatsapp_module
from nanobot.channels.whatsapp import WhatsAppChannel, _NeonizeAPI
class _Proto:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
def HasField(self, name: str) -> bool: # noqa: N802 - protobuf compatibility
return _is_set(getattr(self, name, None))
def ListFields(self): # noqa: N802 - protobuf compatibility
return [
(SimpleNamespace(name=name), value)
for name, value in self.__dict__.items()
if _is_set(value)
]
def _is_set(value) -> bool:
if value is None:
return False
if isinstance(value, (str, bytes, list, tuple, dict, set)):
return bool(value)
return True
def _jid(user: str, server: str) -> _Proto:
return _Proto(User=user, Server=server, IsEmpty=False)
def _event(
*,
message: _Proto,
message_id: str = "m1",
chat: _Proto | None = None,
sender: _Proto | None = None,
sender_alt: _Proto | None = None,
is_group: bool = False,
timestamp: int = 1,
is_from_me: bool = False,
) -> _Proto:
source = _Proto(
Chat=chat or _jid("15551234567", "s.whatsapp.net"),
Sender=sender,
SenderAlt=sender_alt,
IsGroup=is_group,
IsFromMe=is_from_me,
)
return _Proto(
Info=_Proto(ID=message_id, Timestamp=timestamp, MessageSource=source),
Message=message,
)
def _make_channel() -> WhatsAppChannel:
bus = MagicMock()
ch = WhatsAppChannel({"enabled": True}, bus)
ch._ws = AsyncMock()
ch._connected = True
def _make_channel(config: dict | None = None) -> WhatsAppChannel:
merged = {"enabled": True, "allowFrom": ["*"]}
if config:
merged.update(config)
ch = WhatsAppChannel(merged, MagicMock())
ch._started_at = 0
return ch
@pytest.mark.asyncio
async def test_send_text_only():
ch = _make_channel()
msg = OutboundMessage(channel="whatsapp", chat_id="123@s.whatsapp.net", content="hello")
await ch.send(msg)
ch._ws.send.assert_called_once()
payload = json.loads(ch._ws.send.call_args[0][0])
assert payload["type"] == "send"
assert payload["text"] == "hello"
@pytest.mark.asyncio
async def test_send_media_dispatches_send_media_command():
ch = _make_channel()
msg = OutboundMessage(
channel="whatsapp",
chat_id="123@s.whatsapp.net",
content="check this out",
media=["/tmp/photo.jpg"],
def _patch_neonize_api(monkeypatch) -> None:
monkeypatch.setattr(
whatsapp_module,
"_NEONIZE_API",
_NeonizeAPI(
NewAClient=object,
ConnectedEv=object(),
DisconnectedEv=object(),
MessageEv=object(),
PairStatusEv=object(),
build_jid=lambda user, server="s.whatsapp.net": (user, server),
),
)
await ch.send(msg)
assert ch._ws.send.call_count == 2
text_payload = json.loads(ch._ws.send.call_args_list[0][0][0])
media_payload = json.loads(ch._ws.send.call_args_list[1][0][0])
class _FakeLoginClient:
def __init__(self) -> None:
self.handlers = {}
self.me = _Proto(JID=_jid("bot", "s.whatsapp.net"), LID=_jid("BOTLID", "lid"))
self.stop = AsyncMock()
assert text_payload["type"] == "send"
assert text_payload["text"] == "check this out"
def event(self, event_type):
def register(func):
self.handlers[event_type] = func
return func
assert media_payload["type"] == "send_media"
assert media_payload["filePath"] == "/tmp/photo.jpg"
assert media_payload["mimetype"] == "image/jpeg"
assert media_payload["fileName"] == "photo.jpg"
return register
def qr(self, func):
self.qr_handler = func
return func
async def connect(self) -> None:
await self.handlers[whatsapp_module._NEONIZE_API.ConnectedEv](self, _Proto())
class _FailingConnectLoginClient(_FakeLoginClient):
async def connect(self) -> asyncio.Task[None]:
async def fail() -> None:
raise RuntimeError("dial failed")
return asyncio.create_task(fail())
def test_default_config_has_no_bridge_fields() -> None:
config = WhatsAppChannel.default_config()
assert "bridgeUrl" not in config
assert "bridgeToken" not in config
assert config["databasePath"] == ""
@pytest.mark.asyncio
async def test_send_media_only_no_text():
async def test_login_succeeds_when_connected(monkeypatch) -> None:
_patch_neonize_api(monkeypatch)
client = _FakeLoginClient()
ch = _make_channel()
msg = OutboundMessage(
ch._new_client = MagicMock(return_value=client)
assert await ch.login() is True
assert ch._self_jids == {"bot@s.whatsapp.net", "bot", "BOTLID@lid", "BOTLID"}
client.stop.assert_awaited_once()
@pytest.mark.asyncio
async def test_login_fails_when_connect_task_fails(monkeypatch) -> None:
_patch_neonize_api(monkeypatch)
client = _FailingConnectLoginClient()
ch = _make_channel()
ch._new_client = MagicMock(return_value=client)
assert await ch.login() is False
client.stop.assert_awaited_once()
@pytest.mark.asyncio
async def test_send_text_uses_neonize_send_message(monkeypatch) -> None:
_patch_neonize_api(monkeypatch)
client = SimpleNamespace(
send_message=AsyncMock(),
send_image=AsyncMock(),
send_video=AsyncMock(),
send_audio=AsyncMock(),
send_document=AsyncMock(),
)
ch = _make_channel()
ch._client = client
ch._connected = True
await ch.send(OutboundMessage(channel="whatsapp", chat_id="12345@s.whatsapp.net", content="hi"))
client.send_message.assert_awaited_once_with(("12345", "s.whatsapp.net"), "hi")
@pytest.mark.asyncio
async def test_send_media_dispatches_by_mimetype(monkeypatch) -> None:
_patch_neonize_api(monkeypatch)
client = SimpleNamespace(
send_message=AsyncMock(),
send_image=AsyncMock(),
send_video=AsyncMock(),
send_audio=AsyncMock(),
send_document=AsyncMock(),
)
ch = _make_channel()
ch._client = client
ch._connected = True
await ch.send(
OutboundMessage(
channel="whatsapp",
chat_id="123@s.whatsapp.net",
chat_id="12345@s.whatsapp.net",
content="",
media=["/tmp/doc.pdf"],
media=["photo.jpg", "clip.mp4", "voice.ogg", "report.pdf"],
)
)
await ch.send(msg)
ch._ws.send.assert_called_once()
payload = json.loads(ch._ws.send.call_args[0][0])
assert payload["type"] == "send_media"
assert payload["mimetype"] == "application/pdf"
jid = ("12345", "s.whatsapp.net")
client.send_image.assert_awaited_once_with(jid, "photo.jpg")
client.send_video.assert_awaited_once_with(jid, "clip.mp4")
client.send_audio.assert_awaited_once_with(jid, "voice.ogg")
client.send_document.assert_awaited_once_with(
jid,
"report.pdf",
filename="report.pdf",
mimetype="application/pdf",
)
@pytest.mark.asyncio
async def test_send_multiple_media():
async def test_send_when_disconnected_raises() -> None:
ch = _make_channel()
msg = OutboundMessage(
channel="whatsapp",
chat_id="123@s.whatsapp.net",
content="",
media=["/tmp/a.png", "/tmp/b.mp4"],
)
await ch.send(msg)
assert ch._ws.send.call_count == 2
p1 = json.loads(ch._ws.send.call_args_list[0][0][0])
p2 = json.loads(ch._ws.send.call_args_list[1][0][0])
assert p1["mimetype"] == "image/png"
assert p2["mimetype"] == "video/mp4"
with pytest.raises(RuntimeError, match="not connected"):
await ch.send(OutboundMessage(channel="whatsapp", chat_id="123", content="hi"))
@pytest.mark.asyncio
async def test_send_when_disconnected_is_noop():
ch = _make_channel()
ch._connected = False
msg = OutboundMessage(
channel="whatsapp",
chat_id="123@s.whatsapp.net",
content="hello",
media=["/tmp/x.jpg"],
)
await ch.send(msg)
ch._ws.send.assert_not_called()
@pytest.mark.asyncio
async def test_group_policy_mention_skips_unmentioned_group_message():
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"], "groupPolicy": "mention"}, MagicMock())
async def test_group_policy_mention_skips_unmentioned_group_message() -> None:
ch = _make_channel({"groupPolicy": "mention"})
ch._self_jids = {"bot@s.whatsapp.net", "bot"}
ch._handle_message = AsyncMock()
await ch._handle_bridge_message(
json.dumps(
{
"type": "message",
"id": "m1",
"sender": "12345@g.us",
"pn": "user@s.whatsapp.net",
"content": "hello group",
"timestamp": 1,
"isGroup": True,
"wasMentioned": False,
}
)
await ch._handle_neonize_message(
SimpleNamespace(download_any=AsyncMock()),
_event(
message=_Proto(conversation="hello group"),
chat=_jid("120363000", "g.us"),
sender=_jid("SENDERLID", "lid"),
is_group=True,
),
)
ch._handle_message.assert_not_called()
@pytest.mark.asyncio
async def test_group_policy_mention_accepts_mentioned_group_message():
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"], "groupPolicy": "mention"}, MagicMock())
async def test_group_policy_mention_accepts_mention_and_prefers_phone_sender() -> None:
ch = _make_channel({"groupPolicy": "mention"})
ch._self_jids = {"bot@s.whatsapp.net", "bot"}
ch._handle_message = AsyncMock()
context = _Proto(mentionedJID=["bot@s.whatsapp.net"])
message = _Proto(extendedTextMessage=_Proto(text="hello @bot", contextInfo=context))
await ch._handle_bridge_message(
json.dumps(
{
"type": "message",
"id": "m1",
"sender": "12345@g.us",
"pn": "user@s.whatsapp.net",
"content": "hello @bot",
"timestamp": 1,
"isGroup": True,
"wasMentioned": True,
}
)
await ch._handle_neonize_message(
SimpleNamespace(download_any=AsyncMock()),
_event(
message=message,
chat=_jid("120363000", "g.us"),
sender=_jid("LID99", "lid"),
sender_alt=_jid("15559998888", "s.whatsapp.net"),
is_group=True,
),
)
ch._handle_message.assert_awaited_once()
kwargs = ch._handle_message.await_args.kwargs
assert kwargs["chat_id"] == "12345@g.us"
assert kwargs["sender_id"] == "user"
assert kwargs["sender_id"] == "15559998888"
assert kwargs["chat_id"] == "120363000@g.us"
assert kwargs["metadata"]["lid"] == "LID99"
assert kwargs["metadata"]["phone"] == "15559998888"
@pytest.mark.asyncio
async def test_group_policy_mention_accepts_reply_to_bot_message():
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"], "groupPolicy": "mention"}, MagicMock())
async def test_group_policy_mention_accepts_reply_to_bot() -> None:
ch = _make_channel({"groupPolicy": "mention"})
ch._self_jids = {"bot@s.whatsapp.net", "bot"}
ch._handle_message = AsyncMock()
context = _Proto(participant="bot@s.whatsapp.net")
message = _Proto(extendedTextMessage=_Proto(text="reply", contextInfo=context))
await ch._handle_bridge_message(
json.dumps(
{
"type": "message",
"id": "m-reply",
"sender": "12345@g.us",
"pn": "user@s.whatsapp.net",
"content": "replying to bot",
"timestamp": 1,
"isGroup": True,
"wasMentioned": False,
"isReplyToBot": True,
}
)
await ch._handle_neonize_message(
SimpleNamespace(download_any=AsyncMock()),
_event(
message=message,
chat=_jid("120363000", "g.us"),
sender=_jid("SENDERLID", "lid"),
is_group=True,
),
)
ch._handle_message.assert_awaited_once()
kwargs = ch._handle_message.await_args.kwargs
assert kwargs["metadata"]["is_reply_to_bot"] is True
@pytest.mark.asyncio
async def test_sender_id_prefers_phone_jid_over_lid():
"""sender_id should resolve to phone number when @s.whatsapp.net JID is present."""
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock())
ch._handle_message = AsyncMock()
await ch._handle_bridge_message(
json.dumps({
"type": "message",
"id": "lid1",
"sender": "ABC123@lid.whatsapp.net",
"pn": "5551234@s.whatsapp.net",
"content": "hi",
"timestamp": 1,
})
)
kwargs = ch._handle_message.await_args.kwargs
assert kwargs["sender_id"] == "5551234"
@pytest.mark.asyncio
async def test_group_sender_id_uses_participant_when_phone_jid_missing():
"""Group messages should identify the participant, not the group chat JID."""
async def test_group_sender_id_uses_participant_not_group_jid() -> None:
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["SENDERLID"]}, MagicMock())
ch._started_at = 0
ch._handle_message = AsyncMock()
await ch._handle_bridge_message(
json.dumps({
"type": "message",
"id": "group-lid",
"sender": "12345@g.us",
"pn": "",
"participant": "SENDERLID@lid.whatsapp.net",
"content": "hi",
"timestamp": 1,
"isGroup": True,
})
await ch._handle_neonize_message(
SimpleNamespace(download_any=AsyncMock()),
_event(
message=_Proto(conversation="hi"),
chat=_jid("120363000", "g.us"),
sender=_jid("SENDERLID", "lid"),
is_group=True,
),
)
kwargs = ch._handle_message.await_args.kwargs
assert kwargs["sender_id"] == "SENDERLID"
assert kwargs["metadata"]["participant"] == "SENDERLID@lid.whatsapp.net"
assert kwargs["metadata"]["participant"] == "SENDERLID@lid"
@pytest.mark.asyncio
async def test_lid_to_phone_cache_resolves_lid_only_messages():
"""When only LID is present, a cached LID→phone mapping should be used."""
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock())
async def test_lid_to_phone_cache_resolves_lid_only_messages() -> None:
ch = _make_channel()
ch._handle_message = AsyncMock()
# First message: both phone and LID → builds cache
await ch._handle_bridge_message(
json.dumps({
"type": "message",
"id": "c1",
"sender": "LID99@lid.whatsapp.net",
"pn": "5559999@s.whatsapp.net",
"content": "first",
"timestamp": 1,
})
await ch._handle_neonize_message(
SimpleNamespace(download_any=AsyncMock()),
_event(
message=_Proto(conversation="first"),
message_id="c1",
chat=_jid("LID99", "lid"),
sender=_jid("LID99", "lid"),
sender_alt=_jid("5559999", "s.whatsapp.net"),
),
)
# Second message: only LID, no phone
await ch._handle_bridge_message(
json.dumps({
"type": "message",
"id": "c2",
"sender": "LID99@lid.whatsapp.net",
"pn": "",
"content": "second",
"timestamp": 2,
})
await ch._handle_neonize_message(
SimpleNamespace(download_any=AsyncMock()),
_event(
message=_Proto(conversation="second"),
message_id="c2",
chat=_jid("LID99", "lid"),
sender=_jid("LID99", "lid"),
),
)
second_kwargs = ch._handle_message.await_args_list[1].kwargs
assert second_kwargs["sender_id"] == "5559999"
assert ch._handle_message.await_args_list[1].kwargs["sender_id"] == "5559999"
@pytest.mark.asyncio
async def test_voice_message_transcription_uses_media_path():
"""Voice messages are transcribed when media path is available."""
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock())
ch._handle_message = AsyncMock()
ch.transcribe_audio = AsyncMock(return_value="Hello world")
await ch._handle_bridge_message(
json.dumps({
"type": "message",
"id": "v1",
"sender": "12345@s.whatsapp.net",
"pn": "",
"content": "[Voice Message]",
"timestamp": 1,
"media": ["/tmp/voice.ogg"],
})
)
ch.transcribe_audio.assert_awaited_once_with("/tmp/voice.ogg")
kwargs = ch._handle_message.await_args.kwargs
assert kwargs["content"].startswith("Hello world")
@pytest.mark.asyncio
async def test_forwarded_voice_message_preserves_metadata_after_transcription():
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock())
ch._handle_message = AsyncMock()
ch.transcribe_audio = AsyncMock(return_value="Forwarded audio text")
await ch._handle_bridge_message(
json.dumps({
"type": "message",
"id": "v-forwarded",
"sender": "12345@s.whatsapp.net",
"pn": "",
"content": "[Voice Message]",
"timestamp": 1,
"media": ["/tmp/voice.ogg"],
"isForwarded": True,
})
)
kwargs = ch._handle_message.await_args.kwargs
assert kwargs["content"] == "Forwarded audio text"
assert kwargs["metadata"]["is_forwarded"] is True
@pytest.mark.asyncio
async def test_unauthorized_voice_message_does_not_transcribe() -> None:
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["allowed"]}, MagicMock())
ch._handle_message = AsyncMock()
ch.transcribe_audio = AsyncMock(return_value="Hello world")
await ch._handle_bridge_message(
json.dumps({
"type": "message",
"id": "v-blocked",
"sender": "blocked@s.whatsapp.net",
"pn": "",
"content": "[Voice Message]",
"timestamp": 1,
"media": ["/tmp/voice.ogg"],
})
)
ch.transcribe_audio.assert_not_awaited()
ch._handle_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_voice_message_no_media_shows_not_available():
"""Voice messages without media produce a fallback placeholder."""
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock())
ch._handle_message = AsyncMock()
await ch._handle_bridge_message(
json.dumps({
"type": "message",
"id": "v2",
"sender": "12345@s.whatsapp.net",
"pn": "",
"content": "[Voice Message]",
"timestamp": 1,
})
)
kwargs = ch._handle_message.await_args.kwargs
assert kwargs["content"] == "[Voice Message: Audio not available]"
def test_load_or_create_bridge_token_persists_generated_secret(tmp_path):
token_path = tmp_path / "whatsapp-auth" / "bridge-token"
first = _load_or_create_bridge_token(token_path)
second = _load_or_create_bridge_token(token_path)
assert first == second
assert token_path.read_text(encoding="utf-8") == first
assert len(first) >= 32
if os.name != "nt":
assert token_path.stat().st_mode & 0o777 == 0o600
def test_configured_bridge_token_skips_local_token_file(monkeypatch, tmp_path):
token_path = tmp_path / "whatsapp-auth" / "bridge-token"
monkeypatch.setattr("nanobot.channels.whatsapp._bridge_token_path", lambda: token_path)
ch = WhatsAppChannel({"enabled": True, "bridgeToken": "manual-secret"}, MagicMock())
assert ch._effective_bridge_token() == "manual-secret"
assert not token_path.exists()
@pytest.mark.asyncio
async def test_login_exports_effective_bridge_token(monkeypatch, tmp_path):
token_path = tmp_path / "whatsapp-auth" / "bridge-token"
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
calls = []
monkeypatch.setattr("nanobot.channels.whatsapp._bridge_token_path", lambda: token_path)
monkeypatch.setattr("nanobot.channels.whatsapp._ensure_bridge_setup", lambda: bridge_dir)
monkeypatch.setattr("nanobot.channels.whatsapp.shutil.which", lambda _: "/usr/bin/npm")
def fake_run(*args, **kwargs):
calls.append((args, kwargs))
return MagicMock()
monkeypatch.setattr("nanobot.channels.whatsapp.subprocess.run", fake_run)
ch = WhatsAppChannel({"enabled": True}, MagicMock())
assert await ch.login() is True
assert len(calls) == 1
_, kwargs = calls[0]
assert kwargs["cwd"] == bridge_dir
assert kwargs["env"]["AUTH_DIR"] == str(token_path.parent)
assert kwargs["env"]["BRIDGE_TOKEN"] == token_path.read_text(encoding="utf-8")
@pytest.mark.asyncio
async def test_start_sends_auth_message_with_generated_token(monkeypatch, tmp_path):
token_path = tmp_path / "whatsapp-auth" / "bridge-token"
sent_messages: list[str] = []
class FakeWS:
def __init__(self) -> None:
self.close = AsyncMock()
async def send(self, message: str) -> None:
sent_messages.append(message)
ch._running = False
def __aiter__(self):
return self
async def __anext__(self):
raise StopAsyncIteration
class FakeConnect:
def __init__(self, ws):
self.ws = ws
async def __aenter__(self):
return self.ws
async def __aexit__(self, exc_type, exc, tb):
return False
monkeypatch.setattr("nanobot.channels.whatsapp._bridge_token_path", lambda: token_path)
monkeypatch.setitem(
sys.modules,
"websockets",
types.SimpleNamespace(connect=lambda url: FakeConnect(FakeWS())),
)
ch = WhatsAppChannel({"enabled": True, "bridgeUrl": "ws://localhost:3001"}, MagicMock())
await ch.start()
assert sent_messages == [
json.dumps({"type": "auth", "token": token_path.read_text(encoding="utf-8")})
]
# ---------------------------------------------------------------------------
# LID -> phone mapping seeding (startup): static config + bridge reverse files.
# ---------------------------------------------------------------------------
def test_lid_mappings_from_config():
def test_lid_mappings_from_config() -> None:
ch = WhatsAppChannel(
{"enabled": True, "lidMappings": {"123456789012345": "15551234567"}},
MagicMock(),
)
assert ch._lid_to_phone["123456789012345"] == "15551234567"
assert ch._lid_to_phone == {"123456789012345": "15551234567"}
def test_lid_mappings_from_bridge_reverse_files(tmp_path, monkeypatch):
auth_dir = tmp_path / "whatsapp-auth"
auth_dir.mkdir()
(auth_dir / "lid-mapping-999888777666555_reverse.json").write_text(
json.dumps("15559998888"), encoding="utf-8"
@pytest.mark.asyncio
async def test_image_media_is_downloaded_and_forwarded(monkeypatch, tmp_path) -> None:
monkeypatch.setattr(whatsapp_module, "get_media_dir", lambda channel: tmp_path / channel)
ch = _make_channel()
ch._handle_message = AsyncMock()
client = SimpleNamespace(download_any=AsyncMock())
message = _Proto(
imageMessage=_Proto(
caption="look",
mimetype="image/jpeg",
)
# malformed / empty files must be ignored, not crash startup
(auth_dir / "lid-mapping-broken_reverse.json").write_text("{not json", encoding="utf-8")
(auth_dir / "lid-mapping-empty_reverse.json").write_text(json.dumps(""), encoding="utf-8")
monkeypatch.setattr(
"nanobot.config.paths.get_runtime_subdir", lambda name: auth_dir
)
ch = WhatsAppChannel({"enabled": True}, MagicMock())
assert ch._lid_to_phone == {"999888777666555": "15559998888"}
def test_lid_mappings_config_takes_precedence_over_files(tmp_path, monkeypatch):
auth_dir = tmp_path / "whatsapp-auth"
auth_dir.mkdir()
(auth_dir / "lid-mapping-555_reverse.json").write_text(
json.dumps("from-file"), encoding="utf-8"
)
monkeypatch.setattr(
"nanobot.config.paths.get_runtime_subdir", lambda name: auth_dir
await ch._handle_neonize_message(
client,
_event(message=message, sender_alt=_jid("15551234567", "s.whatsapp.net")),
)
ch = WhatsAppChannel(
{"enabled": True, "lidMappings": {"555": "from-config"}}, MagicMock()
)
assert ch._lid_to_phone["555"] == "from-config"
client.download_any.assert_awaited_once()
kwargs = ch._handle_message.await_args.kwargs
assert kwargs["content"].startswith("look\n[image: ")
assert len(kwargs["media"]) == 1
assert kwargs["media"][0].endswith(".jpg")
def test_lid_mappings_empty_when_no_auth_dir(tmp_path, monkeypatch):
missing = tmp_path / "does-not-exist"
monkeypatch.setattr(
"nanobot.config.paths.get_runtime_subdir", lambda name: missing
@pytest.mark.asyncio
async def test_voice_message_transcribes_and_drops_media_when_successful(
monkeypatch, tmp_path
) -> None:
monkeypatch.setattr(whatsapp_module, "get_media_dir", lambda channel: tmp_path / channel)
ch = _make_channel()
ch._handle_message = AsyncMock()
ch.transcribe_audio = AsyncMock(return_value="Hello from audio")
client = SimpleNamespace(download_any=AsyncMock())
message = _Proto(audioMessage=_Proto(mimetype="audio/ogg", PTT=True))
await ch._handle_neonize_message(
client,
_event(message=message, sender_alt=_jid("15551234567", "s.whatsapp.net")),
)
ch = WhatsAppChannel({"enabled": True}, MagicMock())
assert ch._lid_to_phone == {}
ch.transcribe_audio.assert_awaited_once()
kwargs = ch._handle_message.await_args.kwargs
assert kwargs["content"] == "Hello from audio"
assert kwargs["media"] == []
@pytest.mark.asyncio
async def test_unauthorized_voice_message_does_not_download_or_transcribe(
monkeypatch, tmp_path
) -> None:
monkeypatch.setattr(whatsapp_module, "get_media_dir", lambda channel: tmp_path / channel)
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["allowed"]}, MagicMock())
ch._started_at = 0
ch._handle_message = AsyncMock()
ch.transcribe_audio = AsyncMock(return_value="blocked audio")
client = SimpleNamespace(download_any=AsyncMock())
await ch._handle_neonize_message(
client,
_event(
message=_Proto(audioMessage=_Proto(mimetype="audio/ogg", PTT=True)),
chat=_jid("blocked", "s.whatsapp.net"),
sender=_jid("blocked", "s.whatsapp.net"),
),
)
client.download_any.assert_not_awaited()
ch.transcribe_audio.assert_not_awaited()
ch._handle_message.assert_awaited_once()
kwargs = ch._handle_message.await_args.kwargs
assert kwargs["sender_id"] == "blocked"
assert kwargs["content"] == ""
assert kwargs["media"] == []
assert kwargs["is_dm"] is True
@pytest.mark.asyncio
async def test_unauthorized_dm_uses_base_pairing_flow(monkeypatch) -> None:
_patch_neonize_api(monkeypatch)
monkeypatch.setattr("nanobot.channels.base.generate_code", lambda _ch, _sid: "ABCD-EFGH")
monkeypatch.setattr("nanobot.channels.base.is_approved", lambda _ch, _sid: False)
client = SimpleNamespace(send_message=AsyncMock(), download_any=AsyncMock())
ch = WhatsAppChannel({"enabled": True, "allowFrom": []}, MagicMock())
ch._client = client
ch._connected = True
ch._started_at = 0
await ch._handle_neonize_message(
client,
_event(
message=_Proto(conversation="hello"),
chat=_jid("blocked", "s.whatsapp.net"),
sender=_jid("blocked", "s.whatsapp.net"),
),
)
client.download_any.assert_not_awaited()
client.send_message.assert_awaited_once()
assert client.send_message.await_args.args[0] == ("blocked", "s.whatsapp.net")
assert "ABCD-EFGH" in client.send_message.await_args.args[1]
def test_reset_database_removes_sqlite_sidecars(tmp_path) -> None:
db = tmp_path / "neonize.db"
wal = tmp_path / "neonize.db-wal"
shm = tmp_path / "neonize.db-shm"
for path in (db, wal, shm):
path.write_text("x", encoding="utf-8")
WhatsAppChannel._reset_database(db)
assert not db.exists()
assert not wal.exists()
assert not shm.exists()
-2
View File
@@ -1,7 +1,6 @@
from pathlib import Path
from nanobot.config.paths import (
get_bridge_install_dir,
get_cli_history_path,
get_cron_dir,
get_data_dir,
@@ -34,7 +33,6 @@ def test_media_dir_supports_channel_namespace(monkeypatch, tmp_path: Path) -> No
def test_shared_and_legacy_paths_remain_global() -> None:
assert get_cli_history_path() == Path.home() / ".nanobot" / "history" / "cli_history"
assert get_bridge_install_dir() == Path.home() / ".nanobot" / "bridge"
assert get_legacy_sessions_dir() == Path.home() / ".nanobot" / "sessions"