"""Canal WhatsApp : format des webhooks et des envois de l'API WhatsApp Business Cloud (Meta). Formats repris de la documentation Meta (webhooks « messages », POST //messages). Le simulateur local implémente les deux côtés pour travailler sans compte Meta. """ from __future__ import annotations import hashlib import hmac import logging import time import uuid from dataclasses import dataclass import httpx log = logging.getLogger(__name__) @dataclass(frozen=True) class MessageEntrant: wamid: str telephone: str nom: str texte: str def signature(corps: bytes, secret: str) -> str: return "sha256=" + hmac.new(secret.encode(), corps, hashlib.sha256).hexdigest() def signature_valide(corps: bytes, entete: str | None, secret: str) -> bool: """X-Hub-Signature-256 = HMAC-SHA256(corps brut, secret de l'application). Comparaison à temps constant.""" return bool(entete) and hmac.compare_digest(signature(corps, secret), entete) def extraire_messages(payload: dict) -> list[MessageEntrant]: """Extrait les messages texte. Les notifications de statut (sent, delivered, read) sont ignorées.""" res = [] if payload.get("object") != "whatsapp_business_account": return res for entry in payload.get("entry", []): for change in entry.get("changes", []): valeur = change.get("value", {}) noms = {c.get("wa_id"): c.get("profile", {}).get("name", "") for c in valeur.get("contacts", [])} for m in valeur.get("messages", []): if m.get("type") == "text": res.append(MessageEntrant(m["id"], m["from"], noms.get(m["from"], ""), m["text"]["body"])) else: # image, audio, localisation... : hors périmètre de ce projet res.append(MessageEntrant(m["id"], m["from"], noms.get(m["from"], ""), f"[message de type {m.get('type')} non pris en charge]")) return res def payload_entrant(telephone: str, texte: str, nom: str = "Client", phone_number_id: str = "100000000000001") -> dict: """Construit un webhook au format Meta (utilisé par le simulateur et les tests).""" return {"object": "whatsapp_business_account", "entry": [{"id": "200000000000002", "changes": [{"field": "messages", "value": { "messaging_product": "whatsapp", "metadata": {"display_phone_number": "33100000000", "phone_number_id": phone_number_id}, "contacts": [{"profile": {"name": nom}, "wa_id": telephone}], "messages": [{"from": telephone, "id": f"wamid.{uuid.uuid4().hex}", "timestamp": str(int(time.time())), "type": "text", "text": {"body": texte}}]}}]}]} def payload_sortant(telephone: str, texte: str) -> dict: # Limite documentée : 4096 caractères pour le corps d'un message texte. return {"messaging_product": "whatsapp", "recipient_type": "individual", "to": telephone, "type": "text", "text": {"preview_url": False, "body": texte[:4096]}} class ClientWhatsApp: def __init__(self, base: str, version: str, phone_number_id: str, jeton: str, transport: httpx.AsyncBaseTransport | None = None) -> None: self.url = f"{base.rstrip('/')}/{version}/{phone_number_id}/messages" self.jeton = jeton self.transport = transport # ASGITransport = simulateur dans le même processus async def envoyer(self, telephone: str, texte: str) -> str: async with httpx.AsyncClient(timeout=15, transport=self.transport) as http: r = await http.post(self.url, json=payload_sortant(telephone, texte), headers={"Authorization": f"Bearer {self.jeton}"}) r.raise_for_status() return r.json()["messages"][0]["id"]