درس 4 — Construire

Canaux web et WhatsApp

9 دقائق قراءة

هذا المحتوى غير مترجم بعد. تُعرض النسخة الأصلية.

Canaux web et WhatsApp

Un client WhatsApp écrit « où est mon colis ». Meta envoie le webhook, l'agent met 2,5 secondes à répondre, le serveur rend son HTTP 200 après la réponse. Pendant un pic, un tour dépasse le délai de Meta : la plateforme considère la livraison comme échouée et renvoie le même message. Le client reçoit deux réponses, et si son message demandait un retour, deux retours sont créés. Le canal n'est pas un détail d'intégration : c'est lui qui fixe l'identité du client, l'ordre des messages et le nombre de fois où l'agent travaille.

Le mécanisme

Les deux canaux aboutissent au même appel, SupportService.message_client(canal, client_ref, texte, canal_msg_id). Le service ne sait rien de HTTP ni de Meta ; les canaux ne savent rien de l'agent.

sequenceDiagram
  participant Meta as Meta (ou simulateur)
  participant W as POST /webhook/whatsapp
  participant T as Tâche de fond
  participant S as SupportService
  participant G as API Graph /messages
  Meta->>W: corps JSON + X-Hub-Signature-256
  W->>W: HMAC-SHA256(corps brut, secret de l'application)
  W-->>Meta: 200 {"recu": true} immédiatement
  W->>T: un message texte = une tâche
  T->>S: message_client("whatsapp", wa_id, texte, wamid)
  S->>S: wamid déjà vu ? alors on s'arrête
  S-->>T: réponse de l'agent
  T->>G: POST /v25.0/{phone_number_id}/messages (Bearer)
Widget webWhatsApp
EntréePOST /api/chat (JSON validé par Pydantic)webhook signé au format Meta
client_refidentifiant de visiteur aléatoire, gardé dans localStoragenuméro wa_id, authentifié par Meta
Preuve d'identité pour une commandee-mail de la commandele numéro suffit
Réponsedans la réponse HTTPenvoi séparé vers l'API Graph
Messages d'un conseillerle widget interroge /api/chat/{visiteur}/messages toutes les 2 spoussés par le même client d'envoi

L'identifiant de visiteur web n'est pas une identité : n'importe qui peut en fabriquer un. Il sert seulement à retrouver la conversation. C'est pourquoi l'e-mail reste exigé par les outils sur ce canal (leçon 3).

Mise en œuvre

1. Vérification et signature

Meta vérifie le point de terminaison par un GET avec hub.mode=subscribe, hub.verify_token et hub.challenge ; il faut renvoyer le challenge tel quel. Chaque POST porte ensuite l'en-tête X-Hub-Signature-256, un HMAC-SHA256 du corps calculé avec le secret de l'application.

files/support/whatsapp.py (extrait)
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)

La signature porte sur les octets reçus. D'où corps = await request.body() avant tout décodage : un json.dumps(payload) recalculé n'a pas les mêmes espaces ni le même échappement des accents, et la comparaison échoue une fois sur deux selon le contenu.

2. Acquitter d'abord, traiter ensuite

files/support/app.py (extrait)
@app.post("/webhook/whatsapp")
async def webhook(request: Request, taches: BackgroundTasks) -> dict:
    corps = await request.body()
    if not wa.signature_valide(corps, request.headers.get("x-hub-signature-256"), cfg.wa_app_secret):
        raise HTTPException(401, "signature invalide")
    # Répondre 200 tout de suite : le modèle prend plusieurs secondes et Meta relance les webhooks non acquittés.
    for m in wa.extraire_messages(json.loads(corps)):
        taches.add_task(traiter_whatsapp, m)
    return {"recu": True}

extraire_messages ne garde que les messages : les notifications de statut (sent, delivered, read) arrivent sur le même webhook et sont ignorées. Une image ou un vocal devient un texte « [message de type image non pris en charge] », que l'agent traite comme une question hors périmètre.

3. L'idempotence par le wamid

Chaque message WhatsApp a un identifiant unique (wamid.…). La colonne messages.canal_msg_id est UNIQUE : la seconde insertion échoue et le service s'arrête avant l'agent.

files/support/service.py (extrait)
async with self._verrous.setdefault(cid, asyncio.Lock()):
    if self.store.ajouter_message(cid, "client", texte, canal_msg_id=canal_msg_id) is None:
        log.info("doublon ignoré %s", canal_msg_id)
        return Reponse(cid, conv["statut"], doublon=True)

Le verrou par conversation règle un second problème : un client qui envoie « bonjour » puis « LL-10587 » à une seconde d'écart. Sans verrou, deux tours d'agent tournent en parallèle sur la même session et le second ne voit pas le premier.

4. Le simulateur

Sans compte Meta, l'application joue les deux rôles. La page /whatsapp construit un webhook au format exact (payload_entrant), le signe avec le même secret et le poste sur /webhook/whatsapp. Les envois sortants partent vers /simulateur/{version}/{phone_number_id}/messages, qui imite l'API Graph (jeton Bearer, réponse {"messages": [{"id": "wamid…"}]}). Le transport est un httpx.ASGITransport : l'application s'appelle elle-même sans réseau.

