softphone ReactJS en WebRTC pour se connecter à un IVR Twilio.
Un agent humain peut utiliser un softphone ReactJS en WebRTC pour se connecter à un IVR Twilio.
Comment ça s’architecture (résumé)
Backend (Node, Python, etc.)
Grâce à notre API, les développeurs peuvent construire leurs solutions quatre fois plus vite. Découvrez comment et pourquoi.
Front React :
Front React : utilise le Twilio Voice SDK (JS) pour établir une session WebRTC depuis le navigateur
Twilio
Twilio reçoit l’appel et exécute :
- soit votre Studio Flow (IVR no-/low-code),
- soit du TwiML (<Gather>, <Dial>, <Enqueue>, etc.),
- puis route vers bot/IA, autre file, ou agent humain (votre softphone).
1-Ci-dessous le routage vers un agent Human
Deux chemins simples
-
A. Softphone ( avec Humain agent. ) → Numéro Twilio (configuré sur Studio)
Votre React appelle un numéro Twilio ; ce numéro déclenche directement le Studio IVR. -
B. Softphone → TwiML App → Redirect vers Studio
Votre webhook TwiML renvoie un <Redirect> vers l’URL du Flow Studio ou un <Dial> vers un numéro géré par Studio.
Mini-exemple (esquisse)
Backend (Node) : créer un token
// server const AccessToken = require('twilio').jwt.AccessToken; const VoiceGrant = AccessToken.VoiceGrant; const token = new AccessToken(ACCOUNT_SID, API_KEY, API_SECRET, { identity: userId }); token.addGrant(new VoiceGrant({ outgoingApplicationSid: TWIML_APP_SID })); res.json({ token: token.toJwt() });
Frontend (React) : appeler via WebRTC
import { Device } from '@twilio/voice-sdk'; const device = new Device(token); // token récupéré du backend await device.register(); // WebRTC ready const call = await device.connect({ params: { to: 'studio' } }); // ou numéro Twilio
TwiML (webhook) : rediriger vers Studio
<Response> <Redirect>https://webhooks.twilio.com/v1/Accounts/ACxxx/Flows/FWxxx</Redirect> </Response>
Alternatives utiles
- SIP Interface Twilio pour enregistrer un softphone SIP (plus complexe côté navigateur).
- Twilio Flex si vous voulez un poste agent clé-en-main (mais vous pouvez garder votre softphone React custom).
En bref : oui, votre softphone React/WebRTC peut « attaquer » l’IVR Twilio sans problème ; la voie la plus simple est Twilio Voice SDK JS + Studio Flow.
2- L'autre channel > Routage vers une IA
2- l'autre voie: Routage vers une IA ?
Twilio reçoit l’appel et exécute :
- soit votre Studio Flow (IVR no-/low-code),
- soit du TwiML (<Gather>, <Dial>, <Enqueue>, etc.),
- puis route vers bot/IA,
retour sur le cas Numéro 1 : des humains > Twilio Voice SDK JS + Studio Flow. ?Pour le cas N°1 ) mais depuis un softphone webRTC inclus dans un client JS (*)
* genre clone de ChatGPT en cours de dév pour le projet Magic.School qui serait aussi applicable pour solar school sous partie de la magic school )
Twilio Voice SDK JS + Studio Flow. ? > Oui — Twilio Voice SDK (JS) + Studio Flow est un couple qui marche très bien.
Voici la version ultra-condensée pour le mettre en place.
Schéma express
Navigateur (React/WebRTC) → Twilio Voice (WebRTC) → Numéro/TwiML App → Studio Flow (IVR) → (IA / Bot / Agent humain + ERP)
Étapes clés
- Backend (Node/Python) : générez un Access Token Twilio avec VoiceGrant (lié à une TwiML App ou appelez un numéro Twilio configuré sur votre Studio Flow).
-
Frontend (React) : utilisez @twilio/voice-sdk pour créer un Device, vous enregistrer, puis composer soit :
- le numéro Twilio relié à Studio (le plus simple),
- soit une TwiML App dont le webhook renvoie un <Redirect> vers le Flow Studio.
Snippets mini
Backend (Node) – token
import { jwt } from 'twilio'; const { AccessToken } = jwt; const { VoiceGrant } = AccessToken; export function getVoiceToken(req, res) { const token = new AccessToken(process.env.ACCOUNT_SID, process.env.API_KEY, process.env.API_SECRET, { identity: req.user.id, // votre identifiant agent }); token.addGrant(new VoiceGrant({ outgoingApplicationSid: process.env.TWIML_APP_SID })); res.json({ token: token.toJwt() }); }
Frontend (React) – appel vers Studio (via numéro)
import { Device } from '@twilio/voice-sdk'; const device = new Device(token, { edge: 'euw1', codecPreferences: ['opus'] }); await device.register(); // Appeler le numéro Twilio attaché au Studio Flow const call = await device.connect({ params: { To: '+33123456789' } });
Webhook TwiML (si vous n’utilisez pas de numéro, mais une TwiML App)
<Response> <!-- Redirige l'appel vers votre Studio Flow --> <Redirect>https://webhooks.twilio.com/v1/Accounts/ACxxxxx/Flows/FWxxxxx</Redirect> </Response>
Bons réflexes (France/UE)
- Edge : utilisez euw1 (latence moindre en Europe).
- Audio : privilégiez OPUS.
- DTMF / Speech : Studio gère les choix via touches ou reconnaissance vocale.
- Routage : depuis Studio, orientez vers
- un bot/IA (Webhook/Functions)
- ou agent humain (Queue, <Dial> vers poste WebRTC/SIP).
boilerplate React + Node minimal prêt à lancer (token endpoint + page d’appel) adapté à votre cas d’usage.
Parfait — voici un boilerplate prêt à l’emploi (React + Node) qui fait exactement Twilio Voice SDK (JS) → Studio Flow (IVR) avec softphone WebRTC côté agent.
Ce que fait ce starter
- Frontend (React/Vite) : un softphone WebRTC minimal (composer, raccrocher, DTMF) via @twilio/voice-sdk.
-
Backend (Node/Express) : un endpoint /token (JWT + VoiceGrant) et un webhook TwiML qui peut soit :
- rediriger vers votre Studio Flow (si vous renseignez STUDIO_FLOW_WEBHOOK),
- soit servir un mini-IVR de démo (<Gather>, appuyez 1 = agent WebRTC, 2 = bot de démo).
Mise en route ultra-rapide
- Côté Twilio
- Créez un Studio Flow (IVR) et attachez-le à un numéro Twilio, ou notez l’URL du webhook du Flow.
- Créez une TwiML App et mettez la Voice URL vers votre backend /voice (exposé avec ngrok).
- Backend
cd backend cp .env.example .env # remplissez: ACCOUNT_SID, API_KEY_SID, API_KEY_SECRET, TWIML_APP_SID npm install npm run dev # exposez : ngrok http 3001 → configurez la TwiML App Voice URL = https://<ngrok>/voice
- Frontend
cd frontend npm install npm run dev # Ouvrez http://localhost:5173 # Composez votre numéro Twilio rattaché au Studio Flow
Astuces & bonnes pratiques
- Europe : l’edge euw1 est déjà configuré (latence moindre).
- Codecs : OPUS prioritaire dans le starter.
- Routage agent : depuis Studio, utilisez soit Queues/TaskRouter, soit un <Dial><Client>IDENTITY</Client> (l’identity de l’agent est celle renvoyée par le backend /token — variable WEBRTC_AGENT_IDENTITY).
- Sécurité : ne mettez jamais vos clés API en front ; le token est délivré par le backend uniquement.
- DTMF & STT : Studio gère DTMF directement ; pour la reco vocale, configurez Gather (speech) dans les widgets/steps adéquats.
Si vous voulez, je peux vous ajouter un exemple de widget Studio (JSON export) avec un routage vers agent + bot, ou
un webhook vers votre ERP (Odoo) pour un cas d’usage concret ( ODOO contact ou CRM ) ?
Voici les 2 façons courantes d’utiliser des webhooks avec Odoo :
- sortants (Odoo → autre service) : Odoo envoie un POST/GET quand un événement se produit.
- entrants (autre service → Odoo) : Odoo expose une URL que l’autre service appelle.
1) Webhooks sortants (sans coder un module)
A. Via Actions automatisées (UI natif)
- Activez le Mode développeur.
- Allez dans Paramètres → Technique → Automatisation → Actions automatisées.
- Créer : choisissez le Modèle (ex. sale.order) et l’événement (création, écriture, condition domaine, etc.).
- Action à exécuter → Exécuter du code Python.
- Code minimal pour pousser un JSON vers votre endpoint :
import json, requests url = "https://example.com/webhooks/sales" payload = { "id": record.id, "name": record.name, "state": record.state, "amount_total": record.amount_total, "customer": record.partner_id.name, } headers = { "Content-Type": "application/json", "X-Webhook-Secret": "votre_clef_partagée" } # Timeout court + gestion d’erreur simple try: resp = requests.post(url, data=json.dumps(payload), headers=headers, timeout=5) if resp.status_code >= 400: raise Exception(f"Webhook failed: {resp.status_code} {resp.text[:200]}") except Exception as e: # journaliser proprement env['ir.logging'].create({ 'name': 'webhook_out', 'type': 'server', 'level': 'ERROR', 'dbname': env.cr.dbname, 'message': str(e), 'path': 'webhook', 'line': 0, 'func': 'automated_action', })
Bonnes pratiques (sortant)
- Idempotence : envoyez event_id/event_timestamp pour que le récepteur déduplique.
- Sécurité : ajoutez une signature HMAC dans un header, ex. X-Signature: sha256=....
- Fiabilité : si critique, basculez sur une file d’attente (module OCA queue_job) pour retries.
- Filtrage : mettez une condition domaine sur l’action (ex. seulement quand state = 'sale').
Alternative no-code : avec Odoo Studio, créez la même action automatisée et choisissez “Exécuter du code Python”.
2) Webhooks entrants (exposer une URL dans Odoo)
Pour recevoir des appels de Stripe, Twilio, etc., on crée un petit contrôleur HTTP dans un module Odoo.
A. Squelette de module minimal
__manifest__.py
{ "name": "webhook_in_demo", "version": "17.0.1.0.0", "depends": ["base"], "data": [], "installable": True, "application": False, }
controllers/webhook.py
from odoo import http from odoo.http import request import hmac, hashlib, json SECRET = b"votre_clef_partagee" # à mettre en ir.config_parameter class WebhookController(http.Controller): @http.route("/webhook/externe", type="json", auth="public", methods=["POST"], csrf=False) def webhook_externe_json(self, **kwargs): # Si le fournisseur envoie JSON pur (type="json"), request.jsonrequest contient déjà le dict payload = request.jsonrequest or {} signature = request.httprequest.headers.get("X-Signature", "") body_raw = request.httprequest.data # bytes # Vérification HMAC (exemple SHA256) digest = hmac.new(SECRET, body_raw, hashlib.sha256).hexdigest() if signature != f"sha256={digest}": return {"status": "error", "reason": "bad signature"} # Exemple : créer/mettre à jour un partenaire name = payload.get("customer_name") email = payload.get("customer_email") if name: partner = request.env["res.partner"].sudo().search([("email", "=", email)], limit=1) if partner: partner.sudo().write({"name": name}) else: request.env["res.partner"].sudo().create({"name": name, "email": email}) return {"status": "ok"}
__init__.py
from . import controllers
controllers/__init__.py
from . import webhook
Points clés (entrant)
- Route : csrf=False pour autoriser un POST sans token de formulaire.
- Auth : auth="public" (le standard pour webhooks), mais validez toujours une signature (HMAC) ou un token dans l’URL (/webhook/externe?token=...).
- Sécurité réseau : optionnellement, whitelistez les IPs du fournisseur (si listées).
- Idempotence : utilisez un event_id fourni et stockez-le (modèle webhook.event.log) pour éviter les doubles traitements.
- Logs : journalisez les requêtes (taille limitée) et les erreurs.
- Performance : si traitement lourd, poussez dans une queue (OCA queue_job) et répondez vite 200 OK.
3) Tests & Débogage
- Postman / cURL : envoyez un POST JSON vers votre route.
- Ngrok (dev local) : exposez votre Odoo en HTTPS public pour que le fournisseur envoie ses webhooks vers votre machine.
- Journaux Odoo : surveillez ir.logging ou les logs serveur.
4) Cas d’usage rapides
- Changement d’état de devis/commande → notifiez votre ERP/logistique externe (webhook sortant via action automatisée).
- Webhook Stripe/Twilio → créez/validez un paiement ou logguez un appel dans Odoo (webhook entrant via contrôleur).
- Synchronisation CRM → à chaque création de lead, push vers un Webhook Zapier/Make.
5) Sécurité (checklist)
- HMAC obligatoire ou token secret dans l’URL.
- Timeout court (3–5 s) côté sortant, et réponse rapide côté entrant.
- Retry policy côté récepteur (ou file d’attente côté Odoo).
- Chiffrement TLS (HTTPS uniquement).
- Sanitisation des données reçues + contrôle des champs.
Twilio (+ WhatsApp Business) côté téléphonie et un petit contrôleur côté Odoo CRM.
Oui, c’est possible — et assez propre avec Twilio (+ WhatsApp Business) côté téléphonie et un petit contrôleur côté Odoo CRM. Voici un par-cours concret + le code minimal prêt à adapter.
Architecture rapide
Appel entrant (numéro Twilio) → IVR Studio qui capte nom/prénom + email (reconnaissance vocale, FR) et confirme le numéro mobile → Twilio Function (Node.js) qui :
- nettoie/valide les champs,
- envoie un WhatsApp de confirmation au prospect,
- poste les données vers Odoo (route sécurisée) pour créer un lead.
Caller → Twilio Number (Voice) → Twilio Studio (Gather speech FR + fallback DTMF/record) → Twilio Function (parse/validate + WhatsApp message + POST Odoo) → Odoo Controller (/twilio/lead) → crm.lead.create(...)
1) IVR : Twilio Studio (étapes clés)
- Trigger: Incoming Call
- Say/Play (FR) : “Bonjour ! Dites votre nom et prénom après le bip.”
-
Gather Input on Call → input: speech, language: fr-FR, hints: prénom, nom
- on success → “Merci. Maintenant, dictez votre adresse e-mail.” (2e Gather speech, hints: arobase, point, gmail, hotmail, ...)
- Split based on (si SpeechResult vide) → Record (fallback)
- Enfin → Run Function (passe name_result, email_result, caller)
Astuces :
- Mettre speechTimeout: auto, timeout: 7.
- Prévoir un DTMF fallback (“Si besoin, tapez 1 pour être rappelé”).
- Ajouter un dernier Confirm : “Nous allons vous écrire sur WhatsApp au +33… est-ce correct ?” (DTMF 1=oui/2=non → si non, Gather pour un autre numéro).
2) Twilio Function (Node.js, serverless)
// /functions/handle-lead.js exports.handler = async function(context, event, callback) { const axios = require('axios'); const crypto = require('crypto'); const rawName = (event.name_result || '').trim(); const rawEmail = (event.email_result || '').toLowerCase().replace(/\s+/g,''); const caller = (event.From || '').replace(/^tel:/,''); // e.g. +336... // 1) Normalisation & validation const name = rawName.replace(/[^a-zA-ZÀ-ÿ'\-\s]/g, ' ').replace(/\s{2,}/g,' ').trim(); const email = rawEmail .replace(/arobase/g,'@').replace(/\s+point\s+/g,'.') .replace(/[^a-z0-9@.\-_+]/g,'') const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); // Si email invalide, on le laisse vide (Odoo pourra le compléter plus tard) const phone_e164 = caller; // Supposé déjà e164 par Twilio. Sinon: Twilio Lookup API. // 2) Envoi WhatsApp (template pré-approuvée) // Vous devez activer WhatsApp Business sur votre numéro Twilio et créer un template. const client = context.getTwilioClient(); try { await client.messages.create({ from: 'whatsapp:+YOUR_WHATSAPP_TWILIO_NUMBER', to: `whatsapp:${phone_e164}`, body: `Bonjour ${name || ''} ! Merci pour votre appel. Nous vous recontactons très vite.` // → en production, utilisez un "template" WhatsApp (HSM) approuvé si en dehors de la fenêtre 24h. }); } catch (e) { console.log('WA send error', e.message); } // 3) Push vers Odoo (HMAC signé) const payload = { source: 'IVR_Twilio', name: name || 'Prospect (IVR)', email: isEmail ? email : '', phone: phone_e164, notes: `Capturé via IVR le ${new Date().toISOString()}` }; const body = JSON.stringify(payload); const secret = context.ODOO_WEBHOOK_SECRET; // config var const signature = 'sha256=' + crypto.createHmac('sha256', secret).update(body).digest('hex'); try { const resp = await axios.post(`${context.ODOO_BASE_URL}/twilio/lead`, body, { headers: {'Content-Type':'application/json','X-Signature':signature} }); return callback(null, {status: 'ok', odoo: resp.data}); } catch (e) { console.log('Odoo push error', e.response?.status, e.response?.data); return callback(null, {status: 'ko', reason: 'odoo_push_failed'}); } };
Variables d’environnement à définir (Twilio Console → Functions):
- ODOO_BASE_URL=https://votre-odoo.com
- ODOO_WEBHOOK_SECRET=...
- YOUR_WHATSAPP_TWILIO_NUMBER=+33... (numéro WhatsApp activé)
3) Odoo : route webhook pour créer un lead
Module minimal ivr_twilio_crm :
__manifest__.py
{ "name": "IVR Twilio → CRM Lead", "version": "17.0.1.0.0", "depends": ["crm"], "data": [], "installable": True, }
controllers/webhook.py
from odoo import http from odoo.http import request import hmac, hashlib class TwilioWebhook(http.Controller): @http.route("/twilio/lead", type="json", auth="public", methods=["POST"], csrf=False) def twilio_lead(self, **kwargs): body_raw = request.httprequest.data # bytes sig = request.httprequest.headers.get("X-Signature","") secret = (request.env['ir.config_parameter'] .sudo().get_param('ivr_twilio.secret','')).encode() digest = hmac.new(secret, body_raw, hashlib.sha256).hexdigest() if sig != f"sha256={digest}": return {"status":"error","reason":"bad_signature"} payload = request.jsonrequest or {} name = payload.get("name") or "Prospect (IVR)" email = (payload.get("email") or "").strip() phone = (payload.get("phone") or "").strip() notes = payload.get("notes") or "" source = payload.get("source") or "IVR_Twilio" # Trouver/Créer partenaire Partner = request.env["res.partner"].sudo() partner = False if email: partner = Partner.search([("email","=",email)], limit=1) if not partner and phone: partner = Partner.search([("phone","=",phone)], limit=1) if not partner: partner = Partner.create({"name": name, "email": email or False, "phone": phone or False}) # Créer un lead CRM Lead = request.env["crm.lead"].sudo() lead_vals = { "name": f"Lead IVR - {name}", "partner_id": partner.id, "contact_name": name, "email_from": email or False, "phone": phone or False, "description": notes, "source_id": False, # ou un tag/stage selon votre config "type": "opportunity", "team_id": False, } lead = Lead.create(lead_vals) return {"status":"ok", "lead_id": lead.id}
__init__.py
from . import controllers
controllers/__init__.py
from . import webhook
Après installation, définissez le secret dans Paramètres → Technique → Paramètres système :
Key: ivr_twilio.secret → Value: VOTRE_SECRET
Points à connaître (WhatsApp & conformité)
- WhatsApp Business : votre numéro peut être un fixe (très courant). Il faut l’activer pour WhatsApp Business via Twilio (ou Meta direct).
- Opt-in : pour initier un message en dehors de la fenêtre 24h, utilisez une template approuvée (HSM). Le message ci-dessus fonctionne à chaud juste après l’appel (fenêtre de 24h “user-initiated”).
- RGPD : annoncez le but, la durée de conservation, l’envoi WhatsApp, et proposez un moyen d’opt-out.
- Robustesse : si la reco-vocale échoue, basculez sur enregistrement audio + notification interne (mail/WhatsApp à votre équipe) pour transcription manuelle.
Variantes (si vous préférez open-source)
- Asterisk/FreeSWITCH + Google STT pour la capture vocale → webhook vers Odoo identique.
- Kamailio pour la signalisation SIP + un media server (Asterisk/FS) + passerelle WhatsApp Cloud API (Meta) côté sortie.
Ce que je peux vous livrer ensuite
- Le JSON du Studio Flow clé en main (avec les nœuds Gather/Confirm/Function).
- Le module Odoo zippé prêt à installer (17/16).
- Une template WhatsApp FR prête à soumettre (texte + variables).
a- template WhatsApp en français,
Parfait 👍 Je vous prépare une template WhatsApp en français, au format attendu par Meta WhatsApp Business (via Twilio ou directement).
1. Structure officielle d’une template
Une template est composée de :
- Nom interne (ex. : ivr_lead_confirmation)
- Langue : fr
- Catégorie : UTILITY (confirmation / information)
- Contenu avec variables ({{1}}, {{2}}…)
⚠️ Règle : les variables sont dynamiques (remplies par l’API Twilio).
2. Exemple concret — Confirmation d’appel IVR
Nom de la template :
ivr_lead_confirmation
Langue :
fr
Catégorie :
UTILITY
Texte :
Bonjour {{1}} 👋 Merci pour votre appel. Nous avons bien enregistré vos coordonnées : 📞 Téléphone : {{2}} 📧 Email : {{3}} Notre équipe vous recontactera rapidement. Vous pouvez répondre directement à ce message si vous souhaitez compléter vos informations. À très bientôt !
3. Variables prévues
- {{1}} → Nom/Prénom détecté
- {{2}} → Numéro de téléphone (format E.164, ex. +336…)
- {{3}} → Adresse e-mail (ou vide si non reconnu)
4. Soumission dans Meta Business Manager
- Connectez-vous à Meta Business Manager → WhatsApp Manager
- Allez dans Templates de messages
-
Créez une nouvelle template :
- Catégorie : Utility
- Langue : Français
- Contenu : collez le texte ci-dessus
- Variables : définies automatiquement ({{1}}, {{2}}, {{3}})
Une fois approuvée (généralement en quelques heures), vous pouvez l’utiliser via l’API Twilio :
await client.messages.create({ from: 'whatsapp:+VOTRE_NUM_TWILIO', to: 'whatsapp:+33XXXXXXXXX', body: undefined, // ignoré pour les templates contentSid: 'HSM_TEMPLATE_ID', // Twilio remplace par la template contentVariables: JSON.stringify({ 1: "Jean Dupont", 2: "+33612345678", 3: "jean.dupont@email.com" }) });
2- Le JSON du Studio Flow clé en main (avec les nœuds Gather/Confirm/Function).
Parfait — voici un Studio Flow JSON prêt à importer (FR) qui :
- Accueille l’appelant
- Capte Nom + Prénom (reconnaissance vocale)
- Capte Email (reconnaissance vocale, avec conseils “arobase”, “point”)
- Confirme le numéro détecté par Twilio (+ option de le ressaisir au besoin)
- Appelle une Twilio Function handle-lead avec les données (name/email/phone)
- Fallback en enregistrement vocal si la reco échoue
- Termine l’appel
👍 J’ai ajouté :
- une demande explicite du numéro (si besoin) — déjà présente via confirmation/saisie.
- une question de consentement WhatsApp (“1 = oui / 2 = non” aka savoir si l'utilisateur accepte de recevoir des messages par whatsapp ) pour la suite d’inscription à Magic School.
- le passage du consentement à la Function (consent_wa) pour que l’envoi WhatsApp ne parte que si l’appelant accepte.
Voici le Studio Flow JSON mis à jour (prêt à importer) — seules les nouvelles étapes sont ajoutées, le reste du flow reste identique :
Copiez/collez ce JSON dans Twilio Studio → Import from JSON.
Voici le Studio Flow JSON mis à jour (prêt à importer) — seules les nouvelles étapes sont ajoutées, le reste du flow reste identique :
{ "description": "IVR Lead Capture FR → Twilio Function → Odoo CRM (+ consentement WhatsApp Magic School)", "initial_state": "Trigger", "flags": { "allow_concurrent_calls": true }, "states": [ { "name": "Trigger", "type": "trigger", "transitions": [{ "event": "incomingCall", "next": "Say_Welcome" }], "properties": {} }, { "name": "Say_Welcome", "type": "say-play", "transitions": [{ "event": "audioComplete", "next": "Gather_Name" }], "properties": { "voice": "fr-FR-Wavenet-A", "say": "Bonjour et bienvenue chez Magic School. Après le bip, dites clairement votre nom et votre prénom.", "loop": 1 } }, { "name": "Gather_Name", "type": "gather-input-on-call", "transitions": [ { "event": "speech", "next": "Say_Ask_Email" }, { "event": "noInput", "next": "Record_Fallback" }, { "event": "hangup", "next": "Hangup" } ], "properties": { "input": "speech", "speech_timeout": "auto", "timeout": 7, "language": "fr-FR", "hints": "nom, prénom", "profanity_filter": "true", "gather_language": "fr-FR", "say": "Bip.", "voice": "fr-FR-Wavenet-A" } }, { "name": "Say_Ask_Email", "type": "say-play", "transitions": [{ "event": "audioComplete", "next": "Gather_Email" }], "properties": { "voice": "fr-FR-Wavenet-A", "say": "Merci. Nous allons maintenant enregistrer votre adresse e mail. Dites votre e mail, par exemple jean point dupont arobase exemple point com." } }, { "name": "Gather_Email", "type": "gather-input-on-call", "transitions": [ { "event": "speech", "next": "Confirm_Phone" }, { "event": "noInput", "next": "Record_Fallback" }, { "event": "hangup", "next": "Hangup" } ], "properties": { "input": "speech", "speech_timeout": "auto", "timeout": 8, "language": "fr-FR", "hints": "arobase, point, gmail, hotmail, yahoo, outlook", "profanity_filter": "false", "gather_language": "fr-FR", "say": "Bip.", "voice": "fr-FR-Wavenet-A" } }, { "name": "Confirm_Phone", "type": "say-play", "transitions": [{ "event": "audioComplete", "next": "DTMF_Confirm_Phone" }], "properties": { "voice": "fr-FR-Wavenet-A", "say": "Nous avons détecté votre numéro {{contact.channel.address}}. Si ce numéro est correct, tapez 1. Pour donner un autre numéro, tapez 2." } }, { "name": "DTMF_Confirm_Phone", "type": "gather-input-on-call", "transitions": [ { "event": "keypress", "next": "Split_Confirm" }, { "event": "noInput", "next": "Split_Timeout_Confirm" } ], "properties": { "input": "dtmf", "num_digits": 1, "timeout": 5, "say": "Votre choix s'il vous plaît.", "voice": "fr-FR-Wavenet-A" } }, { "name": "Split_Timeout_Confirm", "type": "say-play", "transitions": [{ "event": "audioComplete", "next": "Split_Confirm" }], "properties": { "voice": "fr-FR-Wavenet-A", "say": "Pas de saisie détectée, nous prenons le numéro actuel." } }, { "name": "Split_Confirm", "type": "split-based-on", "transitions": [ { "event": "noMatch", "next": "Use_Current_Phone" }, { "event": "match", "next": "Use_Current_Phone", "conditions": [{ "friendly_name": "If 1 pressed", "arguments": ["{{widgets.DTMF_Confirm_Phone.Digits}}"], "type": "equal_to", "value": "1" }] }, { "event": "match", "next": "Say_Ask_New_Phone", "conditions": [{ "friendly_name": "If 2 pressed", "arguments": ["{{widgets.DTMF_Confirm_Phone.Digits}}"], "type": "equal_to", "value": "2" }] } ], "properties": { "input": "{{widgets.DTMF_Confirm_Phone.Digits}}" } }, { "name": "Say_Ask_New_Phone", "type": "say-play", "transitions": [{ "event": "audioComplete", "next": "Gather_New_Phone" }], "properties": { "voice": "fr-FR-Wavenet-A", "say": "Veuillez saisir votre numéro au clavier, avec l'indicatif pays. Par exemple, pour la France, 3 3 suivi de votre numéro." } }, { "name": "Gather_New_Phone", "type": "gather-input-on-call", "transitions": [ { "event": "keypress", "next": "Normalize_New_Phone" }, { "event": "noInput", "next": "Use_Current_Phone" } ], "properties": { "input": "dtmf", "finish_on_key": "#", "timeout": 8, "say": "Composez le numéro puis appuyez sur dièse.", "voice": "fr-FR-Wavenet-A" } }, { "name": "Normalize_New_Phone", "type": "set-variables", "transitions": [{ "event": "next", "next": "Ask_Consent_WA" }], "properties": { "variables": [{ "key": "final_phone", "value": "+{{widgets.Gather_New_Phone.Digits}}" }] } }, { "name": "Use_Current_Phone", "type": "set-variables", "transitions": [{ "event": "next", "next": "Ask_Consent_WA" }], "properties": { "variables": [{ "key": "final_phone", "value": "{{contact.channel.address}}" }] } }, /* === NOUVEAU : Consentement WhatsApp === */ { "name": "Ask_Consent_WA", "type": "say-play", "transitions": [{ "event": "audioComplete", "next": "DTMF_Consent_WA" }], "properties": { "voice": "fr-FR-Wavenet-A", "say": "Acceptez-vous de recevoir un message WhatsApp pour la suite de votre inscription à Magic School ? Tapez 1 pour oui, 2 pour non." } }, { "name": "DTMF_Consent_WA", "type": "gather-input-on-call", "transitions": [ { "event": "keypress", "next": "Split_Consent_WA" }, { "event": "noInput", "next": "Default_Consent_No" } ], "properties": { "input": "dtmf", "num_digits": 1, "timeout": 6, "say": "Votre choix s'il vous plaît.", "voice": "fr-FR-Wavenet-A" } }, { "name": "Split_Consent_WA", "type": "split-based-on", "transitions": [ { "event": "noMatch", "next": "Default_Consent_No" }, { "event": "match", "next": "Set_Consent_Yes", "conditions": [{ "friendly_name": "If 1 pressed", "arguments": ["{{widgets.DTMF_Consent_WA.Digits}}"], "type": "equal_to", "value": "1" }] }, { "event": "match", "next": "Set_Consent_No", "conditions": [{ "friendly_name": "If 2 pressed", "arguments": ["{{widgets.DTMF_Consent_WA.Digits}}"], "type": "equal_to", "value": "2" }] } ], "properties": { "input": "{{widgets.DTMF_Consent_WA.Digits}}" } }, { "name": "Set_Consent_Yes", "type": "set-variables", "transitions": [{ "event": "next", "next": "Run_Function" }], "properties": { "variables": [{ "key": "consent_wa", "value": "yes" }] } }, { "name": "Set_Consent_No", "type": "set-variables", "transitions": [{ "event": "next", "next": "Run_Function" }], "properties": { "variables": [{ "key": "consent_wa", "value": "no" }] } }, { "name": "Default_Consent_No", "type": "set-variables", "transitions": [{ "event": "next", "next": "Run_Function" }], "properties": { "variables": [{ "key": "consent_wa", "value": "no" }] } }, /* === Appel Function + fin personnalisée === */ { "name": "Run_Function", "type": "run-function", "transitions": [ { "event": "success", "next": "Split_Goodbye_By_Consent" }, { "event": "fail", "next": "Say_Fail" } ], "properties": { "service_sid": null, "environment_sid": null, "function_name": "handle-lead", "parameters": [ { "key": "name_result", "value": "{{widgets.Gather_Name.SpeechResult}}" }, { "key": "email_result", "value": "{{widgets.Gather_Email.SpeechResult}}" }, { "key": "From", "value": "{{flow.variables.final_phone}}" }, { "key": "consent_wa", "value": "{{flow.variables.consent_wa}}" } ], "timeout": 10 } }, { "name": "Split_Goodbye_By_Consent", "type": "split-based-on", "transitions": [ { "event": "noMatch", "next": "Say_Goodbye_NoWA" }, { "event": "match", "next": "Say_Goodbye_WA", "conditions": [{ "friendly_name": "consent yes?", "arguments": ["{{flow.variables.consent_wa}}"], "type": "equal_to", "value": "yes" }] }, { "event": "match", "next": "Say_Goodbye_NoWA", "conditions": [{ "friendly_name": "consent no?", "arguments": ["{{flow.variables.consent_wa}}"], "type": "equal_to", "value": "no" }] } ], "properties": { "input": "{{flow.variables.consent_wa}}" } }, { "name": "Say_Goodbye_WA", "type": "say-play", "transitions": [{ "event": "audioComplete", "next": "Hangup" }], "properties": { "voice": "fr-FR-Wavenet-A", "say": "Merci, vos informations ont été enregistrées. Vous recevrez un message WhatsApp pour la suite de votre inscription. À bientôt." } }, { "name": "Say_Goodbye_NoWA", "type": "say-play", "transitions": [{ "event": "audioComplete", "next": "Hangup" }], "properties": { "voice": "fr-FR-Wavenet-A", "say": "Merci, vos informations ont été enregistrées. Nous ne vous enverrons pas de message WhatsApp. À bientôt." } }, { "name": "Say_Fail", "type": "say-play", "transitions": [{ "event": "audioComplete", "next": "Hangup" }], "properties": { "voice": "fr-FR-Wavenet-A", "say": "Une erreur est survenue. Notre équipe vous rappellera. Merci et à bientôt." } }, { "name": "Record_Fallback", "type": "record-voicemail", "transitions": [ { "event": "recordingComplete", "next": "Say_Goodbye_NoWA" }, { "event": "noAudio", "next": "Say_Goodbye_NoWA" } ], "properties": { "play_beep": true, "trim": "trim-silence", "max_length_sec": "60", "finish_on_key": "#", "transcribe": false, "say": "Nous n'avons pas bien entendu. Laissez votre nom et votre e mail après le bip. Appuyez sur dièse pour terminer." } }, { "name": "Hangup", "type": "hang-up", "transitions": [], "properties": {} } ] }
Petite mise à jour de la Twilio Function handle-lead
Pour respecter le consentement, ajoutez ce test (pseudopatch) :
// event.consent_wa vaut "yes" ou "no" const consentWA = (event.consent_wa || '').toLowerCase() === 'yes'; // ... après normalisation des champs ... // 2) Envoi WhatsApp uniquement si consentement if (consentWA) { try { await client.messages.create({ from: 'whatsapp:+YOUR_WHATSAPP_TWILIO_NUMBER', to: `whatsapp:${phone_e164}`, body: `Bonjour ${name || ''} ! Merci pour votre appel. Nous vous recontactons très vite pour finaliser votre inscription à Magic School.` // ou utilisez la template approuvée }); } catch (e) { console.log('WA send error', e.message); } } // 3) Push vers Odoo — incluez le consentement const payload = { source: 'IVR_Twilio', name: name || 'Prospect (IVR)', email: isEmail ? email : '', phone: phone_e164, consent_wa: consentWA, // <-- nouveau champ notes: `Capturé via IVR ${new Date().toISOString()}` };
Et côté Odoo, stockez consent_wa dans un champ (ex.: ajout d’un booléen x_consent_whatsapp sur crm.lead) pour votre conformité RGPD.
récupération automatique du numéro de mobile ?
En bref : oui, on récupère automatiquement le numéro dans la plupart des cas, mais il faut prévoir les exceptions et laisser l’appelant corriger.Ce que Twilio fournit à chaque appel
Génèrer le module Odoo avec ce champ custom + affichage dans la vue des leads (XML) ?
Parfait. Voici un module Odoo 17 prêt à coller qui ajoute les champs custom au CRM Lead, les affiche dans la vue, et montre comment les remplir depuis votre webhook (Twilio Function → Odoo).
1) Arborescence
magic_ivr_crm_fields/ ├─ __init__.py ├─ __manifest__.py ├─ models/ │ ├─ __init__.py │ └─ crm_lead.py └─ views/ └─ crm_lead_views.xml
2) __manifest__.py
{ "name": "Magic IVR - Champs CRM pour Phone & Consentement", "version": "17.0.1.0.0", "summary": "Ajoute x_phone_raw, x_phone_e164, x_phone_source, x_consent_whatsapp, x_consent_ts sur crm.lead + vue.", "depends": ["crm"], "data": [ "views/crm_lead_views.xml", ], "license": "LGPL-3", "installable": True, "application": False, }
3) __init__.py
from . import models
4) models/__init__.py
from . import crm_lead
5) models/crm_lead.py
from odoo import api, fields, models class CrmLead(models.Model): _inherit = "crm.lead" # Téléphone x_phone_raw = fields.Char("Téléphone (brut IVR)", help="Numéro tel que reçu de la téléphonie (caller ID / saisie DTMF).") x_phone_e164 = fields.Char("Téléphone (normalisé E.164)", help="Numéro normalisé +CCXXXXXXXXX.") x_phone_source = fields.Selection( [ ("from_callerid", "Détecté automatiquement (Caller ID)"), ("user_entry", "Saisi par l'appelant"), ("unknown", "Inconnu / masqué"), ], string="Source du téléphone", default="unknown", help="Origine de la valeur du numéro." ) # Consentement WhatsApp x_consent_whatsapp = fields.Boolean("Consentement WhatsApp") x_consent_ts = fields.Datetime("Date/heure du consentement") # Aide : si vous souhaitez automatiquement copier x_phone_e164 -> phone standard @api.onchange("x_phone_e164") def _onchange_x_phone_e164(self): for lead in self: if lead.x_phone_e164 and not lead.phone: lead.phone = lead.x_phone_e164
Option : si vous préférez forcer le phone standard à suivre x_phone_e164, remplacez l’onchange par un write() dans votre contrôleur (voir plus bas).
6) views/crm_lead_views.xml
<?xml version="1.0" encoding="UTF-8"?> <odoo> <!-- Inherit formulaire Lead pour afficher les champs --> <record id="view_crm_lead_form_inherit_magic_ivr" model="ir.ui.view"> <field name="name">crm.lead.form.magic.ivr.fields</field> <field name="model">crm.lead</field> <field name="inherit_id" ref="crm.crm_lead_view_form"/> <field name="arch" type="xml"> <!-- Insérer un groupe sous l'entête coordonnées --> <xpath expr="//sheet/notebook/page[@name='internal_notes']" position="before"> <group string="Téléphonie / WhatsApp (Magic IVR)"> <group> <field name="x_phone_raw" readonly="0"/> <field name="x_phone_e164" readonly="0"/> <field name="x_phone_source"/> </group> <group> <field name="x_consent_whatsapp"/> <field name="x_consent_ts" attrs="{'invisible': [('x_consent_whatsapp','=',False)]}"/> </group> </group> </xpath> </field> </record> </odoo>
L’insertion se fait avant l’onglet “Notes internes”. Vous pouvez déplacer le bloc si besoin (ex. près de phone/mobile).
7) Intégration côté webhook Odoo (contrôleur)
Si vous utilisez déjà un contrôleur (ex. /twilio/lead) comme montré précédemment, ajoutez le mapping suivant (extrait) :
# ... dans votre controller /twilio/lead from odoo import http from odoo.http import request from datetime import datetime # payload reçu de Twilio Function : # { # "name": "...", # "email": "...", # "phone": "+336...", # "phone_raw": "...", # (optionnel) vous pouvez envoyer tel que reçu # "phone_source": "from_callerid" | "user_entry" | "unknown", # "consent_wa": true/false, # "notes": "..." # } # ... après vérification de signature HMAC et récupération payload ... name = payload.get("name") or "Prospect (IVR)" email = (payload.get("email") or "").strip() phone_e164 = (payload.get("phone") or "").strip() phone_raw = (payload.get("phone_raw") or "").strip() phone_source = payload.get("phone_source") or ("from_callerid" if phone_raw and not payload.get("user_entered") else "unknown") consent_wa = bool(payload.get("consent_wa")) notes = payload.get("notes") or "" Partner = request.env["res.partner"].sudo() Lead = request.env["crm.lead"].sudo() # Trouver/Créer partenaire (logique simple; adaptez à votre CRM) partner = False if email: partner = Partner.search([("email","=",email)], limit=1) if not partner and phone_e164: partner = Partner.search([("phone","=",phone_e164)], limit=1) if not partner: partner = Partner.create({ "name": name, "email": email or False, "phone": phone_e164 or phone_raw or False }) lead_vals = { "name": f"Lead IVR - {name}", "partner_id": partner.id, "contact_name": name, "email_from": email or False, "phone": phone_e164 or phone_raw or False, "description": notes, # Champs custom "x_phone_raw": phone_raw or phone_e164 or False, "x_phone_e164": phone_e164 or False, "x_phone_source": phone_source, "x_consent_whatsapp": consent_wa, "x_consent_ts": datetime.utcnow() if consent_wa else False, } lead = Lead.create(lead_vals) return {"status":"ok", "lead_id": lead.id}
Astuce : Si vous savez dans la Function si le numéro vient d’un DTMF ressaisi, envoyez phone_source: "user_entry". Sinon, utilisez "from_callerid" ou "unknown" selon le cas.
8) Côté Twilio Function : envoyez les bons champs
Dans votre Function handle-lead, enrichissez le payload :
const payload = { source: 'IVR_Twilio', name: name || 'Prospect (IVR)', email: isEmail ? email : '', phone: phone_e164, // normalisé (E.164) phone_raw: caller_raw, // tel que reçu (optionnel) phone_source: userEntered ? 'user_entry' : (caller_raw ? 'from_callerid' : 'unknown'), consent_wa: consentWA, // booléen notes: `Capturé via IVR le ${new Date().toISOString()}` };
9) Déploiement
- Zippez le dossier magic_ivr_crm_fields et installez-le (Apps → Update Apps List → Rechercher → Installer).
- Vérifiez la nouvelle section dans la fiche Lead.
- Testez un appel : le lead doit se créer avec x_phone_* et x_consent_*.
Si vous voulez, je peux aussi vous fournir une variante qui :
- remplit automatiquement le champ mobile (plutôt que phone) quand Twilio Lookup détecte un mobile ;
- ajoute une action serveur pour “Envoyer WhatsApp d’onboarding” visible dans le formulaire lead (bouton).