https://d226lax1qjow5r.cloudfront.net/blog/blogposts/creating-a-complex-ivr-system-with-ease-with-xstate-dr/unnamed.jpg

Creación sencilla de un sistema IVR complejo con XState

Publicado el May 13, 2021

Tiempo de lectura: 7 minutos

Aunque no sepa que se llaman así, probablemente utilice sistemas IVR todo el tiempo. Un sistema IVR en sistema IVR te permite llamar a un número de teléfono, escuchar las pistas de audio y navegar por la llamada para obtener la información que necesitas. Vonage hace que crear un sistema IVR completo sea tan simple como activar un servidor Web. En este post veremos cómo crear sistemas IVR muy complejos y elaborados manteniendo el código simple y fácil de mantener. Para lograr esto, usaremos XState que es una popular librería de Máquinas de Estado para Javascript.

Un sistema IVR con menos de 35 líneas de código

La clave para implementar un sistema IVR con Vonage es crear un servidor web que le indique a Vonage cómo manejar cada paso de la llamada. Por lo general, esto significa que tan pronto como un usuario llame a tu número virtual entrante, Vonage enviará una solicitud HTTP a tu punto final /answer y esperará que respondas con una carga útil JSON compuesta por OBJETOS NCCO que especifican lo que el usuario debe escuchar. De manera similar, cuando el usuario utiliza su teclado para elegir lo que desea escuchar a continuación, Vonage realiza una solicitud a un punto final diferente llamado típicamente /dtmf. El punto final /dtmf con una carga útil de solicitud que incluye el número que el usuario ha elegido, que tu servidor debe usar para averiguar con qué conjunto de objetos NCCO debe responder.

Veamos qué aspecto tiene esto en código cuando se utiliza express para alimentar nuestro servidor Web.

const express = require('express');
const bodyParser = require('body-parser');

const port = process.env.PORT || 3000;
const app = express();
app.use(bodyParser.json());

app.post('/answer', (req, res) => {
  const ncco = [
    { action: 'talk', text: "Hi. You've reached Joe's Restaurant! Springfield's top restaurant chain!" },
    { action: 'talk', text: 'Please select one of our locations.' },
    { action: 'talk', text: 'Press 1 for our Main Street location.' },
    { action: 'talk', text: 'Press 2 for our Broadway location.' },
    { action: 'input', eventUrl: [ 'https://example.com/dtmf' ], maxDigits: 1 },
  ];
  res.json(ncco);
});

app.post('/dtmf', (req, res) => {
  const { dtmf } = req.body;
  let ncco;
  switch (dtmf) {
    case '1':
      ncco = [ { action: 'talk', text: "Joe's Main Street is located at Main Street number 11, Springfield." } ];
      break;
    case '2':
      ncco = [ { action: 'talk', text: "Joe's Broadway is located at Broadway number 46, Springfield." } ];
      break;
  }
  res.json(ncco);
});

app.listen(port, () => console.log(`Example app listening on port ${port}!`));

Pruébelo usted mismo

Ya puedes empezar a escribir el código de tu aplicación. Pero para poder llamar y probar por ti mismo que todo funciona, necesitarás completar lo siguiente:

Vonage API Account

To complete this tutorial, you will need a Vonage API account. If you don’t have one already, you can sign up today and start building with free credit. Once you have an account, you can find your API Key and API Secret at the top of the Vonage API Dashboard.

This tutorial also uses a virtual phone number. To purchase one, go to Numbers > Buy Numbers and search for one that meets your needs.

  • Asegúrese de que su servidor Web es accesible en la Web. Puede hacerlo exponiendo su máquina de desarrollo local utilizando Ngrok o desarrollando con Glitch.

  • Cree una aplicación Voice. Puedes hacerlo a través del Sitio web de Vonageo usando la CLI de Vonage. Deberás ingresar la url pública de tu /answer cuando configures tu aplicación.

  • Obtenga un número entrante virtual y conéctelo a su aplicación mediante la función Sitio web o CLI.

Cuando todo esto esté listo, podrás llamar a tu número y escucharás la respuesta de audio basada en los datos que te devuelva tu servidor Web.

Más allá del "Hola Mundo" de los sistemas IVR

