squelette Node/Express

le squelette Node/Express pour /call/invite (mint token + joinUrl) et l’exemple TwiML pour <Dial><Client> afin de brancher directement votre softphone React.

voici le squelette prêt à brancher (Node/Express + TwiML) pour votre flux agent → client softphone avec Twilio Voice JS SDK.

Je vous fournis : variables d’env., backend /call/invite, TwiML d’appel, webhook d’états, et snippets côté front.

1) Variables d’environnement (.env)

PORT=3001 APP_BASE_URL=https://votre-app.fr # pour construire la joinUrl (page React client) TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx TWILIO_API_KEY=SKxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # API Key SID TWILIO_API_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # API Key Secret TWILIO_TWIML_APP_SID=APxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # TwiML App (Programmable Voice) TWILIO_STATUS_CALLBACK_URL=https://votre-api.fr/voice/status-callback ODDO_BASE_URL=https://votre-odoo.fr ODDO_API_KEY=xxxxxxxxxxxxxxxx

2) Backend Node/Express (mint token + endpoints)

server.js

import 'dotenv/config'; import express from 'express'; import cors from 'cors'; import crypto from 'crypto'; import { jwt } from 'twilio'; const app = express(); app.use(cors({ origin: true, credentials: true })); app.use(express.json()); // Génération d'un Access Token Twilio Voice pour une identité "client:{uuid}" function mintVoiceToken(identity) { const { AccessToken } = jwt; const { VoiceGrant } = AccessToken; const token = new AccessToken( process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_API_KEY, process.env.TWILIO_API_SECRET, { identity, ttl: 60 * 30 } // 30 minutes ); const voiceGrant = new VoiceGrant({ outgoingApplicationSid: process.env.TWILIO_TWIML_APP_SID, // Facultatif : autoriser les appels entrants (Device.register()) incomingAllow: true }); token.addGrant(voiceGrant); return token.toJwt(); } /** * POST /call/invite * Body: { contactId, displayName } * - Crée une "invitation" app : génère identity + token, * - Retourne une joinUrl vers VOTRE page React "softphone client", * qui récupérera le token (ici: token en query pour le MVP; en prod, préférez un /token séparé). */ app.post('/call/invite', async (req, res) => { try { const { contactId, displayName } = req.body || {}; // Identité unique navigateur du client const identity = `client:${contactId || crypto.randomUUID()}`; // Token Voice const jwt = mintVoiceToken(identity); // URL vers votre page React (ex: /softphone?identity=...&token=...) const joinUrl = new URL('/softphone', process.env.APP_BASE_URL); joinUrl.searchParams.set('identity', identity); joinUrl.searchParams.set('token', jwt); // TODO: Enregistrer l’“appel” côté Odoo (état = pending, contactId, identity, timestamps) // await odooLogCall({ contactId, identity, status: 'pending' }) return res.json({ callId: crypto.randomUUID(), identity, joinUrl: joinUrl.toString(), expiresInSec: 60 * 30 }); } catch (e) { console.error(e); return res.status(500).json({ error: 'invite_failed' }); } }); /** * TwiML Voice: pointé par votre TwiML App (Programmable Voice) * - Permet aux AGENTS de "composer" vers une identité client. * On passe ?To=client:xxxxx pour joindre le navigateur du client. */ app.post('/twiml/voice', (req, res) => { const to = (req.body.To || '').trim(); res.set('Content-Type', 'text/xml'); if (!to) { // Appel entrant sans cible → jouer un message ou raccrocher return res.send(` <Response> <Say>Pas de destinataire spécifié.</Say> <Hangup/> </Response> `); } // Vers identité Twilio Client (navigateur) if (to.startsWith('client:')) { return res.send(` <Response> <Dial callerId="client:agent"> <Client>${to}</Client> </Dial> </Response> `); } // Vers PSTN (fallback) return res.send(` <Response> <Dial callerId="+33123456789">${to}</Dial> </Response> `); }); /** * Webhook d’état d’appel Twilio (Status Callback) * - Mappe initiated/ringing/answered/completed → Odoo (pending/ringing/connected/ended) */ app.post('/voice/status-callback', async (req, res) => { try { const { CallSid, CallStatus, // queued|initiated|ringing|in-progress|completed|busy|failed|no-answer|canceled Direction, // outbound-api|inbound|... Timestamp, Called, Caller, To, From } = req.body; const map = { initiated: 'pending', ringing: 'ringing', 'in-progress': 'connected', completed: 'ended', busy: 'ended', failed: 'ended', 'no-answer': 'ended', canceled: 'ended' }; const status = map[CallStatus] || 'unknown'; // TODO: update Odoo call log par CallSid (ou par pair {identity/contactId}) // await odooUpdateCall({ callSid: CallSid, status, details: req.body }); console.log('[StatusCB]', { CallSid, CallStatus, Direction, To, From, at: Timestamp, mapped: status }); return res.sendStatus(200); } catch (e) { console.error(e); return res.sendStatus(500); } }); app.listen(process.env.PORT || 3001, () => { console.log(`API Voice running on :${process.env.PORT || 3001}`); });

