https://d226lax1qjow5r.cloudfront.net/blog/blogposts/build-a-whatsapp-and-messenger-graphql-bot-to-find-hospital-beds/whatsapp_messenger_graphql_1200x600.png

病院のベッドを検索するWhatsAppとMessengerのGraphQLボットを構築する

最終更新日 March 25, 2021

所要時間:4 分

COVIDは私たち全員に影響を与えたが、他の人たち以上に影響を与えた人もいる。人々が直面し、現在も直面し続けている大きな問題のひとつは、病院のベッド不足である。数ヶ月前、私は GraphQLAPIを作った。を作成した。データは公式ウェブサイトからスクレイピングされ、統一されたフォーマットに正規化された後、GraphQL APIの形で公開される。

なお、このデータはインドの一部の都市でのみ入手可能である。

また GraphQLプレイグラウンド.

Bedav API と Vonage Messages API を使って、JavaScript を使って WhatsApp と Facebook Messenger で動作するチャットボットを作成します。このチャットボットの機能は、ユーザーが都市で病院を検索し、空床がある病院を見つけ、病院の電話番号や住所などの追加情報を提供することです。また、ユーザーが病院までの道順を知ることができるコマンドも作成します。

前提条件

  1. ノードとnpm

  2. Vonageアカウント

    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.

プロジェクト設定

このコードは GitHub リポジトリにありますが、このチュートリアルにしたがって、リポジトリは参考程度に使うことをお勧めします。

NPMの初期化と依存関係のインストール

新しいフォルダを作成し、npmを初期化して、その中に srcフォルダを作成します:

mkdir chatbot && cd chatbot mkdir src npm init -y

Expressを使ってウェブフックとサーバーを作成します。 AxiosはMessages APIへのリクエスト送信に使用し graphql-requestはBedav GraphQL APIへのリクエスト送信に使用します。以下のコマンドを実行することで、追加ユーティリティとともにこれらをインストールできる:

npm i express axios graphql-request js-base64 outdent dotenv

そして最後に、Nodemonをdev依存としてインストールし、変更を加えるたびにnodeサーバーを再起動する必要がないようにする:

npm i -D nodemon

また、スクリプトを package.jsonにスクリプトを追加しましょう:

// package.json

{
  "scripts": {
    "dev": "nodemon src/app.js"
  }
}

ウェブフックの作成

コードに入る前に、ウェブフックとは何か?Webhookは基本的に、特定のアクションやイベントが実行されるたびに呼び出されるURLである。Webhookはコールバックと似ていると言えますが、独立したアプリケーション間でやりとりするために使われることがほとんどです。私たちのケースでは、2つのWebhookを公開する必要があります。1つはユーザーがメッセージを送信したときにトリガーされるもので、もう1つはAPIからメッセージのステータスが更新されたとき(例えば、ユーザーが送信したメッセージを読んだとき)です。

まず、Expressを src/app.jsここにメインのExpressアプリケーションと、コマンドを処理する主要な関数がある:

// src/app.js

const express = require("express")

const app = express()
app.use(express.json())

次に、2つのエンドポイントを追加しましょう。1つはメッセージウェブフック用、もう1つはステータスウェブフック用です:

// src/app.js

const { fixedMessages, sendMessage } = require("./utils")

