Softphone Twilio (Voice JS SDK) côté agent humain 

<> 


Odoo CRM.​

c’est possible :

  • Le softphone Twilio (Voice JS SDK) côté agent humain peut afficher, en plus de l’écran d’appel, des données issues de votre Odoo CRM.


        • Techniquement, il suffit que le softphone appelle un petit backend CTI qui interroge Odoo via JSON-RPC / XML-RPC / REST et lui renvoie les infos utiles (contact, opportunité, historique, notes…).
        • Ce backend peut aussi écrire dans Odoo (log d’appel, création d’activité, rattachement à un contact).

🔗 Comment ça marche en séquence

  1. Appel entrant (Twilio → Voice JS SDK).
  2. Le SDK fournit un numéro ou un identifiant (ex : call.parameters.From).
  3. Votre backend CTI interroge Odoo :
    • res.partner pour retrouver le contact,
    • crm.lead pour opportunités liées,
    • mail.activity pour l’historique.
  4. Le résultat est renvoyé au softphone web → l’agent voit la fiche ou un résumé (nom, email, dernière interaction…).
  5. Pendant ou après l’appel, vous pouvez logger une activité dans Odoo (avec durée, agent, enregistrement Twilio, transcription).



🔒 Points importants

  • Sécurité : jamais exposer vos identifiants Odoo directement dans le front, toujours passer par un backend.
  • RGPD : si vous êtes en France/Europe, mieux vaut héberger votre backend CTI dans l’UE et limiter les données renvoyées.
  • UX agent : on parle souvent de “screen-pop” → la fiche contact s’ouvre automatiquement quand le téléphone sonne.

👉 Si vous voulez, je peux vous préparer :

  • un exemple TwiML (backend Twilio) qui passe les infos de l’appelant jusqu’au front agent,
  • et un snippet React pour afficher en direct la fiche contact Odoo dans la même page que le softphone.


> Le webhook TwiML (Node/Express)

1) Webhook TwiML (Node/Express)

// server.js (extraits pertinents) import express from "express"; import { twiml } from "twilio"; const app = express(); app.use(express.urlencoded({ extended: false })); app.use(express.json()); /** * Webhook d’entrée : /voice * - Récupère le numéro appelant (From) * - Route vers un agent web (client: support-agent) * - Passe des "Parameter" (phone, leadId…) lisibles côté agent dans le Voice JS SDK */ app.post("/voice", (req, res) => { const vr = new twiml.VoiceResponse(); const callerPhone = req.body.From || ""; // ex: +336... const leadId = req.query.leadId || ""; // si vous voulez enrichir depuis votre site const dial = vr.dial(); const client = dial.client(); client.identity("support-agent"); // cible (l'agent web enregistré) client.parameter({ name: "customer_phone", value: callerPhone }); if (leadId) client.parameter({ name: "lead_id", value: String(leadId) }); res.type("text/xml").send(vr.toString()); }); /** * Suivi d’appel côté Twilio → votre backend (facultatif mais utile) * - Recevez les statuts (ringing, answered, completed) * - Enregistrez durée, recordingUrl, etc., puis loggez dans Odoo */ app.post("/status", (req, res) => { // req.body.CallSid, CallStatus, From, To, Timestamp... // TODO: POST /api/odoo/calls/log res.sendStatus(200); }); app.listen(3001, () => console.log("TwiML webhooks on http://localhost:3001"));

Dans votre TwiML App (Programmable Voice), mettez https://votre-domaine/voice en Voice URL (POST) et https://votre-domaine/status en Status Callback URL.

2) React (Agent) — Voice JS SDK + Screen-pop Odoo


