- Add MercadoLibre OAuth, listings, orders, webhooks and category search - New marketplace_external_bp.py, meli_service.py, marketplace_external_service.py - New marketplace_external.html/js with ML management UI - Inventory: bulk publish to ML with category autocomplete, listing type and shipping selectors - Inventory: new .btn--meli styles, select/label CSS fixes - WhatsApp bridge: rate limiting, 440/515/408 error handling, stale watchdog - DB migration v3.4_meli_integration.sql for marketplace_listings, orders, sync_queue - Add Celery tasks for ML sync and webhook processing - Sidebar: MercadoLibre navigation link
150 lines
5.2 KiB
Python
150 lines
5.2 KiB
Python
"""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@lid
|
|
remote_jid = key.get('remoteJid', '')
|
|
phone = remote_jid.replace('@s.whatsapp.net', '').replace('@lid', '')
|
|
|
|
# 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,
|
|
'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)}
|