El ejemplo anterior funciona como se espera, pero un sistema IVR del mundo real cederá la entrada al usuario muchas veces, e interpretará la entrada numérica del usuario basándose en el estado del usuario en la llamada. Para ilustrar esto, supongamos que en nuestro ejemplo se le pedirá al usuario que elija la ubicación del restaurante que le interesa y, a continuación, que elija si quiere escuchar el horario de apertura o hacer una reserva. En ambos casos, el usuario puede pulsar 1 en el teclado, pero la interpretación depende de la pista de audio anterior y del estado del usuario en la llamada.

Para soportar este caso de uso tendremos que cambiar el código que acabamos de escribir. Idealmente, lo cambiaremos de manera que a medida que añadimos funcionalidad y hacemos nuestro sistema IVR más complejo con el tiempo, nuestro código seguirá siendo simple y no tendremos que volver a pensar en cómo estructurarlo. Para lograr esto, modelaremos nuestra estructura de llamadas como una Máquina de estados finitos utilizando XStateuna librería de Máquinas de Estado para Javascript.

##A Primer on State Machines

Una Máquina de Estado es simplemente un modelo para una "máquina" que sólo puede estar en un estado en un momento dado, y sólo puede pasar de un estado a otro dadas unas entradas específicas. XState y otras bibliotecas de Máquinas de Estado permiten modelar e instanciar una máquina en código, de forma que se garantice el cumplimiento de las "reglas" de la Máquina de Estado.

Modelar nuestra estructura de llamadas como una máquina de estados

Para modelar nuestra estructura de llamadas como una máquina de estados, utilizaremos la función Machine que expone XState:

// machine.js
const { Machine } = require('xstate');

module.exports = Machine({
  id: 'call',
  initial: 'intro',
  states: {
    intro: {
      on: {
        'DTMF-1': 'mainStLocation',
        'DTMF-2': 'broadwayLocation'
      }
    },
    mainStLocation: {
    },
    broadwayLocation: {
    }
  }
});

Como se puede ver en el código anterior, nuestra llamada sólo puede estar en uno de los tres estados:

  • La página intro estado en el que el usuario está escuchando la introducción y se le indica que elija la ubicación que le interesa.

  • El mainStLocation ubicación en Main St. de nuestro hipotético restaurante chai

  • El broadwayLocation estado cuando están escuchando información sobre la ubicación de Broadway.

También puedes verlo:

  • La única forma de pasar al mainStLocation es estar en el estado intro y enviar el evento DTMF-1 evento.

  • La única forma de pasar al estado broadwayLocation es estar en el estado intro y enviar el evento DTMF-2 evento.

Podemos optar por colocar los objetos NCCO relacionados con cada estado dentro de la definición del evento utilizando la metapropiedad de XState metapropiedad

// machine.js
const { Machine } = require('xstate');

module.exports = Machine({
  id: 'call',
  initial: 'intro',
  states: {
    intro: {
      on: {
        'DTMF-1': 'mainStLocation',
        'DTMF-2': 'broadwayLocation'
      },
      meta: {
        ncco: [
          { action: 'talk', text: "Hi. You've reached Joe's Restaurant! Springfield's top restaurant chain!" },
          { action: 'talk', text: 'Please select one of our locations.' },
          { action: 'talk', text: 'Press 1 for our Main Street location.' },
          { action: 'talk', text: 'Press 2 for our Broadway location.' },
          { action: 'input', eventUrl: [ 'https://example.com/dtmf' ], maxDigits: 1 }
        ]
      }
    },
    mainStLocation: {
      meta: {
        ncco: [
          { action: 'talk', text: "Joe's Main Street is located at Main Street number 11, Springfield." }
        ]
      }
    },
    broadwayLocation: {
      meta: {
        ncco: [
          { action: 'talk', text: "Joe's Broadway is located at Broadway number 46, Springfield." }
        ]
      }
    }
  }
});

Utilización de nuestra máquina