// AgentSoftphone.jsx import React, { useEffect, useRef, useState } from "react"; import { Device } from "@twilio/voice-sdk"; /** * Conditions : * - Vous exposez /token?identity=support-agent (backend qui délivre un AccessToken Twilio) * - Vous exposez /api/odoo/contacts/lookup?phone=... (backend CTI → Odoo JSON-RPC) */ export default function AgentSoftphone() { const [status, setStatus] = useState("déconnecté"); const [contact, setContact] = useState(null); // objet Odoo à afficher const deviceRef = useRef(null); const activeCallRef = useRef(null); const connect = async () => { if (deviceRef.current) return; setStatus("connexion…"); const r = await fetch(`/token?identity=${encodeURIComponent("support-agent")}`); const { token } = await r.json(); const dev = new Device(token, { codecPreferences: ["opus", "pcmu"], allowIncomingWhileBusy: true, }); // Appels entrants dev.on("incoming", async (call) => { setStatus("appel entrant…"); // 1) Récupérer les paramètres TwiML passés dans <Client><Parameter> const phone = call.parameters?.From || call.customParameters?.get("customer_phone") || ""; // 2) Screen-pop Odoo if (phone) { try { const resp = await fetch( `/api/odoo/contacts/lookup?phone=${encodeURIComponent(phone)}` ); const data = await resp.json(); setContact((data.matches && data.matches[0]) || null); } catch (e) { console.error("Lookup Odoo failed", e); } } // 3) Gérer le call call.on("accept", () => setStatus("en communication")); call.on("disconnect", () => { setStatus("terminé"); activeCallRef.current = null; }); call.accept(); // auto-accept MVP activeCallRef.current = call; }); dev.on("registered", () => setStatus("enregistré (support-agent)")); dev.on("error", (e) => setStatus(`erreur: ${e.message}`)); await dev.register(); deviceRef.current = dev; }; const hangup = () => { activeCallRef.current?.disconnect(); activeCallRef.current = null; }; useEffect(() => { return () => { activeCallRef.current?.disconnect(); deviceRef.current?.unregister(); deviceRef.current?.destroy(); }; }, []); return ( <div style={{ maxWidth: 840, margin: "24px auto", fontFamily: "system-ui" }}> <h2>Console Agent – Softphone Twilio</h2> <div style={{ display: "flex", gap: 8, margin: "8px 0" }}> <button onClick={connect}>Se connecter</button> <span style={{ opacity: 0.75 }}>{status}</span> <button onClick={hangup} style={{ marginLeft: "auto" }}> Raccrocher </button> </div> <div style={{ display: "grid", gridTemplateColumns: "2fr 1fr", gap: 12 }}> <div style={{ border: "1px solid #eee", borderRadius: 12, padding: 12 }}> <h3>État de l’appel</h3> <p>La carte softphone (boutons, timer, etc.) peut être ici.</p> <p style={{ fontSize: 12, color: "#666" }}> Astuce : si vous passez aussi un <code>lead_id</code> en Parameter, vous pouvez précharger l’opportunité Odoo en parallèle. </p> </div> <div style={{ border: "1px solid #eee", borderRadius: 12, padding: 12 }}> <h3>Screen-pop Odoo (contact)</h3> {contact ? ( <div> <div style={{ fontWeight: 600 }}>{contact.name}</div> <div style={{ fontSize: 14 }}> {contact.email || "—"} · {contact.phone || contact.mobile || "—"} </div> <div style={{ marginTop: 8 }}> <a href={`https://votre-odoo/res_partner/${contact.id}`} target="_blank" rel="noreferrer" > Ouvrir dans Odoo </a> </div> </div> ) : ( <div style={{ fontSize: 14, color: "#666" }}>Aucun contact trouvé.</div> )} </div> </div> </div> ); }

Points clés à ne pas oublier

  • /token (backend) doit délivrer un Access Token Twilio avec un VoiceGrant (outgoingApplicationSid et incomingAllow: true) pour support-agent.
  • /api/odoo/contacts/lookup doit faire l’auth Odoo (JSON-RPC) et renvoyer un contact (voir l’exemple que je vous ai donné précédemment).
  • Dans /voice, vous pouvez aussi router selon files (ventes, support) et passer d’autres Parameters (ex. campus, lang, lead_id) afin d’enrichir le screen-pop.
  • Si vous enregistrez les appels, utilisez le statusCallback pour récupérer recordingUrl et la durée, puis loggez dans Odoo (mail.activity, crm.lead, etc.).

> le /token (Node/Express) et un exemple de module Odoo minimal pour un endpoint REST de lookup

oici les deux briques demandées, prêtes à copier-coller.

1) Backend /token (Node/Express) — Twilio Access Token (VoiceGrant)

npm i express cors dotenv twilio

// token-server.js require('dotenv').config(); const express = require('express'); const cors = require('cors'); const { jwt: { AccessToken } } = require('twilio'); const app = express(); app.use(cors()); app.get('/token', (req, res) => { const identity = String(req.query.identity || 'support-agent'); const token = new AccessToken( process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_API_KEY, process.env.TWILIO_API_SECRET, { ttl: 3600 } // 1h ); token.identity = identity; const VoiceGrant = AccessToken.VoiceGrant; const grant = new VoiceGrant({ outgoingApplicationSid: process.env.TWILIO_TWIML_APP_SID, // APxxxxxxxx… incomingAllow: true, // recevoir des appels vers client:<identity> }); token.addGrant(grant); res.json({ token: token.toJwt(), identity }); }); const PORT = process.env.PORT || 3001; app.listen(PORT, () => console.log(`Token server on http://localhost:${PORT}`));

.env minimal :

TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx TWILIO_API_KEY=SKxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx TWILIO_API_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx TWILIO_TWIML_APP_SID=APxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx PORT=3001

Dans la console Twilio → Programmable Voice → TwiML Apps, créez l’app, mettez votre /voice en URL vocale, et récupérez le SID (AP…).

2) Module Odoo minimal (REST) — lookup contact par téléphone

Objectif : exposer un endpoint HTTP REST côté Odoo pour rechercher un contact (res.partner) à partir d’un numéro, de façon sécurisée.

