174 lines
8.2 KiB
Python
174 lines
8.2 KiB
Python
# -*- coding: utf-8 -*-
|
|
import logging
|
|
import json
|
|
from odoo import http, fields, _
|
|
from odoo.http import request, Response
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
class WhatsAppWebhookController(http.Controller):
|
|
|
|
@http.route('/social/whatsapp/webhook', type='http', auth='public', methods=['GET', 'POST'], csrf=False)
|
|
def whatsapp_webhook(self, **kwargs):
|
|
"""
|
|
Secure WhatsApp Cloud API Webhook endpoint:
|
|
- GET: Handles Meta verification challenge
|
|
- POST: Handles real-time message events and status updates
|
|
"""
|
|
if request.httprequest.method == 'GET':
|
|
return self._handle_verification(kwargs)
|
|
elif request.httprequest.method == 'POST':
|
|
return self._handle_incoming_event()
|
|
return Response("Method Not Allowed", status=405)
|
|
|
|
def _handle_verification(self, kwargs):
|
|
"""Verifies Meta webhook challenge during setup."""
|
|
mode = kwargs.get('hub.mode') or request.params.get('hub.mode')
|
|
token = kwargs.get('hub.verify_token') or request.params.get('hub.verify_token')
|
|
challenge = kwargs.get('hub.challenge') or request.params.get('hub.challenge')
|
|
|
|
if mode == 'subscribe' and token and challenge:
|
|
# Check against any company's configured verify token or system default
|
|
companies = request.env['res.company'].sudo().search([])
|
|
valid = any(comp.whatsapp_verify_token == token for comp in companies)
|
|
|
|
# Also check system parameter fallback
|
|
if not valid:
|
|
sys_token = request.env['ir.config_parameter'].sudo().get_param('dine360.whatsapp_verify_token')
|
|
valid = (token == sys_token)
|
|
|
|
if valid:
|
|
_logger.info("WhatsApp webhook challenge verified successfully.")
|
|
matching_company = companies.filtered(lambda c: c.whatsapp_verify_token == token)
|
|
if matching_company:
|
|
matching_company[:1].write({
|
|
'whatsapp_webhook_verified': True,
|
|
'whatsapp_last_webhook_ping': fields.Datetime.now(),
|
|
})
|
|
return request.make_response(str(challenge), [('Content-Type', 'text/plain')], status=200)
|
|
else:
|
|
_logger.warning("WhatsApp webhook verification token mismatch. Received: %s", token)
|
|
return Response("Forbidden: Invalid verify token", status=403)
|
|
|
|
_logger.warning("Invalid WhatsApp webhook GET request parameters: %s", kwargs)
|
|
return Response("Bad Request", status=400)
|
|
|
|
def _handle_incoming_event(self):
|
|
"""Processes incoming events from Meta (messages, status updates)."""
|
|
raw_body = request.httprequest.get_data()
|
|
signature_header = request.httprequest.headers.get('X-Hub-Signature-256')
|
|
|
|
if not raw_body:
|
|
return Response("Empty Body", status=400)
|
|
|
|
try:
|
|
payload = json.loads(raw_body.decode('utf-8'))
|
|
except Exception as e:
|
|
_logger.error("Failed to parse JSON in WhatsApp webhook: %s", e)
|
|
return Response("Invalid JSON", status=400)
|
|
|
|
# Optional HMAC-SHA256 signature verification if Meta App Secret is set
|
|
wa_api = request.env['social.whatsapp.api'].sudo()
|
|
companies = request.env['res.company'].sudo().search([('whatsapp_enabled', '=', True)])
|
|
if signature_header:
|
|
app_secret = companies.filtered(lambda c: c.meta_app_secret)[:1].meta_app_secret
|
|
if app_secret and not wa_api.verify_webhook_signature(raw_body, signature_header, app_secret):
|
|
_logger.warning("X-Hub-Signature-256 validation failed for WhatsApp webhook.")
|
|
return Response("Forbidden: Signature mismatch", status=403)
|
|
|
|
# Process entries
|
|
try:
|
|
entries = payload.get('entry', [])
|
|
for entry in entries:
|
|
changes = entry.get('changes', [])
|
|
for change in changes:
|
|
value = change.get('value', {})
|
|
metadata = value.get('metadata', {})
|
|
phone_number_id = metadata.get('phone_number_id')
|
|
|
|
# Identify corresponding company
|
|
company = companies.filtered(lambda c: c.whatsapp_phone_number_id == phone_number_id)[:1]
|
|
if not company:
|
|
company = request.env.company
|
|
|
|
# 1. Process delivery status receipts (sent -> delivered -> read)
|
|
statuses = value.get('statuses', [])
|
|
for status in statuses:
|
|
self._process_message_status(status, company)
|
|
|
|
# 2. Process incoming customer messages
|
|
messages = value.get('messages', [])
|
|
for msg in messages:
|
|
self._process_customer_message(msg, company, raw_body.decode('utf-8'))
|
|
|
|
# Mark webhook ping timestamp
|
|
if companies:
|
|
companies[:1].write({'whatsapp_last_webhook_ping': fields.Datetime.now()})
|
|
|
|
except Exception as e:
|
|
_logger.exception("Unexpected error processing WhatsApp webhook event: %s", e)
|
|
|
|
# Meta requires an immediate 200 OK
|
|
return Response("EVENT_RECEIVED", status=200)
|
|
|
|
def _process_message_status(self, status_data, company):
|
|
"""Updates WhatsApp notification state when Meta delivers status callbacks."""
|
|
wamid = status_data.get('id')
|
|
new_status = status_data.get('status') # delivered, read, failed, sent
|
|
if not wamid or not new_status:
|
|
return
|
|
|
|
notif_model = request.env['social.whatsapp.notification'].sudo()
|
|
notif = notif_model.search([('meta_message_id', '=', wamid)], limit=1)
|
|
if notif:
|
|
state_map = {
|
|
'sent': 'sent',
|
|
'delivered': 'delivered',
|
|
'read': 'read',
|
|
'failed': 'failed',
|
|
}
|
|
if new_status in state_map:
|
|
vals = {'state': state_map[new_status]}
|
|
if new_status == 'failed':
|
|
errors = status_data.get('errors', [])
|
|
if errors:
|
|
vals['error_message'] = errors[0].get('message', 'Delivery failed')
|
|
notif.write(vals)
|
|
_logger.info("Updated WhatsApp notification %s status to '%s'", notif.id, new_status)
|
|
|
|
def _process_customer_message(self, msg_data, company, raw_payload_str):
|
|
"""Logs customer inquiry and triggers automated status response if order is found."""
|
|
msg_model = request.env['social.whatsapp.message'].sudo()
|
|
record = msg_model.process_incoming_message(msg_data, company=company)
|
|
if not record:
|
|
return
|
|
|
|
# Store raw payload for audit
|
|
record.write({'raw_payload': raw_payload_str})
|
|
|
|
# Automated Order Status Response if customer is asking about an identifiable order
|
|
if record.sale_order_id and record.body:
|
|
body_lower = record.body.lower()
|
|
order_keywords = ['where is my order', 'order status', 'track', 'status of my order', 'when will my order arrive']
|
|
if any(kw in body_lower for kw in order_keywords):
|
|
order = record.sale_order_id
|
|
state_labels = {
|
|
'draft': 'Quotation created',
|
|
'sent': 'Quotation sent',
|
|
'sale': 'Confirmed & In Preparation',
|
|
'done': 'Completed',
|
|
'cancel': 'Cancelled',
|
|
}
|
|
order_state = state_labels.get(order.state, order.state)
|
|
reply_text = _("Hello! Your order %(order)s is currently %(state)s. Thank you for your patience!") % {
|
|
'order': order.name,
|
|
'state': order_state
|
|
}
|
|
|
|
# Send reply within 24h window
|
|
wa_api = request.env['social.whatsapp.api'].sudo()
|
|
res = wa_api.send_text_message(company, record.from_phone, reply_text)
|
|
if res.get('success'):
|
|
record.write({'status': 'replied'})
|
|
_logger.info("Automated order status reply sent to %s for order %s", record.from_phone, order.name)
|