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

Node.jsによるWhatsAppメッセージングボット用ステートマシン

最終更新日 August 23, 2021

所要時間:1 分

一般的なウェブ・サーバーでは、ステートについてあまり考える必要はない。ユーザーがリクエストを送り、ユーザーがレスポンスを返す。エンドユーザーがそうするのだ。しかし、ボットは違う。

エンドユーザーがボットとの会話を開始しても、ボットはそこからパスを定義し、ユーザーに質問をして次のステップの可能性を知らせる必要があります。ボットが単に質問に答えるだけでなく、エンドユーザーに一連のステップを歩ませる場合、それがステートマシンと呼ばれるものです。

ステートマシンをメッセージングボットとして実装するのは少し厄介だ。デフォルトでは、あなたがコントロールするサーバーに送信されたメッセージは、セッションやステート、あるいは個々のメッセージが属するかもしれない全体像に関するその他の情報を持たずに送られてきます。しかし、本当に意味することは、あなたのサーバーと与えられた電話番号の間の「セッション」の最後の状態を手動で保存する必要があるということです。実際には、ウェブアプリケーションも同じことをしなければならない。プラットフォームやライブラリは、日常的に私たちのために仕事をしてくれているだけなのだ。

前提条件

私たちのボット・サーバーは、従来のウェブ・サーバーのひとつを、従来とは異なる方法で使用します。この例に従うには、以下のものが必要です:

これから見ていくコードは、より大きな WhatsAppボットのサンプルプロジェクトの一部です。ここからコピー&ペーストすることもできますし、リミックスして機能的なアプリを作ることもできます。

サーバー・エンドポイント

エンドユーザーからのすべての指示やリクエストは、私たちのサーバー上のただひとつのエンドポイントを経由します。それらを解析し、次に何をすべきかを決定するのは私たちのサーバー次第です。着信メッセージは、メッセージそのものとそのメタデータを含むPOSTリクエストになる。Expressを使ってそれらを処理し、特定のタイプを後で他のハンドラに転送することができる。

まず、Expressサーバーを server.jsでExpressサーバーを立ち上げ、送られてくるリクエストのボディを解析し、静的ページをサーバーするように設定します。また、ステートを定義することもできる。文字列の比較をしなくてすむように、プロパティ名を整数にマッピングして説明しました。後でたくさん出てくるだろう!

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

Vonage APIは2つのウェブフックを提供しているので、サーバーには2つのエンドポイントがある。しかしこの例では、実際に何かをするのは1つだけだ。物事を整理整頓するために /statusエンドポイントは受け取ったリクエストを確認するだけです。エンドポイントは /inboundエンドポイントからアプリの作業が始まります。

エンドポイントの前に /inboundエンドポイントの前に、返信を送信するための Vonage インスタンスを作成します。この例では Vonage Message API Sandbox を使っています。 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) {}

インバウンド・メッセージ・ハンドラーやその他の機能を詳しく説明する前に、必要な他の部分を設定します。

ウェブフックの設定

個人用メッセージングアプリのアカウントとサーバー間でメッセージをやり取りするには、Vonage Messages APIを設定する必要があります。Vonage所有のアカウントまたはVonageアプリケーションに登録したアカウントに送信されたメッセージは、指定したエンドポイントに転送されます。これを行うには、Messages API Sandboxを使用しているかどうかに応じて2つの方法があります。

サンドボックスを使用している場合、メッセージングを試すためにアプリケーションを作成する必要はありません。サンドボックスのページからウェブフックを設定することができます。受信メッセージを処理するエンドポイントと、一般にアクセス可能なサーバー上のステータスメッセージを処理するエンドポイントを提供するだけです。

Specifying webhook endpoints in the Messages API Sandbox

メッセージング用の番号を所有している場合、アプリケーション内でウェブフックを設定することができます。アプリケーションを作成する際、Capabilitiesまでスクロールダウンし、"Messages "をオンに切り替えます。これにより、ウェブフックのエンドポイントを指定するフィールドが表示されます。

Setting webhook endpoints in a Vonage application

データストアの設定

