https://d226lax1qjow5r.cloudfront.net/blog/blogposts/state-machines-for-whatsapp-messaging-bots-with-node-js/state-machine_1200x600-1.png

Máquinas de estado para bots de mensajería de WhatsApp con Node.js

Publicado el August 23, 2021

Tiempo de lectura: 6 minutos

En un servidor web típico, no hay que pensar mucho en el estado. Un usuario te envía una petición y tú le das una respuesta. No es necesario que la aplicación se guíe a sí misma a través de un camino de opciones y acciones; el usuario final hace eso. Sin embargo, un bot funciona de forma diferente.

Aunque un usuario final inicie una conversación con un bot, el bot necesita definir el camino a partir de ahí, haciendo preguntas al usuario para informarle de los posibles pasos siguientes. Cuando el bot no se limita a responder preguntas, sino que guía al usuario final a través de una serie de pasos, es lo que se conoce como una máquina de estados.

Implementar una máquina de estados como un bot de mensajería es un poco complicado porque los bots de mensajería no tienen orgánicamente ningún concepto de estado. Por defecto, un mensaje enviado a un servidor que controlas llega sin una sesión, estado o cualquier otra información sobre una gran imagen de la que el mensaje individual pueda formar parte. Pero en realidad todo lo que eso significa es que tendrás que almacenar manualmente el último estado de una "sesión" entre tu servidor y un número de teléfono determinado. En realidad, una aplicación web tiene que hacer lo mismo. Las plataformas y las bibliotecas simplemente hacen el trabajo por nosotros de forma rutinaria.

Requisitos previos

Nuestro servidor bot utilizará uno de esos servidores web tradicionales de una forma no tradicional. Para seguir con este ejemplo necesitarás:

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.

El código que vamos a ver forma parte de un proyecto más amplio de proyecto de ejemplo de bot de WhatsApp en Glitch. También puedes copiar y pegar desde allí, o remezclarlo para empezar con una aplicación funcional.

Servidores

Todas las instrucciones y solicitudes de los usuarios finales se encaminan a través de un único punto final en nuestro servidor. Depende de nuestro servidor analizarlas y determinar qué hacer a continuación. Los mensajes entrantes serán peticiones POST que contendrán el mensaje en sí y sus metadatos. Podemos usar Express para manejarlos, y luego reenviar tipos específicos a otros manejadores más tarde.

En primer lugar, configuraremos un servidor Express en server.jsconfigurándolo para que analice el cuerpo de las peticiones entrantes y sirva páginas estáticas. También podemos definir nuestros estados. He usado nombres de propiedades explicativos mapeados a enteros para evitar tener que hacer comparaciones de cadenas. Habrá un montón de ellas más adelante.

const fs = require('fs');
const express = require('express');
const app = express();

app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(express.static('public'));

const states = {
  waiting: 0,
  getUsername: 1,
  getEmail: 2,
  getAddress: 3,
  confirmPayment: 4
};

// CONFIGURE DATABASE

// APP CODE

const listener = app.listen(process.env.PORT, () => {
  console.log("Your app is listening on port " + listener.address().port);
});

Debido a que la API de Vonage proporciona dos webhooks, hay dos puntos finales en el servidor. Pero en este ejemplo, sólo uno hará algo realmente. Para mantener las cosas ordenadas, el punto final /status sólo reconoce las solicitudes que recibe. El endpoint /inbound es donde realmente comienza el trabajo de la aplicación.

Antes del /inbound creamos una instancia de Vonage que podemos utilizar para enviar respuestas. El ejemplo utiliza el Sandbox de la API de Messages API de Vonage, que requiere la configuración del parámetro apiHost.

// APP CODE

// this endpoint receives information about events in the app
app.post('/status', function(req, res) {
  res.status(204).end();
});

const Vonage = require('@vonage/server-sdk');
const vonage = new Vonage({
  apiKey: process.env.API_KEY,
  apiSecret: process.env.API_SECRET,
  applicationId: process.env.APP_ID,
  privateKey: __dirname + '/.data/private.key'
},{
  apiHost: 'https://messages-sandbox.nexmo.com/'
});

app.post('/inbound', function(req, res) {});
app.post('/signup', function(req, res) {});
function setUsername(phone, username) {}