app.post("/webhooks/inbound", (request, response) => {
  console.log(request.body)
)

app.post("/webhooks/status", (request, response) => {
  console.log(request.body)
  response.status(200).end()
})

app.listen(3000, () => {
  console.log("Listening on port 3000")
})

Ngrokのインストールとセットアップ

Ngrokを使えば、一時的なURLを使ってウェブフックをインターネットに公開し、テストすることができる。ngrokを使い始めるには、彼らのサイトに行き、アカウントを作成する。 アカウントを作成する.ログインすると、ダッシュボードにアカウントをインストールして接続する手順が表示されます。

ngrokをインストールして設定したので、開発サーバーを起動しよう。 npm run dev.ngrokを使ってローカルサーバーの公開URLを作成します。 ./ngrok http 3000.このコマンドは、ngrokにHTTPプロキシを作成するよう指示します。 localhost:3000.

メッセージ・サンドボックスの設定

Vonage Dashboardにログインし、次の場所に移動します。 サンドボックスセクションに移動します。 メッセージとディスパッチ.

Vonage Dashboard Menu Bar with the Sandbox section highlighted

WhatsAppサンドボックスを作成するには サンドボックスに追加をクリック。Vonage Sandboxは本人確認を必要としますので、ダッシュボードの手順に従って下さい。同様の手順でFacebook Messenger用サンドボックスも作成できます。

これでWhatsAppサンドボックスの有効化は完了です!しかしまだ終わっていません。Webhooksが存在するURLをVonageに提供する必要があります。サンドボックスページを下にスクロールすると、下記のようなWebhooksセクションが表示されます:

Webhooks section on the Sandbox page of the Vonage Dashboard

最初は inboundルートにあるウェブフックです。 webhooks/inboundになるので、そのフィールドを https://<your-ngrok-https-url>/webhooks/inbound.同様に statusのURLを https://<your-ngrok-https-url>/webhooks/status.これでサンドボックスとテスト環境の設定は完了だ!

注:ngrokを再起動するたびに、新しい別のURLが提供されますので、Vonageダッシュボードで手動で変更する必要があります。

ユーザーにメッセージを返す

ユーザーにメッセージを送り返すヘルパー関数が必要だ。

まず、Messages APIを利用できるように、Vonage APIの認証情報を設定する必要がある。これらの認証情報は環境変数に格納される。フォルダのルートに .envファイルを作成します。環境変数をノード環境にロードするには dotenvパッケージを使用します。以下のようにフィールドを追加します:

VONAGE_API_KEY = // Your Vonage API key
VONAGE_API_SECRET = // Your Vonage API Secret
WHATSAPP_NUMBER = // The from number in the case of the WhatsApp Sandbox
MESSENGER_ID = // The from ID of the Messenger Sandbox

それが終わったら、次のコードを app.jsを追加して環境変数を追加する:

// src/app.js
require("dotenv").config()

という名前の新しいファイルを utils.jsという名前の新しいファイルを srcフォルダに作成します。このファイルには、書式設定やユーザーへのメッセージ送信などのユーティリティ機能が含まれます。

メッセージを送信するために、Messages REST APIにリクエストをします。Messages Sandboxの場合、APIのURLは次のようになる。 https://messages-sandbox.nexmo.com/v0.1/messages.リクエストの送信にはAxiosを使います。リクエストの送信には authオプションを使用して、以下のようにAPI認証情報を追加します:

// src/utils.js

const axios = require("axios")

// default message type will be `text`
const sendMessage = async (to, message, type = "text") => {  let from = {
    // either 'WhatsApp' or 'messenger'
    type: to.type,
  }

  if (to.type === "whatsapp") {
    // the WhatsApp Sandbox number    
    from.number = proccess.env.WHATSAPP_NUMBER
  } else if (to.type === "messenger") {
    // the Messenger Sandbox ID
    from.id = process.env.MESSENGER_ID
  }

  await axios.post(
    "https://messages-sandbox.nexmo.com/v0.1/messages",
    {
      to,
      from,
      message: {
        content: {
          type,
          [type]: message,
        },
      },
    },
    {
      auth: {
        username: process.env.VONAGE_API_KEY,
        password: process.env.VONAGE_API_SECRET,
      },
    }
  )
}

module.exports = {
  sendMessage,
}

メッセージ・タイプは動的である。 locationタイプのメッセージを送信します。WhatsAppでは locationのメッセージにはGoogleマップ上の場所のサムネイルと名前、住所が添付されます。

コマンドの作成

これでコマンドの作成に取りかかることができる:

  1. help- 使用可能なすべてのコマンドのメニューを取得する

  2. cities- 利用可能な全都市のリストを入手

  3. search <hospital-name> in <location>- 特定の場所の病院を検索します。例えば search sakra in bangaloreはバンガロールのサクラという名前の病院を検索します。

  4. get directions to <hospital-id>- 特定のIDを持つ病院への道順を知る。例えば get directions to 87はID87の病院の場所を送信します。

受信メッセージハンドラ

へのリクエストを処理する関数を作りましょう。 inboundへのリクエストを処理する関数を作りましょう。 /webhooks/inbound.この関数は、ユーザーから送られたメッセージを解析し、ユーザーが使おうとしているコマンドのハンドラにメッセージを渡します。適切なメッセージを送り返し、ステータスコード200を返します。

さもなければ、Messages APIは200のレスポンスを得るまでリクエストを送り続ける。

正規表現を使って、ユーザーが searchまたは directionsコマンドを使おうとしているかどうかを正規表現を使ってチェックし、そのメッセージを適切なハンドラに渡す。メッセージが help, hiまたは helloであれば、ヘルプ・メッセージがユーザーに送られる。メッセージが citiesであれば、利用可能な都市のリストがユーザーに送られる。最後に、メッセージがどのコマンドにもマッチしない場合、無効なメッセージがユーザーに送られる。

// src/app.js

const handleInbound = async (request, response) => {
  const content = request.body.message.content

  const text = content.text.toLowerCase().trim()

  // whom we have to reply to
  const to = request.body.from

  const searchRegex = /^search .* in .*$/i
  const directionsRegex = /^get directions to .*/i

  if (text.match(searchRegex)) {
    handleSearch(text, to)
  } else if (text.match(directionsRegex)) {
    handleDirections(text, to)
  } else if (["help", "hi", "hello"].includes(text)) {
    sendMessage(to, fixedMessages.help)
  } else if (text === "cities") {
    sendMessage(to, fixedMessages.cities)
  } else {
    sendMessage(to, `Sorry, invalid message. Type *help* to get a list of all commands.`)
  }

  response.status(200).end()
}

では、受信ウェブフック・エンドポイント・ハンドラーを修正して handleInbound関数を使うように修正しましょう。

// src/app.js
app.post("/webhooks/inbound", handleInbound)

ヘルプとようこそコマンド

ユーザーが最初に私たちのサービスにメッセージを送るとき、私たちは彼らに挨拶するメッセージが必要です。このメッセージには、私たちのボットが何をするのか、どのようなコマンドを使用できるのかといった情報を含める必要があります。また、ユーザーが何をすればいいのかわからず、次のように入力したときに送信されるヘルプメッセージも必要です。 help.これらのメッセージは似たようなものになります。

Bedav APIは特定の地域の病院情報しか持っていません。情報が利用可能な地域のリストを与えるメッセージも必要だ。

で実装されているように handleInbound関数で実装されているように、ヘルプメッセージはユーザーが ハイ, こんにちはまたは ヘルプと入力すると、利用可能な都市に関するメッセージが送信されます。 都市.

これらのメッセージはいずれも定数であるため、トップ・レベルに定数オブジェクトを作成し、すべての固定メッセージを定義することができる。また outdentを使って文字列の余分なスペースを削除する。コードを utils.js:

// src/utils.js

const outdent = require("outdent")

const fixedMessages = {
  help: outdent`
    The Bedav Bot gives you information on the availability of beds in hospitals and the contact information and location of those hospitals as well.
    
    You can use the following commands:
    1. *help* - Get this menu and all the commands you can use
    2. *cities* - Get a list of all the cities available
    2. *search* _<hospital-name>_ *in* _<location>_ - Search for a hospital in a particular location. For example, "search sakra in bangalore" searches for hospitals with the name Sakra in Bangalore
    3. *get directions to* _<hospital-id>_ - Get directions to a hospital with a particular ID. You can get the hospital ID from the search results. The serial number preceding the Hospital name is the Hospital ID. For example, if the search result has _(87) Sakra Hospital_, send _get directions to 87_ to get directions to Sakra Hospital.
  `,
  cities: outdent`
    The cities/districts currently available are:

    *Karnataka*
      1. Bangalore/Bengaluru

    *Maharashtra*
      2. Pune
      3. Kohlapur
      4. Sangli
      5. Satara
      6. Solapur

    *Andhra Pradesh*
      7. Anantapur
      8. Chittoor
      9. East Godavari
      10. Guntur
      11. Krishna
      12. Kurnool
      13. Prakasam
      14. Nellore
      15. Srikakulam
      16. Vishakapatanam
      17. Vizianagaram
      18. West Godavari
      19. Kadapa
  `,
}

module.exports = {
  ...
  fixedMessages,
}

GraphQLクライアントのセットアップ

GraphQL APIへのクエリーを簡単にするため、ここでは graphql-requestライブラリを使用します。GraphQLクライアントをセットアップするには、以下のインスタンスを作成します。 GraphQLClientのインスタンスを作成し、GraphQL APIのURLを指定します。

// app.js

const { GraphQLClient } = require("graphql-request")
const client = new GraphQLClient("https://bedav.org/graphql")

ユーティリティ関数の作成

APIドキュメントの hospitalIdフィールドの説明にあるように、これはBase64でエンコードされた文字列である。 Hospital:<hospitalId>ここで hospitalIdは病院固有の整数である。次のセクションで説明する理由から、2つのユーティリティ関数を作成しましょう。1つは病院IDの番号を取得するもので、もう1つは整数をBase64文字列にエンコードするものです。 Hospital:<hospitalId>.先ほどプロジェクトに追加した js-base64ライブラリを使うことにする。 base64エンコードされた文字列を使用する。

// src/utils.js

const { encode, decode } = require("js-base64")

const getHospitalId = (encodedId) => {
  return decode(encodedId).slice(9)
}

const getEncodedString = (hospitalId) => {
  return encode(`Hospital:${hospitalId}`)
}

module.exports = {
  ...
  getEncodedString,
}

検索コマンド

文字列をフォーマットするユーティリティ関数

個々の病院をフォーマットする関数と、その関数を使用して病院グループをフォーマットする関数です。

// src/utils.js

const getFormattedHospital = (hospital) => {
  const index = getHospitalId(hospital.id)

  const roundedString = (occupied, total) => {
    return `${Math.floor((occupied * 100) / total)}% Occupied`
  }

  const h = hospital

  // Percentages of beds available
  const percentages = {
    icu: roundedString(h.icuOccupied, h.icuTotal),
    hdu: roundedString(h.hduOccupied, h.icuTotal),
    oxygen: roundedString(h.oxygenOccupied, h.icuTotal),
    general: roundedString(h.generalOccupied, h.icuTotal),
    ventilators: roundedString(h.ventilatorsOccupied, h.icuTotal),
  }

  const formatted = outdent`
    *(${index}) ${hospital.name}*
      ${h.icuTotal !== 0 && h.icuAvailable !== null ? `_ICU Available_: ${h.icuAvailable} (${percentages.icu})` : ""}
      ${h.hduTotal !== 0 && h.icuAvailable !== null ? `_HDU Avalable_: ${h.hduAvailable} (${percentages.hdu})` : ""}
      ${h.oxygenTotal !== 0 && h.oxygenAvailable !== null ? `_Oxygen Available_: ${h.oxygenAvailable} (${percentages.oxygen}})` : ""}
      ${h.generalTotal !== 0 && h.generalAvailable !== null ? `_General Available_: ${h.generalAvailable} (${percentages.general})` : ""}
      ${
        h.ventilatorsTotal !== 0 && h.ventilatorsAvailable !== null
          ? `_Ventilators Available_: ${h.ventilatorsAvailable} (${percentages.ventilators})`
          : ""
      }
      ${h.phone !== null ? `_Phone_: ${h.phone}` : ""}
      ${h.phone !== null ? `_Website_: ${h.website}` : ""}
  `

  return removeEmptyLines(formatted)
}

