diff --git a/AGENTS.md b/AGENTS.md index 814661b3..bba7f4fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/Dockerfile b/Dockerfile index 564ff4bc..1bf995ea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 && \ diff --git a/README.md b/README.md index 08f60d7b..948558b3 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/SECURITY.md b/SECURITY.md index 8e65d404..c7b77877 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 diff --git a/bridge/package.json b/bridge/package.json deleted file mode 100644 index 83047375..00000000 --- a/bridge/package.json +++ /dev/null @@ -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" - } -} diff --git a/bridge/src/index.ts b/bridge/src/index.ts deleted file mode 100644 index b821a4b3..00000000 --- a/bridge/src/index.ts +++ /dev/null @@ -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); -}); diff --git a/bridge/src/server.ts b/bridge/src/server.ts deleted file mode 100644 index a2860ec1..00000000 --- a/bridge/src/server.ts +++ /dev/null @@ -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 = new Set(); - - constructor(private port: number, private authDir: string, private token: string) {} - - async start(): Promise { - 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 { - 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 { - // 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; - } - } -} diff --git a/bridge/src/types.d.ts b/bridge/src/types.d.ts deleted file mode 100644 index 3aeb18be..00000000 --- a/bridge/src/types.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -declare module 'qrcode-terminal' { - export function generate(text: string, options?: { small?: boolean }): void; -} diff --git a/bridge/src/whatsapp.ts b/bridge/src/whatsapp.ts deleted file mode 100644 index eddfd912..00000000 --- a/bridge/src/whatsapp.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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 { - if (this.sock) { - this.sock.end(undefined); - this.sock = null; - } - } -} diff --git a/bridge/tsconfig.json b/bridge/tsconfig.json deleted file mode 100644 index 7f472b2c..00000000 --- a/bridge/tsconfig.json +++ /dev/null @@ -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"] -} diff --git a/docs/chat-apps.md b/docs/chat-apps.md index 9fb787ce..c67f8adc 100644 --- a/docs/chat-apps.md +++ b/docs/chat-apps.md @@ -303,9 +303,15 @@ nanobot gateway
WhatsApp -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: -```bash -# Terminal 1 -nanobot channels login whatsapp - -# Terminal 2 -nanobot gateway +```json +{ + "channels": { + "whatsapp": { + "databasePath": "~/.nanobot/whatsapp-auth/neonize.db" + } + } +} ``` -> 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` +**3. Run** + +```bash +nanobot gateway +``` **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" } } } diff --git a/docs/quick-start.md b/docs/quick-start.md index 51e2ea20..be5ef72f 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -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 diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index 7a19d000..3a5bec03 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -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[^@]+)@(?P[^@]+)$") + + +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,346 +275,396 @@ 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-_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") + 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 - - 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 - - return True + 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") - - 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) + try: + self.logger.info("Connecting WhatsApp channel with neonize...") + await client.connect() + await client.idle() + except asyncio.CancelledError: + raise + finally: + self._running = False + self._connected = False + if self._client is client: + self._client = None + with suppress(Exception): + await client.stop() async def stop(self) -> None: - """Stop the WhatsApp channel.""" self._running = False self._connected = False + client = self._client + self._client = None + if client is not None: + await client.stop() - if self._ws: - await self._ws.close() - self._ws = None + @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 []: - 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)) - except Exception: - self.logger.exception("Error sending media {}", media_path) - raise + await self._send_media(client, to, media_path) - 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]) - return + 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) - msg_type = data.get("type") + user = match.group("user").split(":", 1)[0] + server = match.group("server") + return api.build_jid(user, server) - if msg_type == "message": - # Incoming message from WhatsApp - # Deprecated by whatsapp: old phone number style typically: @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: - 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): - return - - if message_id: - if message_id in self._processed_message_ids: - return - self._processed_message_ids[message_id] = None - while len(self._processed_message_ids) > 1000: - self._processed_message_ids.popitem(last=False) - - 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) - - # 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]) - if transcription: - content = transcription - media_paths = [] - self.logger.info("Transcribed voice from {}: {}...", sender_id, transcription[:50]) - else: - content = "[Voice Message: Transcription failed]" - else: - content = "[Voice Message: Audio not available]" - - # 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 - - await self._handle_message( - sender_id=sender_id, - chat_id=sender, # Use full LID for replies - content=content, - 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), - }, + 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, ) - elif msg_type == "status": - # Connection status update - status = data.get("status") - self.logger.info("Status: {}", status) + def _register_handlers( + self, + client: Any, + *, + login_result: asyncio.Future[None] | None = None, + handle_messages: bool, + ) -> None: + api = _load_neonize() - if status == "connected": - self._connected = True - elif status == "disconnected": - self._connected = False + @client.qr + async def _on_qr(_: Any, qr_data: bytes) -> None: + import segno - elif msg_type == "qr": - # QR code for authentication - self.logger.info("Scan QR code in the bridge terminal to connect WhatsApp") + self.logger.info("Scan the WhatsApp QR code with Linked Devices") + segno.make_qr(qr_data).terminal(compact=True) - elif msg_type == "error": - self.logger.error("Bridge error: {}", data.get("error")) + @client.event(api.ConnectedEv) + async def _on_connected(current_client: Any, _: Any) -> None: + self._connected = True + try: + 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) -def _ensure_bridge_setup() -> Path: - """ - Ensure the WhatsApp bridge is set up and built. + @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) - 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 + if not handle_messages: + return - user_bridge = get_bridge_install_dir() - stamp_file = user_bridge / ".nanobot-bridge-source-hash" + @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 handling WhatsApp message") + raise - # Find source bridge - current_file = Path(__file__) - pkg_bridge = current_file.parent.parent / "bridge" - src_bridge = current_file.parent.parent.parent / "bridge" + async def _remember_self_jids(self, client: Any) -> None: + device = _safe_attr(client, "me") + if device is None: + device = await client.get_me() - source = None - if (pkg_bridge / "package.json").exists(): - source = pkg_bridge - elif (src_bridge / "package.json").exists(): - source = src_bridge + 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)) - if not source: - raise RuntimeError( - "WhatsApp bridge source not found. " - "Try reinstalling: pip install --force-reinstall nanobot" + 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 + + 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 + + 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 + self._processed_message_ids[message_id] = None + 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 + + 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 + + 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: + text = transcription + else: + media_paths.append(path) + text = self._append_media_tag(text, "audio", path) + else: + media_paths.append(path) + text = self._append_media_tag(text, media.kind, path) + + if not text and not media_paths: + return + + await self._handle_message( + sender_id=sender_id, + chat_id=chat_jid, + content=text, + media=media_paths, + metadata=metadata, + is_dm=not is_group, ) - 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_addressed_to_bot(self, message: Any) -> bool: + return self._was_mentioned(message) or self._is_reply_to_bot(message) - expected_hash = source_hash(source) - current_hash = stamp_file.read_text().strip() if stamp_file.exists() else None + 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 - if (user_bridge / "dist" / "index.js").exists() and current_hash == expected_hash: - return user_bridge + 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 - if (user_bridge / "dist" / "index.js").exists() and current_hash != expected_hash: - logger.info("WhatsApp bridge source changed; rebuilding bridge...") + @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 - npm_path = shutil.which("npm") - if not npm_path: - raise RuntimeError("npm not found. Please install Node.js >= 20.") + 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) - 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")) + 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}" - logger.info(" Installing dependencies...") - subprocess.run([npm_path, "install"], cwd=user_bridge, check=True, capture_output=True) + @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(" 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() diff --git a/nanobot/config/__init__.py b/nanobot/config/__init__.py index 18d5c7fc..341e4964 100644 --- a/nanobot/config/__init__.py +++ b/nanobot/config/__init__.py @@ -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", ] diff --git a/nanobot/config/paths.py b/nanobot/config/paths.py index 5fc35420..82796038 100644 --- a/nanobot/config/paths.py +++ b/nanobot/config/paths.py @@ -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" diff --git a/pyproject.toml b/pyproject.toml index d4510073..285078b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/tests/channels/test_whatsapp_channel.py b/tests/channels/test_whatsapp_channel.py index e6bc237b..6d58f7a9 100644 --- a/tests/channels/test_whatsapp_channel.py +++ b/tests/channels/test_whatsapp_channel.py @@ -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 -def _make_channel() -> WhatsAppChannel: - bus = MagicMock() - ch = WhatsAppChannel({"enabled": True}, bus) - ch._ws = AsyncMock() - ch._connected = True +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(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( - channel="whatsapp", - chat_id="123@s.whatsapp.net", - content="", - media=["/tmp/doc.pdf"], + 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="12345@s.whatsapp.net", + content="", + 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" - - -@pytest.mark.asyncio -async def test_send_multiple_media(): - ch = _make_channel() - msg = OutboundMessage( - channel="whatsapp", - chat_id="123@s.whatsapp.net", - content="", - media=["/tmp/a.png", "/tmp/b.mp4"], + 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", ) - 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" - @pytest.mark.asyncio -async def test_send_when_disconnected_is_noop(): +async def test_send_when_disconnected_raises() -> None: 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() + with pytest.raises(RuntimeError, match="not connected"): + await ch.send(OutboundMessage(channel="whatsapp", chat_id="123", content="hi")) @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" - ) - # 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 +@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", + ) ) - 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() diff --git a/tests/config/test_config_paths.py b/tests/config/test_config_paths.py index 6c560ceb..4d791d37 100644 --- a/tests/config/test_config_paths.py +++ b/tests/config/test_config_paths.py @@ -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"