El objeto que devuelve la función Machine debe ser tratado como un objeto inmutable sin estado que define la estructura de nuestra máquina. Para crear realmente una instancia de nuestra máquina que podamos utilizar como fuente de verdad para el estado de una llamada, utilizaremos la función XState interpret . La función interpret devuelve un objeto denominado "Servicio". Puede acceder al estado actual de cada instancia de máquina utilizando la propiedad state del servicio. Y puedes enviar un evento para cambiar el estado de la instancia de la máquina usando el método send() del servicio. Crearemos un módulo callManager que se encargará de crear instancias de máquina para cada llamada entrante, enviando los eventos apropiados a medida que la llamada progresa, y eliminando cada instancia de máquina cuando la llamada finaliza.

// callManager.js
const { interpret } = require('xstate');
const machine = require('./machine');

class CallManager {
  constructor() {
    this.calls = {};
  }

  createCall(uuid) {
    const service = interpret(machine).start();
    this.calls\[uuid] = service;
  }

  updateCall(uuid, event) {
    const call = this.calls\[uuid];
    if(call) {
      call.send(event);
    }
  }

  getNcco(uuid) {
    const call = this.calls\[uuid];
    if(!call) {
      return \[];
    }
    return call.state.meta[`${call.id}.${call.state.value}`].ncco;
  }

  endCall(uuid) {
    delete this.calls\[uuid];
  }
}

exports.callManager = new CallManager();

Como puedes ver, cada llamada se identifica por su uuid que Vonage se encarga de asignar a cada llamada.

Puesta en común

Ahora podemos modificar el código de nuestro servidor Web para que se aplace a la función callManager cada vez que el backend de Vonage llame a nuestros endpoints.

/// server.js
const express = require('express');
const bodyParser = require('body-parser');
const { callManager} = require('./callManager');

const port = process.env.PORT || 3000;
const app = express();
app.use(bodyParser.json());

app.post('/answer', (req, res) => {
  callManager.createCall(req.body.uuid);
  const ncco = callManager.getNcco(req.body.uuid);
  res.json(ncco);
});

app.post('/dtmf', (req, res) => {
  callManager.updateCall(req.body.uuid, `DTMF-${req.body.dtmf}`);
  const ncco = callManager.getNcco(req.body.uuid);
  res.json(ncco);
});

app.post('/event', (req, res) => {
  if(req.body.status == 'completed') {
    callManager.endCall(req.body.uuid);
  }
  res.json({ status: 'OK' });
});

app.listen(port, () => console.log(`Example app listening on port ${port}!`));

Como puedes ver, para saber cuándo finalizó la llamada, agregamos un punto final /event. Si lo asocias con tu aplicación de Vonage como el webhook "Event URL", Vonage realizará una solicitud de manera asincrónica cuando cambie el estado general de la llamada (por ejemplo, cuando el usuario cuelgue). A diferencia de /answer o /dtmf no puedes responder con objetos NCCO a esta solicitud e influir en lo que escucha el usuario.

Cambiar la estructura de llamadas con facilidad

Acabamos de completar una refactorización del código de nuestra aplicación, pero se comporta exactamente igual que antes. Pero a diferencia de antes, ahora modificar la estructura de llamadas es tan sencillo como cambiar el objeto JSON que pasamos a la función Machine función.

Así que si, como hemos mencionado antes, queremos que el usuario decida si quiere escuchar el horario de apertura del local o hacer una reserva, sólo tenemos que añadir unos cuantos estados, transiciones y matrices NCCO más a la definición de nuestra Máquina.

// machine.js
const { Machine } = require('xstate');