すべての病院にICU、HDU、一般病棟のベッドがあるとは限りませんし、すべての病院に酸素吸入器や人工呼吸器があるとは限りません。もしその病院に利用可能なベッドがない場合は、メッセージからその情報を省略することができます。利用可能な合計が0であるか、利用可能フィールドの値がNULLである場合、病院は特定の種類のベッド、酸素または人工呼吸器を利用できません。

また、get directionsコマンドでユーザーが病院のIDを入力する必要があるため、病院のIDも表示しています。また、括弧の中にベッドの占有率を表示しています。

ベッド、人工呼吸器、酸素が利用できない場合は、空行が残る。これを解決するために、この空白行を削除する別のヘルパー関数を作ります。

空行をチェックするには、その行にスペースが1文字以上あるかどうかをチェックする短い正規表現を使う:

// src/utils.js

const removeEmptyLines = (string) => {
  const lines = string.split("\n")
  const newLines = []

  for (const line of lines) {
    // Continue if the line is a blank line
    if (line.match(/^\s*$/)) continue
    newLines.push(line)
  }

  return newLines.join("\n")
}

次に、すべての病院をループし、その病院のフォーマットされた文字列を取得し、各病院の間に空行を追加することで、病院のリストをフォーマットする関数を作成します:

// src/utils.js