Antes de desarrollar el gestor de mensajes entrantes y otras funciones, configuraremos las otras piezas que necesitamos.

Configuración de los Webhooks

Para enviar mensajes de ida y vuelta entre una cuenta de aplicación de mensajería personal y el servidor, debes configurar Messages API de Vonage. Los mensajes enviados a una cuenta propiedad de Vonage o a una que hayas registrado con una aplicación de Vonage se reenviarán al punto final que especifiques. Existen dos maneras de hacer esto, dependiendo de si usas o no Messages API Sandbox.

Si utilizas el Sandbox, no necesitarás crear una aplicación para probar la mensajería. Puedes configurar tus webhooks desde la propia página del Sandbox. Solo tienes que proporcionar un punto final para gestionar los mensajes entrantes y otro para gestionar los mensajes de estado en tu servidor de acceso público.

Specifying webhook endpoints in the Messages API Sandbox

Si posee un número para mensajería, puede configurar los webhooks dentro de su aplicación. Al crear la aplicación, desplácese hasta Capacidades y active "Mensajes". Esto revela los campos donde puede especificar los puntos finales de webhook.

Setting webhook endpoints in a Vonage application

Crear un almacén de datos

El estado que almacenes puede ser simple o complejo, dependiendo de tus necesidades. Además del estado de la interacción del servidor con un usuario determinado, es posible que quieras almacenar información que guardarías en una variable de sesión en un servidor web tradicional. Sin embargo, a veces esa información se almacena en la sesión para evitar tener que seguir buscándola en la base de datos, por lo que puede haber poco beneficio. La información adicional en tu base de datos de estados es probablemente mejor usada para contexto adicional relevante al estado actual.

En primer lugar, crearemos la base de datos propiamente dicha y, a continuación, añadiremos una tabla de estado. Este ejemplo no será complejo. En lugar de contener varias columnas para las diferentes piezas de información que un determinado estado podría necesitar, utilizaremos una columna llamada memo:

// CONFIGURE DATABASE

const dbFile = './.data/sqlite.db';
var exists = fs.existsSync(dbFile);
const sqlite3 = require('sqlite3').verbose();
const db = new sqlite3.Database(dbFile);

db.serialize(function(){
  if (!exists) {
    db.run('CREATE TABLE State (phone NUMERIC UNIQUE, state NUMERIC, memo TEXT)')
    db.run('CREATE TABLE Users (phone NUMERIC UNIQUE, username TEXT, email TEXT, address TEXT)');
  } 
});

También vamos a crear una tabla Users porque el proceso con estado en este ejemplo será el registro de usuarios.

Comprobación del Estado

Ahora, cuando recibimos un mensaje entrante, estamos preparados para comprobar si nuestro bot está en medio de un proceso de estado con el remitente. No miraremos el contenido del mensaje hasta que comprobemos la base de datos de estados y determinemos qué estado estamos esperando. Asumiendo que es posible salir de un proceso con estado, podrías elegir comprobar si el usuario quiere hacerlo antes de ir a la base de datos. Pero la mayoría de los procesos, una vez iniciados, necesitan algún tipo de limpieza si son cancelados, por lo que es igualmente probable que quieras comprobar la base de datos de todos modos.

Lo principal que necesitaremos del cuerpo de la solicitud será el número de teléfono del remitente. Podemos utilizarlo para consultar la base de datos de estados y, si encontramos que el número tiene un estado, llamar a una función a la que corresponda. Si no, podemos pasar todo el mensaje a una función parseIncoming que buscará nuevas instrucciones.

app.post('/inbound', function(req, res) {
  let phone = req.body.from.number;
  let message = req.body.message.content;
  
  db.get('SELECT * FROM State WHERE (phone = $phone)', {
    $phone: phone
  }, function(error, userState) {
    
    switch(userState.state) {
      case states.getUsername:
        setUsername(phone, message.text);
        break;
      case states.getEmail:
        setEmail(phone, message.text, true);
        break;
      case states.getAddress:
        setAddress(phone, message.text, true);
        break;
      case states.confirmPayment:
        completeBuy(phone, message.text);
        break;
      default:
        parseIncoming(phone, message);
    }
    
  });
  
  res.status(204).end();  
});

