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
This commit is contained in:
20
pos/Dockerfile.whatsapp-bridge
Normal file
20
pos/Dockerfile.whatsapp-bridge
Normal file
@@ -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"]
|
||||
@@ -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():
|
||||
|
||||
152
pos/blueprints/internal_bp.py
Normal file
152
pos/blueprints/internal_bp.py
Normal file
@@ -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'})
|
||||
@@ -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(); });
|
||||
|
||||
Reference in New Issue
Block a user