Passer en production revient à changer trois variables de .env : WHATSAPP_API_BASE=https://graph.facebook.com, WHATSAPP_ACCESS_TOKEN, WHATSAPP_PHONE_NUMBER_ID, plus WHATSAPP_APP_SECRET et WHATSAPP_VERIFY_TOKEN de votre application Meta.

terminal
make test     # test_webhook_whatsapp_bout_en_bout : vérification, signature, doublon
make run      # puis http://localhost:8080/whatsapp
Simulateur WhatsApp : à gauche le webhook signé envoyé, à droite le client Julien qui demande où en est LL-10587 (2026-10-15)

Résultat obtenu

Le test sans clé couvre les quatre comportements du webhook :

files/tests/test_parcours.py (extrait)
assert client.get("/webhook/whatsapp", params={"hub.mode": "subscribe", "hub.verify_token": "faux"}).status_code == 403
assert client.post("/webhook/whatsapp", content=corps, headers={"x-hub-signature-256": "sha256=00"}).status_code == 401
assert client.post("/webhook/whatsapp", content=corps, headers=h).status_code == 200
assert client.post("/webhook/whatsapp", content=corps, headers=h).status_code == 200  # relance Meta
assert len(sortants) == 1, "le doublon (même wamid) ne produit pas de seconde réponse"

Puis le canal réel, avec le modèle (make e2e, Vertex AI, gemini-3.5-flash-lite, 2026-10-16) :

make e2e (extrait, 2026-10-16)
[whatsapp 33600000101] Bonjour, je souhaite retourner la housse de couette de ma commande LL-10421, la couleur ne me plaît pas.
  -> webhook HTTP 200, 2.9 s
  -> Votre demande de retour pour la housse de couette a bien été enregistrée. Vous pouvez télécharger et imprimer
     votre étiquette prépayée à l'adresse suivante : https://exemple.fr/etiquettes/RET-CE81C3.pdf. [...]
  OK  retour créé avec étiquette

Aucun e-mail n'a été demandé : le numéro WhatsApp a suffi, parce que le service l'a écrit dans l'état de session.

Ce qui casse en production

SymptômeCauseParade
Toutes les signatures sont refusées, ou certaines seulementHMAC calculé sur un JSON re-sérialisé, ou mauvais secret (jeton d'accès au lieu du secret de l'application)signer request.body() ; le secret se trouve dans les paramètres de base de l'application Meta
Le client reçoit deux ou trois réponses identiqueswebhook acquitté après le tour d'agent, Meta relance200 immédiat, traitement en tâche de fond, wamid unique en base
Les réponses partent dans le désordredeux tours parallèles sur la même conversationverrou par conversation
Une conseillère répond le lundi à un message du samedi, l'envoi est refuséfenêtre de service de 24 h dépassée : hors fenêtre, Meta n'accepte qu'un modèle de message approuvéprévoir un modèle « nous avons traité votre demande », journaliser l'erreur de l'API Graph
Un envoi échoue et rien ne le signalelivrer_whatsapp journalise et abandonnemarqué ponytail: dans le code ; en production, une file de réémission (Cloud Tasks)

La règle de décision

Sur un canal à webhook (WhatsApp, Messenger, Slack), acquittez avant de réfléchir et dédoublonnez sur l'identifiant fourni par la plateforme, jamais sur le texte : deux « oui » successifs sont deux messages. Sur le web, gardez le HTTP synchrone tant qu'un tour reste sous 10 secondes ; au-delà, passez au même modèle asynchrone que WhatsApp.

Pratique

Vos journaux montrent le même wamid reçu trois fois en 40 secondes, et trois réponses envoyées au client. Le code insère bien canal_msg_id. Que vérifiez-vous en premier ?

Un développeur propose de reconnaître un client web à son adresse IP pour lui épargner l'e-mail. Que répondez-vous ?

Exercice. Un client envoie une photo du colis abîmé. Aujourd'hui, l'agent reçoit « [message de type image non pris en charge] ». Modifiez extraire_messages pour que toute image déclenche directement une escalade de priorité haute, sans passer par le modèle.

Voir la solution

Le webhook ne doit pas appeler l'agent pour une image : le modèle n'a pas l'image, il ne peut que deviner. Ajoutez un champ type à MessageEntrant, puis dans traiter_whatsapp :

if m.type == "image":
    conv = svc.store.conversation_ouverte("whatsapp", m.telephone)
    if svc.store.ajouter_message(conv["id"], "client", "[photo envoyée]", canal_msg_id=m.wamid) is None:
        return  # doublon
    pos = svc.store.escalader(conv["id"], "Photo envoyée (colis abîmé ?)", "haute")
    await livrer_whatsapp(conv, f"Photo reçue. Un conseiller la regarde (position {pos}).")
    return

Le test : un payload_entrant modifié avec "type": "image" doit placer la conversation en tête de store.file_attente() sans qu'aucune requête n'atteigne le faux modèle (len(faux_modele.requetes) inchangé). Pour aller plus loin, le média se récupère par son identifiant via l'API Graph et peut être passé à Gemini, qui lit les images.

  • Je sais vérifier une signature X-Hub-Signature-256 sur le corps brut
  • Je sais pourquoi on acquitte un webhook avant le tour d'agent, et ce que cela impose sur Cloud Run
  • Je sais rendre un webhook idempotent avec l'identifiant de la plateforme
  • J'ai envoyé un message depuis le simulateur et vu la réponse arriver par l'API Graph simulée
التالي