From d725ed2e0cc51dbe899dd5eb63406936beba2bc0 Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Mon, 18 May 2026 04:52:56 +0000 Subject: [PATCH] feat(whatsapp): auto-provision Docker bridge per tenant - Add Dockerfile.whatsapp-bridge with Baileys + env var support - Modify whatsapp-bridge-server.js to accept PORT, TENANT_ID, WEBHOOK_BASE - Add internal_bp.py with endpoints to provision/destroy bridges via Docker - Register internal_bp in app.py - Each tenant gets isolated container, port, and volume --- pos/Dockerfile.whatsapp-bridge | 20 +++++ pos/app.py | 3 + pos/blueprints/internal_bp.py | 152 +++++++++++++++++++++++++++++++++ pos/whatsapp-bridge-server.js | 28 +++--- 4 files changed, 191 insertions(+), 12 deletions(-) create mode 100644 pos/Dockerfile.whatsapp-bridge create mode 100644 pos/blueprints/internal_bp.py diff --git a/pos/Dockerfile.whatsapp-bridge b/pos/Dockerfile.whatsapp-bridge new file mode 100644 index 0000000..82c2912 --- /dev/null +++ b/pos/Dockerfile.whatsapp-bridge @@ -0,0 +1,20 @@ +FROM node:20-alpine + +WORKDIR /app + +# Install git and build tools (needed for some npm deps) +RUN apk add --no-cache git python3 make g++ + +# Install dependencies +COPY whatsapp-bridge-package.json package.json +RUN npm install + +# Copy bridge server +COPY whatsapp-bridge-server.js . + +# Create auth directory +RUN mkdir -p /app/auth + +EXPOSE 21465 + +CMD ["node", "whatsapp-bridge-server.js"] diff --git a/pos/app.py b/pos/app.py index 9341fb9..dcfa09b 100644 --- a/pos/app.py +++ b/pos/app.py @@ -107,6 +107,9 @@ def create_app(): from blueprints.supplier_portal_bp import supplier_portal_bp app.register_blueprint(supplier_portal_bp) + from blueprints.internal_bp import internal_bp + app.register_blueprint(internal_bp) + # Health check @app.route('/pos/health') def health(): diff --git a/pos/blueprints/internal_bp.py b/pos/blueprints/internal_bp.py new file mode 100644 index 0000000..c4b9a84 --- /dev/null +++ b/pos/blueprints/internal_bp.py @@ -0,0 +1,152 @@ +"""Internal API endpoints for infrastructure orchestration. + +These endpoints are meant to be called by the Nexus Manager or other +internal services. They require INTERNAL_API_KEY. +""" +import subprocess +import socket +from flask import Blueprint, request, jsonify +from config import INTERNAL_API_KEY +from tenant_db import get_master_conn, get_tenant_conn + +internal_bp = Blueprint('internal', __name__, url_prefix='/pos/api/internal') + + +def _check_internal_key(): + key = request.headers.get('X-Internal-Key', '') + if not INTERNAL_API_KEY: + return jsonify({'error': 'INTERNAL_API_KEY not configured on server'}), 500 + if key != INTERNAL_API_KEY: + return jsonify({'error': 'Unauthorized'}), 401 + return None + + +def _find_free_port(start=21465, end=21565): + """Find first free TCP port in range.""" + for port in range(start, end + 1): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + if s.connect_ex(('127.0.0.1', port)) != 0: + return port + return None + + +@internal_bp.route('/whatsapp-bridge', methods=['POST']) +def provision_whatsapp_bridge(): + """Provision a new WhatsApp Bridge Docker container for a tenant.""" + auth_error = _check_internal_key() + if auth_error: + return auth_error + + data = request.get_json() or {} + tenant_id = data.get('tenant_id') + subdomain = data.get('subdomain', f'tenant-{tenant_id}') + + if not tenant_id: + return jsonify({'error': 'tenant_id required'}), 400 + + # Check if container already exists + container_name = f"wpp-{subdomain}" + check = subprocess.run( + ['docker', 'ps', '-a', '-q', '-f', f'name={container_name}'], + capture_output=True, text=True + ) + if check.stdout.strip(): + return jsonify({'error': f'Container {container_name} already exists'}), 409 + + # Find free port + port = _find_free_port() + if not port: + return jsonify({'error': 'No free ports available in range 21465-21565'}), 503 + + # Build image if not exists + image_check = subprocess.run( + ['docker', 'images', '-q', 'nexus-whatsapp-bridge'], + capture_output=True, text=True + ) + if not image_check.stdout.strip(): + build = subprocess.run( + ['docker', 'build', '-f', '/home/Autopartes/pos/Dockerfile.whatsapp-bridge', + '-t', 'nexus-whatsapp-bridge', '/home/Autopartes/pos'], + capture_output=True, text=True + ) + if build.returncode != 0: + return jsonify({'error': 'Failed to build bridge image', 'details': build.stderr}), 500 + + # Run container + bridge_url = f"http://127.0.0.1:{port}" + run = subprocess.run([ + 'docker', 'run', '-d', + '--name', container_name, + '--restart', 'unless-stopped', + '-p', f'{port}:21465', + '-e', f'PORT=21465', + '-e', f'TENANT_ID={tenant_id}', + '-e', f'WEBHOOK_BASE=http://127.0.0.1:5001/pos/api/whatsapp/webhook', + '-e', f'API_KEY=nexus-wpp-secret-2026', + '-e', f'LOG_LEVEL=info', + '-v', f'wpp-{subdomain}:/app/auth', + 'nexus-whatsapp-bridge' + ], capture_output=True, text=True) + + if run.returncode != 0: + return jsonify({'error': 'Failed to start container', 'details': run.stderr}), 500 + + container_id = run.stdout.strip() + + # Save config to tenant_config + conn = get_tenant_conn_by_dbname(data.get('db_name')) + if not conn: + # Fallback: get db_name from master + mconn = get_master_conn() + mcur = mconn.cursor() + mcur.execute("SELECT db_name FROM tenants WHERE id = %s", (tenant_id,)) + row = mcur.fetchone() + mcur.close() + mconn.close() + if row: + conn = get_tenant_conn_by_dbname(row[0]) + + if conn: + cur = conn.cursor() + cur.execute(""" + INSERT INTO tenant_config (key, value) VALUES + ('whatsapp_bridge_url', %s), + ('whatsapp_enabled', 'true') + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value + """, (bridge_url,)) + conn.commit() + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'tenant_id': tenant_id, + 'container_id': container_id, + 'container_name': container_name, + 'port': port, + 'bridge_url': bridge_url + }), 201 + + +@internal_bp.route('/whatsapp-bridge', methods=['DELETE']) +def destroy_whatsapp_bridge(): + """Destroy a tenant's WhatsApp Bridge container.""" + auth_error = _check_internal_key() + if auth_error: + return auth_error + + data = request.get_json() or {} + subdomain = data.get('subdomain') + if not subdomain: + return jsonify({'error': 'subdomain required'}), 400 + + container_name = f"wpp-{subdomain}" + + # Stop and remove container + subprocess.run(['docker', 'stop', container_name], capture_output=True) + subprocess.run(['docker', 'rm', container_name], capture_output=True) + + # Remove volume + subprocess.run(['docker', 'volume', 'rm', f'wpp-{subdomain}'], capture_output=True) + + return jsonify({'success': True, 'message': f'Bridge {container_name} destroyed'}) diff --git a/pos/whatsapp-bridge-server.js b/pos/whatsapp-bridge-server.js index e79aa58..6fc34d4 100644 --- a/pos/whatsapp-bridge-server.js +++ b/pos/whatsapp-bridge-server.js @@ -6,19 +6,23 @@ const pino = require('pino'); const app = express(); app.use(express.json()); -const PORT = 21465; -const API_KEY = 'nexus-wpp-secret-2026'; -const WEBHOOK_URL = 'http://localhost:5001/pos/api/whatsapp/webhook'; +// Configurable via environment variables +const PORT = process.env.PORT || 21465; +const API_KEY = process.env.API_KEY || 'nexus-wpp-secret-2026'; +const TENANT_ID = process.env.TENANT_ID || ''; +const WEBHOOK_BASE = process.env.WEBHOOK_BASE || 'http://localhost:5001/pos/api/whatsapp/webhook'; +const WEBHOOK_URL = TENANT_ID ? `${WEBHOOK_BASE}?tenant_id=${TENANT_ID}` : WEBHOOK_BASE; +const AUTH_DIR = process.env.AUTH_DIR || '/app/auth'; let sock = null; let qrCode = null; let connectionState = 'disconnected'; -const logger = pino({ level: 'warn' }); +const logger = pino({ level: process.env.LOG_LEVEL || 'warn' }); async function connectWhatsApp() { - const { state, saveCreds } = await useMultiFileAuthState('/opt/whatsapp-bridge/auth'); + const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR); const { version } = await fetchLatestBaileysVersion(); - console.log('Connecting with Baileys v' + version.join('.')); + console.log(`[Tenant ${TENANT_ID}] Connecting with Baileys v` + version.join('.')); connectionState = 'connecting'; sock = makeWASocket({ version, auth: state, logger, printQRInTerminal: true, browser: ['Nexus POS', 'Chrome', '120.0'] }); @@ -29,7 +33,7 @@ async function connectWhatsApp() { if (qr) { qrCode = await QRCode.toDataURL(qr); connectionState = 'qr'; - console.log('QR code generated!'); + console.log(`[Tenant ${TENANT_ID}] QR code generated!`); } if (connection === 'close') { connectionState = 'disconnected'; @@ -37,7 +41,7 @@ async function connectWhatsApp() { const reason = lastDisconnect?.error?.output?.statusCode; if (reason !== DisconnectReason.loggedOut) { setTimeout(connectWhatsApp, 5000); } } - if (connection === 'open') { connectionState = 'open'; qrCode = null; console.log('Connected!'); } + if (connection === 'open') { connectionState = 'open'; qrCode = null; console.log(`[Tenant ${TENANT_ID}] Connected!`); } }); sock.ev.on('messages.upsert', async ({ messages }) => { @@ -45,16 +49,16 @@ async function connectWhatsApp() { if (msg.key.fromMe) continue; const phone = msg.key.remoteJid.replace('@s.whatsapp.net', ''); const text = msg.message?.conversation || msg.message?.extendedTextMessage?.text || ''; - console.log('From ' + phone + ': ' + text); + console.log(`[Tenant ${TENANT_ID}] From ${phone}: ${text}`); try { await fetch(WEBHOOK_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event: 'messages.upsert', data: { key: msg.key, message: msg.message, messageTimestamp: msg.messageTimestamp } }) }); - } catch (e) { console.log('Webhook failed:', e.message); } + } catch (e) { console.log(`[Tenant ${TENANT_ID}] Webhook failed:`, e.message); } } }); } -app.get('/', (req, res) => res.json({ status: 'ok', service: 'Nexus WhatsApp Bridge', state: connectionState })); +app.get('/', (req, res) => res.json({ status: 'ok', service: 'Nexus WhatsApp Bridge', tenant: TENANT_ID, state: connectionState })); app.get('/status', (req, res) => res.json({ state: connectionState, hasQr: !!qrCode })); app.get('/qr', (req, res) => { if (connectionState === 'open') return res.json({ state: 'open', message: 'Already connected' }); @@ -71,4 +75,4 @@ app.post('/send', async (req, res) => { }); app.post('/logout', async (req, res) => { if (sock) { await sock.logout(); sock = null; } qrCode = null; connectionState = 'disconnected'; res.json({ state: 'disconnected' }); }); -app.listen(PORT, () => { console.log('WhatsApp Bridge on port ' + PORT); connectWhatsApp(); }); +app.listen(PORT, () => { console.log(`[Tenant ${TENANT_ID}] WhatsApp Bridge on port ${PORT}`); connectWhatsApp(); });