保存する状態は、必要に応じて単純なものにも複雑なものにもできます。あるユーザーとサーバーがやりとりする状態に加えて、 従来のウェブサーバーではセッション変数に保持するような情報を 格納したいと思うかもしれません。しかし、そのような情報は、データベースで検索し続けることを避けるために、セッションに格納されることもあります。状態データベースの追加情報は、おそらく現在の状態に関連する追加コンテキストに使用するのが最善でしょう。

まず、データベース自体を作成し、ステート・テーブルを追加します。この例は複雑ではありません。特定の状態が必要とする可能性のあるさまざまな情報のために複数のカラムを含む代わりに、次のようなキャッチオール・カラムを使用します。 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)');
  } 
});

また Usersテーブルも作成しています。この例ではステートフルな処理はユーザー登録になるからです。

国家のチェック

これで、インバウンドメッセージを受信したときに、ボットが送信者とのステートフルなプロセスの途中であるかどうかをチェックする準備が整いました。ステートデータベースをチェックし、どのような状態を期待しているかを判断するまでは、メッセージの内容は見ません。ステートフルなプロセスを終了することが可能であると仮定すると、データベースにアクセスする前に、ユーザーがそれを望んでいるかどうかをチェックすることを選ぶかもしれません。しかし、ほとんどのプロセスは、いったん開始されると、キャンセルされた場合、何らかのクリーンアップが必要である。

リクエストボディから必要な主なものは、送信者の電話番号である。それを使ってステートデータベースに問い合わせ、その番号がステートを持つことがわかれば、それに対応する関数を呼び出すことができる。そうでない場合は、メッセージ全体を parseIncoming関数に渡すことができます。

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

状態の更新

このプロセスを続けると仮定すると、次の状態は現在の状態によって決まる。もちろん、最初はそんなものはない。ユーザーは何らかの方法でステートフルなプロセスに入らなければならない。この例で利用可能なほとんどの状態について、その方法はサインアップすることである。

エンドポイントのパターンは /signupエンドポイントのパターンは、サインアッププロセスの他のほとんどのステップの半分です。リクエストボディにある電話番号にメッセージを送信し(この場合、メッセージの代わりにWebフォームから送信されます)、次のステップを完了するようユーザに促します。そして、新しいステートデータベースの行を作成し、ユーザがプロセスのどこにいるのかをマークする。以降のステップでは、これが更新となります:

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

});

プロセスの次の関数、 setUsernameは完全な状態遷移を示している。前のステップではプロンプトを送信したため、次に返ってくるメッセージはレスポンスであると想定される。したがって、メッセージテキストは Usersテーブルに挿入されます。これが終われば、あとは /signupエンドポイントと同じです。サーバは次のプロンプトを送信し Stateテーブルを更新します:

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

次のステップ

ボットが主に質問に答えるものである場合、ステートマシンの必要性は限定的であり、いくつかの関数をハードコーディングすることが最も賢明なことかもしれません。しかし、例のユーザー登録のようなワークフローでは、各ステップで同じタスクに対してわずかに異なる情報を使用していることに気づいたかもしれません。共通の要素を1つの関数に抽象化し、より詳細な状態の配列を提供することで、手作業が少ない方法でサーバーにプロセスを進ませることができます。この例の場合、状態の拡張定義には以下のようなものがあります:

  • 更新するカラム名

  • ネクストプロンプト

  • 次の状態

テーブル名のような情報を追加して、複数の異なるプロセスの状態を扱えるようにすることもできる。

メッセージングボットを構成するには、あらゆる種類の興味深い方法があります。詳しくは Vonage Messages API ドキュメントを参照してください。

シェア:

https://a.storyblok.com/f/270183/250x250/f231d97f1b/garann-means.png
Garann Meansデベロッパー・エデュケーター

私はJavaScript開発者で、Vonageの開発者教育者です。長年にわたり、テンプレート、Node.js、プログレッシブ・ウェブ・アプリケーション、そしてオフライン・ファースト戦略に熱中してきましたが、私がいつも本当に愛しているのは、便利できちんと文書化されたAPIです。私の目標は、当社のAPIを使用するお客様の体験を、私がお手伝いできる最高のものにすることです。