const getFormattedHospitals = (hospitals) => {
  let message = ""

  for (const hospital of hospitals) {
    const formattedHospital = getFormattedHospital(hospital)
    message += formattedHospital + "\n\n"
  }

  return message
}

module.exports = {
  ...
  getFormattedHospitals,
}

コマンド・ハンドラ

すべてのGraphQLクエリは queries.jsファイルに格納されます。 srcフォルダに保存されます。

以下のGraphQLクエリを使用して、特定の場所にある病院を検索し、必要なデータを取得します。locityフィールドの nameフィールドの引数は、どの場所のデータを照会しているかをAPIに伝えるために使用します。フィールドの firstフィールドの hospitalsフィールドの引数は、APIが返したい病院の数を指定します。 searchQuery引数は検索クエリを提供する。

// src/queries.js

const searchGraphQLQuery = gql`
  query($location: String, $query: String) {
    # get hospitals from a city named Bengaluru in the state of Karnataka
    locality(name: $location) {
      hospitals(first: 5, searchQuery: $query) {
        edges {
          node {
            id
            name
            phone
            website
            address
            latitude
            longitude

            icuAvailable
            hduAvailable
            oxygenAvailable
            generalAvailable
            ventilatorsAvailable

            icuOccupied
            hduOccupied
            oxygenOccupied
            generalOccupied
            ventilatorsOccupied

            icuTotal
            hduTotal
            oxygenTotal
            generalTotal
            ventilatorsTotal
          }
        }
      }
    }
  }
`

