https://a.storyblok.com/f/270183/72974/d5eb43d133/state-machine_1200x600-1.png

Máquinas de estados para bots de mensagens do WhatsApp com Node.js

Publicado em August 23, 2021

Tempo de leitura: 6 minutos

Em um servidor web comum, não é preciso se preocupar muito com o estado. Um usuário envia uma solicitação e você fornece uma resposta. Não é necessário que o aplicativo se oriente por um caminho de escolhas e ações; quem faz isso é o usuário final. No entanto, um bot funciona de maneira diferente.

Embora seja o usuário final quem inicie uma conversa com um bot, cabe ao bot definir o caminho a partir daí, fazendo perguntas ao usuário para informá-lo sobre os possíveis próximos passos. Quando o bot não está apenas respondendo a perguntas, mas sim guiando o usuário final por uma série de etapas, isso é o que se conhece como máquina de estados.

Implementar uma máquina de estados como um bot de mensagens é um pouco complicado, pois os bots de mensagens não possuem, por natureza, nenhum conceito de estado. Por padrão, uma mensagem enviada a um servidor sob seu controle chega sem sessão, sem estado e sem qualquer outra informação sobre o contexto mais amplo do qual a mensagem individual possa fazer parte. Mas, na verdade, tudo isso significa apenas que você precisará armazenar manualmente o último estado de uma “sessão” entre o seu servidor e um determinado número de telefone. Na prática, um aplicativo web precisa fazer exatamente a mesma coisa. As plataformas e bibliotecas simplesmente realizam esse trabalho para nós de forma automática.

Pré-requisitos

Nosso servidor de bots utilizará um desses servidores web tradicionais de uma maneira não tradicional. Para acompanhar este exemplo, você precisará de:

O código que vamos analisar faz parte de um projeto maior projeto de exemplo de bot do WhatsApp no Glitch. Você também pode copiar e colar de lá ou fazer um remix para começar com um aplicativo funcional.

Pontos de extremidade do servidor

Todas as instruções e solicitações dos usuários finais são encaminhadas por meio de um único ponto de extremidade em nosso servidor. Cabe ao nosso servidor analisá-las e determinar o que fazer a seguir. As mensagens recebidas serão solicitações POST contendo a própria mensagem e seus metadados. Podemos usar o Express para processá-las e, posteriormente, encaminhar tipos específicos para outros manipuladores.

Primeiro, vamos configurar um servidor Express em server.js, configurando-o para analisar o corpo das solicitações recebidas e servir páginas estáticas. Também podemos definir nossos estados. Usei nomes de propriedades explicativos mapeados para números inteiros para evitar ter que fazer comparações de strings. Haverá muitas delas mais adiante!

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);
});

Como a API da Vonage oferece dois webhooks, há dois endpoints no servidor. Mas, neste exemplo, apenas um deles realmente executará alguma ação. Para manter tudo organizado, o /status ponto de extremidade apenas confirma o recebimento de quaisquer solicitações que receba. O /inbound ponto de extremidade é onde o trabalho do aplicativo realmente começa.

Antes do /inbound endpoint, criamos uma instância do Vonage que podemos usar para enviar respostas. O exemplo utiliza o Sandbox da Messages API do Vonage, o que requer a configuração do 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 desenvolver o manipulador de mensagens recebidas e outras funções, vamos configurar os demais componentes necessários.

Configurando os webhooks

Para enviar e receber mensagens entre uma conta de aplicativo de mensagens pessoais e o servidor, é necessário configurar a Messages API do Vonage. As mensagens enviadas para uma conta de propriedade da Vonage ou para uma conta que você tenha registrado em um aplicativo da Vonage serão encaminhadas para o endpoint que você especificar. Existem duas maneiras de fazer isso, dependendo se você está ou não usando o Sandbox da Messages API.

Se você estiver usando o Sandbox, não precisará criar uma aplicação para testar o sistema de mensagens. Você pode configurar seus webhooks diretamente na página do Sandbox. Basta fornecer um endpoint para lidar com as mensagens recebidas e outro para lidar com as mensagens de status em seu servidor acessível ao público.

Specifying webhook endpoints in the Messages API Sandbox

Se você possui um número para envio de mensagens, pode configurar os webhooks em seu aplicativo. Ao criar o aplicativo, role a página até a seção “Recursos” e ative a opção “Mensagens”. Isso exibirá os campos nos quais você pode especificar os endpoints dos webhooks.

Setting webhook endpoints in a Vonage application

Configurando um repositório de dados

