refactor(whatsapp): replace bridge with neonize
This commit is contained in:
@@ -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
@@ -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 && \
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
-3
@@ -1,3 +0,0 @@
|
||||
declare module 'qrcode-terminal' {
|
||||
export function generate(text: string, options?: { small?: boolean }): void;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,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",
|
||||
]
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user