3) Agent : composer vers le client (front minimal)

Côté agent, vous “composez” vers client:{identity}.

Exemple (fetch + ouverture dans votre UI) :

// Quand l’agent clique "Appeler" async function inviterClient(contactId: string) { const res = await fetch('https://votre-api.fr/call/invite', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contactId }) }); const { identity, joinUrl } = await res.json(); // 1) Envoyer joinUrl par SMS/WhatsApp/e-mail (au choix), ou l’afficher en QR. // 2) De l’autre côté (client), la page softphone chargera le token et Device. // 3) Côté agent, initier l’appel sortant via Twilio Voice JS SDK → TwiML voice va <Dial><Client> // Exemple ultra-condensé (SDK v2) : // const device = new Twilio.Device(tokenAgent, { codecPreferences: ['opus'] }); // await device.register(); // device.connect({ params: { To: identity } }); }

4) Client : rejoindre l’appel (votre page React softphone)

Vous avez déjà le composant UI (Rejoindre/Mute/Raccrocher).

Voici un snippet d’intégration Voice SDK pour rejoindre avec le token passé en query :

import { useEffect, useRef, useState } from 'react'; import { Device } from '@twilio/voice-sdk'; // Voice JS SDK export default function SoftphoneClient() { const [device, setDevice] = useState(null); const [connected, setConnected] = useState(false); const connRef = useRef(null); useEffect(() => { const url = new URL(window.location.href); const token = url.searchParams.get('token'); if (!token) return; const d = new Device(token, { codecPreferences: ['opus'], // Opus par défaut warnings: false }); d.on('registered', () => console.log('Device registered')); d.on('error', e => console.error(e)); d.on('incoming', (conn) => { // Si vous autorisez l’appel entrant, vous pouvez auto-accept conn.accept(); connRef.current = conn; setConnected(true); }); d.on('connect', (conn) => { connRef.current = conn; setConnected(true); }); d.on('disconnect', () => { connRef.current = null; setConnected(false); }); d.register(); setDevice(d); return () => { d.destroy(); }; }, []); const hangup = () => { connRef.current?.disconnect(); }; return ( <div> <h1>Softphone Client</h1> <p>{connected ? 'Connecté' : 'En attente d’appel...'}</p> {connected && <button onClick={hangup}>Raccrocher</button>} </div> ); }

Remarque : pour un modèle “le client clique Rejoindre”, vous pouvez aussi déclencher une connexion sortante :

// device.connect() depuis le client si vous voulez un "click-to-join" // connRef.current = device.connect({ params: { To: 'client:agent' } });

5) TwiML App (Programmable Voice)

Dans la console Twilio, créez une TwiML App et pointez l’URL Voice vers votre endpoint :

Voice CONFIG: REQUEST URL (POST): https://votre-api.fr/twiml/voice Status Callback: https://votre-api.fr/voice/status-callback (POST, events: initiated, ringing, answered, completed)

6) Points importants (prod)

  • Tokens : pour la prod, servez le token via un endpoint /token (GET authentifié) plutôt qu’en query string.
  • TURN/Media : géré par Twilio (pas de STUN/TURN à opérer).
  • RGPD : affichez le consentement et la politique (si enregistrement, avertissement explicite).
  • Odoo : créez un modèle “VoIP Call” (contact/opportunité, identity, callSid, états, timestamps).
  • Fallback PSTN : si WebRTC échoue, utilisez le même TwiML avec <Dial>+Numéro</Dial> (avec opt-in et info coût).


route Odoo (Python ou XML-RPC/JSON-RPC) pour créer/mettre à jour automatiquement l’activité “Appel VoIP” à chaque webhook Twilio.

route Odoo (Python ou XML-RPC/JSON-RPC)

a route Odoo (Python ou XML-RPC/JSON-RPC) pour créer/mettre à jour automatiquement l’activité “Appel VoIP” à chaque webhook Twilio.


Découvrir plus