Actualización del Estado

Suponiendo que continuemos el proceso, el siguiente estado vendrá determinado por el estado actual. Inicialmente, por supuesto, no habrá ninguno. El usuario tiene que entrar en un proceso de estado de alguna manera. Para la mayoría de los estados disponibles en el ejemplo, esa forma es registrándose.

El patrón en el /signup es la mitad del que seguirán la mayoría de los demás pasos del proceso de registro. Envía un mensaje al número de teléfono que se encuentra en el cuerpo de la solicitud (en este caso, procedente de un formulario web en lugar de un mensaje), solicitando al usuario que complete el siguiente paso. A continuación, crea una nueva fila en la base de datos de estados para marcar el lugar que ocupa el usuario en el proceso. En los pasos siguientes, será una actualización:

app.post('/signup', function(req, res) {
  let phone = req.body.number;
  
  vonage.channel.send(
    { type: 'whatsapp', number: phone },
    { type: 'whatsapp', number: process.env.WHATSAPP_NUM },
    { content: {
      type: 'text',
      text: 'Welcome to Nice Cool Shoes! What should we call you?'
    }}, (e, data) => {
      if (e) {
        console.error(e);
      } else {
        db.run('INSERT INTO State (phone, state) VALUES ($phone, $state)', {
          $phone: parseInt(phone),
          $state: states.getUsername
        }, (err) => {
          if (err) {
            console.error(err);
          }
        });
      }
    }
  );
  
  res.send({});

});

La siguiente función del proceso setUsername muestra una transición de estado completa. Debido a que el paso anterior envió un prompt, se asume que el siguiente mensaje que regresa es la respuesta. Por lo tanto, el texto del mensaje se inserta en la Users como nombre de usuario del nuevo usuario. Una vez hecho esto, el resto es como el /signup punto final. El servidor envía el siguiente mensaje y actualiza la tabla State tabla:

function setUsername(phone, username) {
  
  db.run('INSERT INTO Users (phone, username) VALUES ($phone, $username)', {
    $phone: parseInt(phone),
    $username: username
  }, (err, row) => {
    if (err) {
      console.error(err);
    }
  });
  
  vonage.channel.send(
    { type: 'whatsapp', number: phone },
    { type: 'whatsapp', number: process.env.WHATSAPP_NUM },
    { content: {
      type: 'text',
      text: 'Nice to meet you, ' + username + '! What\'s your email address?'
    }}, (e, data) => {
      if (e) {
        console.error(e);
      } else {
        db.run('UPDATE State SET state = $state WHERE phone = $phone', {
          $phone: parseInt(phone),
          $state: states.getEmail
        }, (err, row) => {
          if (err) {
            console.error(err);
          }
        });
      }
    }
  );  
  
}

Próximos pasos

Si tu bot está principalmente respondiendo preguntas, tu necesidad de una máquina de estados puede ser limitada y codificar algunas funciones puede ser lo más sensato. Pero te habrás dado cuenta de que los flujos de trabajo como el registro de usuario del ejemplo utilizan información ligeramente diferente para las mismas tareas en cada paso. Abstrayendo los elementos comunes en una única función y proporcionando un conjunto más detallado de estados, puede hacer que el servidor mueva el proceso de una forma menos manual. Para nuestro ejemplo, una definición ampliada de un estado podría incluir:

  • nombre de la columna a actualizar

  • siguiente mensaje

  • siguiente estado

Podrías añadir más información, como nombres de tablas, para hacer que maneje estados en múltiples procesos diferentes.

Hay todo tipo de formas interesantes de estructurar un bot de mensajería. Consulta la documentación de Messages API de Vonage para obtener más información sobre las funciones y los casos de uso que pueden ser útiles para tu proyecto.

Compartir:

https://a.storyblok.com/f/270183/250x250/f231d97f1b/garann-means.png
Garann MeansDesarrollador Educador

Soy desarrollador de JavaScript y educador de desarrolladores en Vonage. A lo largo de los años me han entusiasmado las plantillas, Node.js, las aplicaciones web progresivas y las estrategias offline-first, pero lo que siempre me ha encantado es una API útil y bien documentada. Mi objetivo es hacer que tu experiencia usando nuestras APIs sea la mejor posible.