"""WhatsApp service via Baileys Bridge (self-hosted, free). Simple REST bridge that wraps WhatsApp Web via Baileys. Supports per-tenant configuration via bridge_url parameter. """ import requests from config import WHATSAPP_BRIDGE_URL HEADERS = {'Content-Type': 'application/json'} def _get_url(bridge_url=None): return bridge_url or WHATSAPP_BRIDGE_URL def get_status(bridge_url=None): url = _get_url(bridge_url) try: return requests.get(f'{url}/status', timeout=5).json() except Exception as e: return {'state': 'error', 'error': str(e)} def get_qr(bridge_url=None): url = _get_url(bridge_url) try: return requests.get(f'{url}/qr', timeout=5).json() except Exception as e: return {'state': 'error', 'error': str(e)} def connect(bridge_url=None): url = _get_url(bridge_url) try: return requests.post(f'{url}/connect', headers=HEADERS, timeout=5).json() except Exception as e: return {'state': 'error', 'error': str(e)} def send_message(phone, text, bridge_url=None): url = _get_url(bridge_url) try: return requests.post(f'{url}/send', headers=HEADERS, json={'phone': phone, 'message': text}, timeout=15).json() except Exception as e: return {'error': str(e)} def send_quote(phone, quote_data, bridge_url=None): lines = [f"*Cotizacion #{quote_data.get('id', '')}*", ""] for item in quote_data.get('items', []): lines.append(f"- {item.get('quantity', 1)}x {item.get('name', '')} ${item.get('subtotal', 0):,.2f}") lines.append(f"\nSubtotal: ${quote_data.get('subtotal', 0):,.2f}") lines.append(f"IVA: ${quote_data.get('tax_total', 0):,.2f}") lines.append(f"*Total: ${quote_data.get('total', 0):,.2f}*") return send_message(phone, "\n".join(lines), bridge_url=bridge_url) def logout(bridge_url=None): url = _get_url(bridge_url) try: return requests.post(f'{url}/logout', headers=HEADERS, timeout=5).json() except Exception as e: return {'error': str(e)} def process_incoming(webhook_data): """Extract a normalized dict from a Baileys webhook payload. Supports text messages, image messages, audio (voice notes), and video. Media content comes pre-downloaded as base64 from the bridge so Python doesn't have to re-authenticate with WhatsApp servers. Returns: dict with keys: phone — numeric phone (no JID suffix) jid — full remote JID (may be @s.whatsapp.net or @lid) text — text content (plain text or media caption) from_me — bool, True if we sent the message message_id — WhatsApp message ID media_kind — 'text' | 'image' | 'audio' | 'video' media_base64 — base64 string if media, else None media_mimetype — e.g. 'image/jpeg', 'audio/ogg' is_voice_note — True for WhatsApp voice notes (audioMessage ptt) push_name — display name from WhatsApp """ data = webhook_data.get('data', {}) key = data.get('key', {}) message = data.get('message', {}) # remoteJid can be phone@s.whatsapp.net or LID:instance@lid remote_jid = key.get('remoteJid', '') # Strip JID suffixes and LID instance suffix (:12) phone = remote_jid.split('@')[0].split(':')[0] if remote_jid else '' # DEBUG import json print(f"[WA-DEBUG] key fields: {json.dumps({k: v for k, v in key.items() if k in ('remoteJid', 'senderPn', 'fromMe', 'id')})}") # senderPn contains the real phone number when remoteJid is a privacy LID sender_pn = key.get('senderPn', '') if sender_pn: sender_pn = sender_pn.replace('@s.whatsapp.net', '') # The bridge now classifies and passes these extra fields. Fall back to # the old parsing if they're missing (older bridge version). media_kind = data.get('media_kind', 'text') media_base64 = data.get('media_base64') media_mimetype = data.get('media_mimetype') media_caption = data.get('media_caption') or '' is_voice_note = bool(data.get('media_ptt')) push_name = data.get('push_name') or '' # Text content: # - For 'text' messages → conversation or extendedTextMessage # - For 'image'/'video' → the caption (may be empty) # - For 'audio' → empty (filled in later by Whisper transcription) # - For 'location' → synthetic text with coordinates if media_kind == 'text': text = ( message.get('conversation', '') or message.get('extendedTextMessage', {}).get('text', '') or '' ) else: text = media_caption # Location fields (from bridge classification) latitude = data.get('latitude') longitude = data.get('longitude') return { 'phone': phone, 'jid': remote_jid, 'sender_pn': sender_pn, 'text': text, 'from_me': key.get('fromMe', False), 'message_id': key.get('id', ''), 'media_kind': media_kind, 'media_base64': media_base64, 'media_mimetype': media_mimetype, 'is_voice_note': is_voice_note, 'push_name': push_name, 'latitude': latitude, 'longitude': longitude, } def send_image(phone, caption, base64_image, bridge_url=None): """Send an image message via the Baileys bridge.""" url = _get_url(bridge_url) try: return requests.post( f'{url}/send-image', headers=HEADERS, json={'phone': phone, 'caption': caption, 'base64': base64_image}, timeout=15 ).json() except Exception as e: return {'error': str(e)} # ═══════════════════════════════════════════════════════════════════════════ # Meta Cloud API provider (WA CRM / self-hosted, 1 número por tenant) # # cfg keys (tenant_config): # whatsapp_provider = 'meta' | 'baileys' (default: baileys) # wa_phone_number_id — Meta phone number ID # wa_access_token — permanent/long-lived access token # wa_app_secret — App secret (valida X-Hub-Signature-256 en webhooks) # wa_verify_token — token del GET verify del webhook # wa_graph_url — (opcional) override de graph.facebook.com/v21.0 # ═══════════════════════════════════════════════════════════════════════════ import base64 as _b64 import secrets as _secrets def _meta_is_meta(cfg): return str(cfg.get('provider') or '').lower() == 'meta' and cfg.get('phone_number_id') def _meta_digits(phone): return ''.join(ch for ch in str(phone or '') if ch.isdigit()) def _meta_graph(cfg, method, path, json_payload=None, files=None, data=None, params=None): base = (cfg.get('graph_url') or 'https://graph.facebook.com/v21.0').rstrip('/') url = f"{base}/{path}" headers = {'Authorization': f"Bearer {cfg.get('access_token')}"} if method == 'GET': return requests.get(url, headers=headers, params=params or {}, timeout=20).json() if files: return requests.post(url, headers={'Authorization': headers['Authorization']}, files=files, data=data or {}, timeout=60).json() return requests.post(url, headers=headers, json=json_payload, timeout=30).json() def meta_send_text(cfg, phone, text): try: r = _meta_graph(cfg, 'POST', f"{cfg['phone_number_id']}/messages", json_payload={ 'messaging_product': 'whatsapp', 'recipient_type': 'individual', 'to': _meta_digits(phone), 'type': 'text', 'text': {'preview_url': True, 'body': text}, }) if r.get('error'): return {'error': r['error'].get('message', 'Meta error')} return {'ok': True, 'id': (r.get('messages') or [{}])[0].get('id')} except Exception as e: return {'error': str(e)} def meta_send_image(cfg, phone, caption, base64_image): """Upload media then send image message.""" try: raw = base64_image if ',' in raw and raw.strip().startswith('data:'): raw = raw.split(',', 1)[1] img_bytes = _b64.b64decode(raw) up = _meta_graph(cfg, 'POST', f"{cfg['phone_number_id']}/media", files={'file': ('image.jpg', img_bytes, 'image/jpeg')}, data={'messaging_product': 'whatsapp'}) media_id = up.get('id') if not media_id: return {'error': up.get('error', {}).get('message', 'media upload failed')} r = _meta_graph(cfg, 'POST', f"{cfg['phone_number_id']}/messages", json_payload={ 'messaging_product': 'whatsapp', 'recipient_type': 'individual', 'to': _meta_digits(phone), 'type': 'image', 'image': {'id': media_id, 'caption': caption or ''}, }) if r.get('error'): return {'error': r['error'].get('message', 'Meta error')} return {'ok': True, 'id': (r.get('messages') or [{}])[0].get('id')} except Exception as e: return {'error': str(e)} def meta_get_status(cfg): try: r = _meta_graph(cfg, 'GET', f"{cfg['phone_number_id']}", params={'fields': 'display_phone_number,verified_name,quality_rating'}) if r.get('error'): return {'state': 'error', 'error': r['error'].get('message')} return {'state': 'connected', 'phone': r.get('display_phone_number'), 'name': r.get('verified_name'), 'quality': r.get('quality_rating')} except Exception as e: return {'state': 'error', 'error': str(e)} def meta_verify_signature(app_secret, payload_bytes, signature_header): """Validate X-Hub-Signature-256: 'sha256='.""" if not app_secret or not signature_header: return True # not configured -> do not enforce import hmac as _hmac import hashlib as _hashlib expected = 'sha256=' + _hmac.new( app_secret.encode(), payload_bytes, _hashlib.sha256).hexdigest() try: return _hmac.compare_digest(expected, signature_header) except Exception: return False def _provider_cfg_error(cfg, provider): """Error claro cuando el proveedor elegido está incompleto (sin fallback silencioso).""" if provider == 'wacrm': return {'error': 'WA CRM no configurado: faltan api_base y/o api_key (Configuración → WhatsApp)'} if provider == 'meta': return {'error': 'Meta Cloud API no configurada: faltan phone_number_id y/o access_token'} if provider == 'baileys': return {'error': 'Bridge de WhatsApp no configurado o retirado (WhatsApp pasa por WA CRM)'} return {'error': f'Proveedor de WhatsApp desconocido: {provider}'} def wa_send_text(cfg, phone, text): """Router: WA CRM, Meta directo o Baileys según config del tenant. Si el proveedor elegido está incompleto, devuelve un error explícito en lugar de degradar silenciosamente a otro proveedor. """ cfg = cfg or {} provider = str(cfg.get('provider') or 'baileys').lower() if provider == 'wacrm': if not _wacrm_is_wacrm(cfg): return _provider_cfg_error(cfg, 'wacrm') return wacrm_send_text(cfg, phone, text) if provider == 'meta': if not _meta_is_meta(cfg): return _provider_cfg_error(cfg, 'meta') return meta_send_text(cfg, phone, text) if not cfg.get('bridge_url'): return _provider_cfg_error(cfg, 'baileys') return send_message(phone, text, bridge_url=cfg.get('bridge_url')) def wa_send_image(cfg, phone, caption, base64_image): cfg = cfg or {} provider = str(cfg.get('provider') or 'baileys').lower() if provider == 'wacrm' and not _wacrm_is_wacrm(cfg): return _provider_cfg_error(cfg, 'wacrm') if provider == 'meta' and not _meta_is_meta(cfg): return _provider_cfg_error(cfg, 'meta') if provider == 'baileys' and not cfg.get('bridge_url'): return _provider_cfg_error(cfg, 'baileys') if _wacrm_is_wacrm(cfg): # WA CRM exige URL pública para media; sin ella degradamos a texto. public_base = (cfg.get('public_base') or '').rstrip('/') if public_base: import uuid as _uuid import os as _os token = _uuid.uuid4().hex media_dir = '/tmp/wa_media' _os.makedirs(media_dir, exist_ok=True) raw = base64_image.split(',', 1)[1] if base64_image.strip().startswith('data:') else base64_image path = _os.path.join(media_dir, token + '.png') try: with open(path, 'wb') as fh: fh.write(_b64.b64decode(raw)) except Exception: return {'error': 'no se pudo preparar la imagen'} return wacrm_send_media(cfg, phone, 'image', f'{public_base}/pos/api/public/media/{token}.png', caption) return wa_send_text(cfg, phone, (caption or '') + '\n(imagen no disponible: configura wa_public_base)') if _meta_is_meta(cfg or {}): return meta_send_image(cfg, phone, caption, base64_image) return send_image(phone, caption, base64_image, bridge_url=(cfg or {}).get('bridge_url')) def wa_send_document(cfg, phone, base64_data, filename, caption=''): """Envía PDF/documento por el proveedor configurado (WA CRM, Meta o Baileys).""" cfg = cfg or {} provider = str(cfg.get('provider') or 'baileys').lower() if provider == 'wacrm': if not _wacrm_is_wacrm(cfg): return _provider_cfg_error(cfg, 'wacrm') if not (cfg.get('public_base') or '').strip(): return {'error': 'wa_public_base requerido para enviar documentos vía WA CRM'} if _wacrm_is_wacrm(cfg): public_base = (cfg.get('public_base') or '').rstrip('/') if not public_base: return {'error': 'wa_public_base requerido para enviar documentos vía WA CRM'} import uuid as _uuid import os as _os token = _uuid.uuid4().hex media_dir = '/tmp/wa_media' _os.makedirs(media_dir, exist_ok=True) raw = base64_data.split(',', 1)[1] if base64_data.strip().startswith('data:') else base64_data path = _os.path.join(media_dir, token + '_' + filename) with open(path, 'wb') as fh: fh.write(_b64.b64decode(raw)) return wacrm_send_media(cfg, phone, 'document', f'{public_base}/pos/api/public/media/{token}_{filename}', caption, filename) try: raw = base64_data if ',' in raw and raw.strip().startswith('data:'): raw = raw.split(',', 1)[1] if _meta_is_meta(cfg): img_bytes = _b64.b64decode(raw) up = _meta_graph(cfg, 'POST', f"{cfg['phone_number_id']}/media", files={'file': (filename, img_bytes, 'application/pdf')}, data={'messaging_product': 'whatsapp'}) media_id = up.get('id') if not media_id: return {'error': up.get('error', {}).get('message', 'media upload failed')} r = _meta_graph(cfg, 'POST', f"{cfg['phone_number_id']}/messages", json_payload={ 'messaging_product': 'whatsapp', 'recipient_type': 'individual', 'to': _meta_digits(phone), 'type': 'document', 'document': {'id': media_id, 'filename': filename, 'caption': caption or ''}, }) if r.get('error'): return {'error': r['error'].get('message', 'Meta error')} return {'ok': True} # Baileys bridge: endpoint genérico send-file (best effort) url = _get_url(cfg.get('bridge_url')) r = requests.post( f'{url}/send-file', headers=HEADERS, json={'phone': phone, 'base64': raw, 'filename': filename, 'caption': caption or ''}, timeout=30, ).json() return r if isinstance(r, dict) else {'ok': True} except Exception as e: return {'error': str(e)} # ═══════════════════════════════════════════════════════════════════════════ # Proveedor WA CRM (self-hosted) — API pública /api/v1 # cfg: provider='wacrm', api_base, api_key (+ public_base opcional) # ═══════════════════════════════════════════════════════════════════════════ def _wacrm_is_wacrm(cfg): return str((cfg or {}).get('provider') or '').lower() == 'wacrm' and (cfg or {}).get('api_base') and (cfg or {}).get('api_key') def _wacrm_e164(phone): digits = ''.join(ch for ch in str(phone or '') if ch.isdigit()) if not digits: return '' if digits.startswith('52'): return '+' + digits if len(digits) == 10: return '+52' + digits return '+' + digits def wacrm_send_text(cfg, phone, text): base = (cfg.get('api_base') or '').rstrip('/') try: r = requests.post( f'{base}/api/v1/messages', headers={'Authorization': f"Bearer {cfg.get('api_key')}", 'Content-Type': 'application/json'}, json={'to': _wacrm_e164(phone), 'type': 'text', 'text': text}, timeout=30, ) data = r.json() if r.content else {} if r.status_code >= 400 or data.get('error'): return {'error': (data.get('error') or {}).get('message') or f'wacrm HTTP {r.status_code}'} return {'ok': True, 'id': (data.get('data') or {}).get('message_id')} except Exception as e: return {'error': str(e)} def wacrm_send_media(cfg, phone, kind, media_url, caption='', filename=None): base = (cfg.get('api_base') or '').rstrip('/') payload = {'to': _wacrm_e164(phone), 'type': kind, 'media_url': media_url, 'text': caption or ''} if filename: payload['filename'] = filename try: r = requests.post( f'{base}/api/v1/messages', headers={'Authorization': f"Bearer {cfg.get('api_key')}", 'Content-Type': 'application/json'}, json=payload, timeout=45, ) data = r.json() if r.content else {} if r.status_code >= 400 or data.get('error'): return {'error': (data.get('error') or {}).get('message') or f'wacrm HTTP {r.status_code}'} return {'ok': True, 'id': (data.get('data') or {}).get('message_id')} except Exception as e: return {'error': str(e)} def wacrm_get_status(cfg): base = (cfg.get('api_base') or '').rstrip('/') try: r = requests.get(f'{base}/api/v1/me', headers={'Authorization': f"Bearer {cfg.get('api_key')}"}, timeout=15) if r.status_code != 200: return {'state': 'error', 'error': f'HTTP {r.status_code}'} return {'state': 'connected', 'data': r.json().get('data', {})} except Exception as e: return {'state': 'error', 'error': str(e)}