module.exports = {
  searchGraphQLQuery,
}
`

GraphQLクエリを gqlタグで囲む必要はない。しかし、適切な拡張機能とツールを使えば、シンタックスハイライトとタイプチェックを行うことができる。

次に、ロケーション名を nameフィールドの localityフィールドの引数にマッピングする必要がある。 <city/district_name>-<state_name>. のマッピングcity/districtへのマッピングは nameのマッピングはJavaScriptで以下のオブジェクトに変換できます:

// src/utils.js

const locationKey = {
  bangalore: "bengaluru-karnataka",
  bengaluru: "bengaluru-karnataka",
  pune: "pune-maharashtra",
  kohlapur: "kohlapur-maharashtra",
  sangli: "sangli-maharashtra",
  satara: "satara-maharashtra",
  solapur: "solapur-maharashtra",
  anantapur: "anantapur-andhra pradesh",
  chittoor: "chittoor-andhra pradesh",
  "east godavari": "east godavari-andhra pradesh",
  guntur: "guntur-andhra pradesh",
  krishna: "krishna-andhra pradesh",
  kurnool: "kurnool-andhra pradesh",
  prakasam: "prakasam-andhra pradesh",
  nellore: "spsr nellore-andhra pradesh",
  srikakulam: "srikakulam-andhra pradesh",
  vishakapatanam: "vishakapatanam-andhra pradesh",
  vizianagaram: "vizianagaram-andhra pradesh",
  "west godavari": "west godavari-andhra pradesh",
  kadapa: "kadapa-andhra pradesh",
}

module.exports = {
  ...
  locationKey,
}

ある場所の病院を検索するためにユーザーが入力するテキストは、次のような形式になる。 search <search-query> in <location>.では handleSearch関数を作成しましょう。この関数は、検索クエリを実行し、適切な応答をユーザーに送り返す役割を果たします:

// src/app.js

const { fixedMessages, sendMessage, locationKey, getFormattedHospitals } = require("./utils")

const handleSearch = async (message, to) => {
  // extract the search query and location from the message
  const searchRegex = /^search (?<searchQuery>[a-zA-Z0-9_ ]+) in (?<location>[a-zA-Z0-9_ ]+)$/i
  const match = searchRegex.exec(message)

  if (match === null) {
    sendMessage(to, "Please enter the hospital name you want to search for and the location")
    return
  }

  const { groups: { searchQuery, location } } = match

  if (!Object.keys(locationKey).includes(location)) {
    sendMessage(to, "Invalid location entered. Type *cities* to look at all the cities available")
    return
  }

  try {
    const data = await client.request(searchGraphQLQuery, {
      location: locationKey[location],
      query: searchQuery,
    })

   const { edges } = data.locality.hospitals
    const hospitals = edges.map((item) => item.node)

    if (edges.length === 0) {
      sendMessage(to, "Sorry, there were no hospitals that matched your search 🙁")
      return
    }

    const formattedMessage = getFormattedHospitals(hospitals)
    sendMessage(to, formattedMessage)
  } catch (error) {
    sendMessage(to, "Sorry, there were no hospitals that matched your search 🙁")
  }
}

道順取得コマンド

最後のコマンドは、"get directions "コマンドである。コマンドのフォーマットは get directions to <hospital-id>.

まず、ユーザーに病院の場所を送信するために必要な病院の情報を取得するクエリを作成します:

// src/queries.js

const directionsGraphQLQuery = gql`
  query($id: ID!) {
    hospital(id: $id) {
      id
      longitude
      latitude
      name
      address
    }
  }