Structure du module

odoo_addons/ cti_partner_api/ __manifest__.py __init__.py controllers/ __init__.py partner_api.py

__manifest__.py

{ "name": "CTI Partner API", "version": "16.0.1.0.0", "summary": "Endpoints REST pour lookup contact (CTI)", "depends": ["base", "contacts"], "data": [], "installable": True, "application": False, "license": "LGPL-3", }

__init__.py

from . import controllers

controllers/__init__.py

from . import partner_api

controllers/partner_api.py

# -*- coding: utf-8 -*- from odoo import http from odoo.http import request import re import json def _normalize_phone(raw): if not raw: return "" s = re.sub(r"[^\d+]", "", str(raw)) # FR: 0XXXXXXXXX -> +33XXXXXXXXX if s.startswith("0") and len(s) == 10: s = "+33" + s[1:] return s def _auth_api_key(req): """ Auth très simple par clé API envoyée en header: Authorization: Bearer <API_KEY> À remplacer par OAuth/SSO si besoin. """ h = req.httprequest.headers.get("Authorization", "") if h.startswith("Bearer "): token = h[7:].strip() # Compare avec la clé stockée en param système (ou env) api_key = request.env["ir.config_parameter"].sudo().get_param("cti_partner_api.key") return token and api_key and token == api_key return False class PartnerAPI(http.Controller): @http.route("/odoo/api/partners/lookup", type="http", auth="public", methods=["GET"], csrf=False) def partners_lookup(self, **params): # Auth simple if not _auth_api_key(request): return request.make_response(json.dumps({"error": "unauthorized"}), headers=[("Content-Type", "application/json")], status=401) phone = _normalize_phone(params.get("phone")) limit = int(params.get("limit", 5)) env = request.env["res.partner"].sudo() domain = ["|", ("phone", "=", phone), ("mobile", "=", phone)] fields = ["id", "name", "email", "phone", "mobile", "company_type"] partners = env.search_read(domain, fields, limit=limit) # Fallback "contient" si pas trouvé if not partners and phone: last7 = phone[-7:] domain2 = ["|", ("phone", "ilike", last7), ("mobile", "ilike", last7)] partners = env.search_read(domain2, fields, limit=limit) return request.make_response(json.dumps({"matches": partners}), headers=[("Content-Type", "application/json")], status=200)


Configuration & sécurité

  • Dans Paramètres techniques → Paramètres système, créez une clé :
    • Key: cti_partner_api.key
    • Value: (ex) super-secret-123
  • Le backend CTI appellera l’API avec :
    Authorization: Bearer super-secret-123
  • Limitez l’accès réseau (reverse-proxy, IP allow-list), et CORS côté backend CTI (pas côté Odoo si possible).

Appel côté backend CTI (exemple Node)

// lookup depuis votre backend CTI import axios from "axios"; const ODOO_API = "https://odoo.exemple.fr/odoo/api/partners/lookup"; const ODOO_API_KEY = process.env.ODOO_API_KEY; async function lookupContactByPhone(phone) { const r = await axios.get(ODOO_API, { params: { phone, limit: 5 }, headers: { Authorization: `Bearer ${ODOO_API_KEY}` }, timeout: 5000, }); return r.data.matches || []; }

Exemple cURL

curl -G "https://odoo.exemple.fr/odoo/api/partners/lookup" \ -H "Authorization: Bearer super-secret-123" \ --data-urlencode "phone=+33612345678" \ --data-urlencode "limit=3"

Réponse :

{ "matches": [ { "id": 42, "name": "Dupont Alice", "email": "alice@exemple.fr", "phone": "+33123456789", "mobile": "+33612345678", "company_type": "person" } ] }

Où brancher ça dans votre softphone Twilio (agent)

  • Au ring (device.on('incoming')) récupérez call.parameters.From et/ou vos <Parameter> TwiML (call.customParameters.get('customer_phone')).
  • Appelez votre backend CTI (qui lui appelle Odoo via REST ci-dessus).
  • Affichez la fiche contact (screen-pop) dans le panneau agent.

Extrait (rappel) :

device.on("incoming", async (call) => { const phone = call.parameters?.From || call.customParameters?.get("customer_phone") || ""; if (phone) { const r = await fetch(`/api/lookup?phone=${encodeURIComponent(phone)}`); // votre backend CTI proxy vers Odoo const { matches } = await r.json(); setContact(matches?.[0] || null); } call.accept(); });

Bonnes pratiques (résumé)

  • Ne jamais appeler Odoo directement depuis le navigateur : passez par votre backend CTI.
  • Stockez les clés (Twilio / Odoo) côté serveur uniquement.
  • Journalisez côté Odoo (activité mail.activity ou modèle custom), pas dans le front.
  • Si vous enregistrez les appels : utilisez le statusCallback de Twilio pour récupérer recordingUrl/durée et rattacher à la fiche.