module.exports = Machine({
  id: 'call',
  initial: 'intro',
  states: {
    intro: {
      on: {
        'DTMF-1': 'mainStLocation',
        'DTMF-2': 'broadwayLocation'
      },
      meta: {
        ncco: [
          { action: 'talk', text: "Hi. You've reached Joe's Restaurant! Springfield's top restaurant chain!" },
          { action: 'talk', text: 'Please select one of our locations.' },
          { action: 'talk', text: 'Press 1 for our Main Street location.' },
          { action: 'talk', text: 'Press 2 for our Broadway location.' },
          { action: 'input', eventUrl: [ 'https://example.com/dtmf' ], maxDigits: 1 }
        ]
      }
    },
    mainStLocation: {
      on: {
        'DTMF-1': 'mainStReservation',
        'DTMF-2': 'mainStHours',
      },
      meta: {
        ncco: [
          { action: 'talk', text: "Joe's Main Street is located at Main Street number 11, Springfield." },
          { action: 'talk', text: 'Press 1 to make a reservation.' },
          { action: 'talk', text: 'Press 2 to hear our operating hours.' },
          { action: 'input', eventUrl: [ 'https://example.com/dtmf' ], maxDigits: 1 },
        ]
      }
    },
    broadwayLocation: {
      on: {
        'DTMF-1': 'broadwayReservation',
        'DTMF-2': 'broadwayHours',
      },
      meta: {
        ncco: [
          { action: 'talk', text: "Joe's Broadway is located at Broadway number 46, Springfield." },
          { action: 'talk', text: 'Press 1 to make a reservation.' },
          { action: 'talk', text: 'Press 2 to hear our operating hours.' },
          { action: 'input', eventUrl: [ 'https://example.com/dtmf' ], maxDigits: 1 },
        ]
      }
    },
    mainStReservation: { /* ... */ },
    mainStHours: { /* ... */ },
    broadwayReservation: { /* ... */ },
    broadwayHours: { /* ... */ }
  }
});

Más bondades de XState

XState tiene más funciones útiles que pueden ayudarnos a medida que nuestro modelo de llamadas se vuelve más intrincado.

Visualizador XState

El Visualizador XState es una herramienta online para generar diagramas Statechart basados en tus definiciones existentes de Máquina XState. Todo lo que tenemos que hacer para generar un Statechart es pegar su llamada a la función Machine . Esto es particularmente útil para compartir con las partes interesadas no desarrolladores para tener discusiones sobre la estructura de llamada.

chart

Transiciones autorreferentes

Un estado puede transicionar hacia sí mismo. Esto puede ser útil para los casos en los que desea permitir al usuario reproducir la última pieza de información dada.

mainStHours: {
  on: {
    'DTMF-1': 'mainStHours',
    'DTMF-2': 'intro'  },
  meta: {
    ncco: [
      { action: 'talk', text: "Joe's Main Street is open Monday through Friday, 8am to 8pm." },
      { action: 'talk', text: 'Saturday and Sunday 9am to 7pm.' },
      { action: 'talk', text: 'Press 1 to hear this information again.' },
      { action: 'talk', text: 'Press 2 to go back to the opening menu.' },
      { action: 'input', eventUrl: [ 'https://example.com/dtmf' ], maxDigits: 1 }
    ]
  }
}

Persistencia

Puedes registrar una función para que sea llamada cada vez que la máquina pase de un estado a otro utilizando el método onTransition del servicio. Esto puede ser útil para registrar los pasos que está dando el usuario y enviarlos a una base de datos remota para futuras referencias/análisis.

En general, XState admite serializar los datos de una instancia de máquina para que pueda persistir.

Modo estricto

Cuando se pide al usuario que introduzca datos con el teclado en cualquier momento de la llamada, es posible que introduzca un valor que no se espera. Por ejemplo, el usuario puede encontrarse en un estado de la llamada en el que usted espera que elija 1 si desea hacer una reserva o que pulse 2 para escuchar el horario de apertura. Pero si el usuario pulsa 9 el evento enviado será DTMF-9 y esa no es una transición posible dado el estado actual. Idealmente nos gustaría encontrar una forma genérica de detectar cuando el usuario ha introducido una entrada no válida e indicarle que haga la selección de nuevo.

Definiendo nuestra máquina con strict: true podemos hacer que el método send() lance una excepción si se le pasa un evento que no es posible dado el estado actual. Podemos entonces atrapar ese error más adelante y responder con una respuesta NCCO apropiada que le dirá al usuario que haga la selección de nuevo.

Conclusión

En este post presentamos la librería XState y cómo puede ser utilizada para controlar el progreso de una llamada en un sistema IVR potenciado por Vonage, de una manera que escala bien para un caso de uso en el mundo real. El código completo cubierto en este post se puede encontrar aquí. Si desea obtener más información, tanto Vonage y XState tienen una excelente documentación.

Compartir:

https://a.storyblok.com/f/270183/150x150/a3d03a85fd/placeholder.svg
Yonatan Mevorach

Yonatan Mevorach is a Web Developer, blogger, and open-source contributor.