`

module.exports = {
  ...
  directionsGraphQLQuery,
}

では、ユーザーがリクエストした病院までの道順を送信する handleDirections関数を作成しよう。ユーザーがWhatsAppを使っている場合、病院名と住所を含む location型のメッセージを送信する。メッセンジャーには locationのメッセージはないので、病院の名前と住所を含む textのメッセージを送信する:

// src/app.js

const handleDirections = async (message, to) => {
  const directionsRegex = /^get directions to (?<hospitalId>\d+)$/i
  const match = directionsRegex.exec(message)

  if (match === null) {
    sendMessage(to, "Please enter a valid Hospital ID")
    return
  }

  const hospitalId = getEncodedString(parseInt(match.groups.hospitalId))

  try {
    const { hospital } = await client.request(directionsGraphQLQuery, {
      id: hospitalId,
    })

    if (to.type === "whatsapp") {
      sendMessage(
        to,
        {
          type: "location",
          location: {
            longitude: hospital.longitude,
            latitude: hospital.latitude,
            name: hospital.name,
            address: hospital.address,
          },
        },
        "custom"
      )
    } else {
      const link = `https://maps.google.com/maps?q=${hospital.latitude},${hospital.longitude}`
      const message = `${link}\n*${hospital.name}*\n${hospital.address}\n`

      sendMessage(to, message)
    }
  } catch (error) {
    sendMessage(to, "Please enter a valid Hospital ID")
  }
}

これで完了だ!サンドボックスで全てのコマンドを試してみると、以下のようになるはずだ!

Final Example

次の記事

  • 現在、私たちのウェブフックはインターネット全体に公開されています。Vonage Messages APIだけがエンドポイントにアクセスできるようにする方法が必要です。詳しくは JWT トークンを使ってアクセスを制限する.

  • 機械学習がお好きなら、自然言語処理を使ってボットをより自然なものにすることもできる。 より自然ににすることができます。

  • 道順取得コマンドに病院IDを使用することは、最良のユーザーエクスペリエンスではありません。代わりに、ユーザが道順を知りたい病院の名前を入力するコマンドを実装してみてください。もし似たような名前の病院が複数ある場合は、IDとともに病院のリストを返し、ユーザーに行き先を知りたい病院のIDを入力するように求めます。

チュートリアルの最終的なコードは GitHub.

シェア:

https://a.storyblok.com/f/270183/400x514/4b6d6f78ee/shreyas-sreenivas.png
Shreyas Sreenivas

Shreyas Sreenivas is a High School Student currently residing in Bangalore, India. He has experience in building applications in JavaScript, TypeScript, Python and PHP. He's also a huge fan of React, GraphQL, Graph Databases and Serverless.