O estado que você armazena pode ser simples ou complexo, dependendo das suas necessidades. Além do estado da interação do servidor com um determinado usuário, talvez você queira armazenar informações que, em um servidor web tradicional, seriam mantidas em uma variável de sessão. No entanto, às vezes essas informações são armazenadas na sessão para evitar ter que consultá-las constantemente no banco de dados, portanto, pode haver poucos benefícios nisso. Informações adicionais em seu banco de dados de estado provavelmente devem ser usadas para fornecer contexto adicional relevante ao estado atual.

Primeiro, vamos criar o próprio banco de dados e, em seguida, adicionar uma tabela de estados. Este exemplo não será complexo. Em vez de incluir várias colunas para diferentes tipos de informações que um determinado estado possa vir a precisar, vamos simplesmente usar uma coluna genérica chamada 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)');
  } 
});

Também estamos criando uma Users tabela, pois o processo com estado neste exemplo será o cadastro de usuários.

Verificando o estado

Agora, quando recebemos uma mensagem, estamos preparados para verificar se nosso bot está no meio de um processo com estado envolvendo o remetente. Não analisaremos o conteúdo da mensagem até verificarmos o banco de dados de estado e determinarmos qual estado estamos esperando. Supondo que seja possível sair de um processo com estado, você pode optar por verificar se o usuário deseja fazer isso antes de consultar o banco de dados. Mas a maioria dos processos, uma vez iniciados, precisa de alguma limpeza caso sejam cancelados; portanto, é igualmente provável que você queira consultar o banco de dados de qualquer maneira.

O principal dado de que precisamos do corpo da solicitação será o número de telefone do remetente. Podemos usar esse número para consultar o banco de dados de estados e, se constatarmos que o número está associado a um estado, chamar a função correspondente. Caso contrário, podemos passar a mensagem inteira para uma parseIncoming função que irá procurar novas instruções.

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();  
});

Atualização do estado

Supondo que continuemos com o processo, o próximo estado será determinado pelo estado atual. Inicialmente, é claro, não haverá nenhum. O usuário precisa, de alguma forma, entrar em um processo com estado. Para a maioria dos estados disponíveis no exemplo, essa forma é por meio do cadastro.

O padrão no /signup ponto de extremidade é metade do que a maioria das outras etapas do processo de cadastro seguirá. Ele envia uma mensagem para o número de telefone encontrado no corpo da solicitação (neste caso, proveniente de um formulário da web, em vez de uma mensagem), solicitando que o usuário conclua a próxima etapa. Em seguida, cria uma nova linha no banco de dados de estado para marcar a posição do usuário no processo. Nas etapas subsequentes, isso será uma atualização:

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({});

});

A próxima função no processo, setUsername mostra uma transição de estado completa. Como a etapa anterior enviou um prompt, presume-se que a próxima mensagem recebida seja a resposta. Portanto, o texto da mensagem é inserido na Users tabela como o nome de usuário do novo usuário. Feito isso, o restante funciona como o /signup ponto de extremidade. O servidor envia o próximo prompt e atualiza a State tabela:

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 passos

Se o seu bot se dedica principalmente a responder perguntas, a necessidade de uma máquina de estados pode ser limitada, e codificar algumas funções diretamente pode ser a opção mais sensata. Mas você deve ter percebido que fluxos de trabalho, como o cadastro de usuários no exemplo, utilizam informações ligeiramente diferentes para as mesmas tarefas em cada etapa. Ao abstrair os elementos comuns em uma única função e fornecer uma matriz mais detalhada de estados, você pode fazer com que o servidor conduza o processo de maneira menos manual. Para o nosso exemplo, uma definição ampliada de um estado poderia incluir:

  • nome da coluna a ser atualizada

  • próximo prompt

  • próximo estado

Você poderia adicionar mais informações, como nomes de tabelas, para permitir que o sistema lide com estados em vários processos diferentes.

Existem várias maneiras interessantes de estruturar um bot de mensagens. Confira a documentação da Messages API do Vonage para saber mais sobre os recursos e casos de uso que podem ser úteis para o seu projeto.

Compartilhar:

https://a.storyblok.com/f/270183/250x250/f231d97f1b/garann-means.png
Garann MeansFormador de Desenvolvedores

Sou desenvolvedor de JavaScript e instrutor de desenvolvimento na Vonage. Ao longo dos anos, tenho me interessado muito por modelos, Node.js, aplicativos web progressivos e estratégias “offline-first”, mas o que sempre adorei de verdade é uma API útil e bem documentada. Meu objetivo é tornar a sua experiência com nossas APIs a melhor possível.