Facebook Messenger Botの作り方 〜AWS Lambda(Serverless)での構築〜

  • 30
    Like
  • 0
    Comment
More than 1 year has passed since last update.

2016/4/13、FacebookがMessenger Botを発表しました。国内ではすでにLineもbot市場(?)に参入していて、これからbotをベースとしたサービスも続々と増えていくでしょう。Facebook Messenger Botとは何か、そして何が実現できるのか。それを知るためにも、AWS Lambda + Serverlessフレームワークを使ってbotを構築してみました。

Serverless

巷にはherokuで構築してみましたという例が多いですが、Lambda + Serverlessで構築することで次のメリットがあります

  • 運用コストがほぼゼロ(Lambdaは毎月100万リクエストが無料)
  • サーバーレスなので管理コストがほぼゼロ
  • Serverlessは一種のInfrastructure as Codeなので、メンテが楽

逆にServerlessのアップデートが早すぎて大変(実際に今回詰まった)というデメリットはありますが、そこは気合でカバーしましょう。

はじめに

分かる人は読み飛ばして大丈夫です

Facebook Messenger Botとは

企業や団体が公開しているFacebookページにも、Messenger機能があります。通常は、そのFacebookページにメッセージを送ると管理者に通知がいくのですが、これをbotで返信できるようになりました。

サーバーを1台立てるだけで、あるメッセージに対して自動的に返答を返す、ということが簡単に実現できます。なんだかプロトタイピングでユーザーニーズを吸い上げるときとかに使えそうですね(と今思った)。

AWS Lambdaとは

2015年あたりから注目されている、サーバーレスアーキテクチャを実現するAWSのサービスです。通常バックエンドの仕組みを作るときは、EC2のインスタンスを立てて〜としていましたが、Lambdaならそれが不要です。Lambdaはリクエストがある度に起動され、ある単一の関数(Lambda Functionとか呼ばれる)を実行し、レスポンスを返したら終了します。Lambdaによって、人々はサーバーのメンテナンスという仕事から解放されます。

Serverlessとは

そのAWS Lambdaを簡単に使えるようにしたのが、ServerlessというAWS純正のフレームワークです。Lambda FunctionをAPIとして使いたい場合、API Gatewayと接続する必要があるのですが、これを簡単に設定できたり、複数のLambda Functionのデプロイを簡易化するのがServerlessです。裏ではCloud Formationが動いていて、簡単なコマンドと設定ファイルでいい感じに構築してくれます。

動作環境

  • Serverless v0.5.5
  • node.js(開発環境) v4.4.0
  • node.js(Lambda上) v4.3.0

構築手順

それでは早速、botを構築していきましょう。

1.Facebookページの作成・アプリの登録

まずは、メッセージのやり取りをするFacebookページを作成します。自分のFacebookのホームから、左にある「Facebookページを作成」を選択します。ここの文章、基本的に見切れてるんだけど、これでいいのかFacebook...

テスト用ページなので、入力項目は適当で。「コミュニティ」を選択したほうが入力項目少なそうで楽でした。

Facebookページの作成

Facebookページができたら、次はbotアプリの登録をします。Facebook Developerへの登録が必要だった気もしますが、既に登録済みだったため手順は分からず..まあそれほど難しくなかった気がします。

https://developers.facebook.com/apps/

[新しいアプリを追加]を選択

新しいアプリを追加

アプリの選択では[basic setup]を選びます。

basic setupを選択

この辺りも適当に。ネームスペースはtestとか適当なやつだとかぶって使えないのでご注意を。

アプリの設定

[Product Setup]の画面に遷移するので、[Messenger]を選択 => [スタート]。

トークン生成で、先ほど作成したFacebookページを選択。右に表示されるアクセストークンをメモしておきましょう。

アクセストークンの生成

念のため、対象のページがフォローされているか(botのターゲットになっているか)を確認しましょう。

フォローするページの選択

一旦ここまでで作業は完了です。このページは閉じずに置いとくと楽です。

2.Serverlessの構築

次は実行されるAPIの部分を、AWS上に構築していきましょう。

プロジェクトの作成

何はともあれServerlessをインストールします。この記事を書いている時点では、v0.5.5を使用しています。Serverlessは結構変化が早いので、バージョンを合わせることをオススメします。また、もしv0.5.5以上を使用される場合、この記事の通りに動く保証はありません...

$ npm i serverless -g

インストールが完了したら、プロジェクトを作成します。serverlessコマンドはslsとも打てるので、短い方で。プロジェクト作成後、そのディレクトリに移動してLambda Functionを作成します。

注意
Serverlessを最初に使う場合、IAMユーザの登録やアクセスキーの登録が必要だった気がします。筆者の環境には既にServerlessがセットアップされていたため、その手順をなぞることが出来ず...
このあたりの記事を参考にセットアップしてください。
AWS Lambdaを活用したServerless Frameworkを触ってみる

$ sls project create
$ cd [project_name]
$ sls function create

runtimeにはnode.jsの最新版、API Gatewayを使うので[Create Endpoint]を選択。

Lambda Functionの生成

APIの作成

早速Lambda Function(実行されるAPI)を書いていくのですが、その前にFacebook Messenger Botの仕様を確認します。公式のドキュメントに一度目を通しておくといいでしょう。
Messenger Platform - Getting Started

Messenger Botを構築するには、任意のサーバーでGETPOSTの2つのAPIを解放する必要があります。pathはなんでも良いのですが、ここでは仮に/facebook として話を進めましょう。

  • GET /facebook:正しいendpointかを認証するためのAPI
  • POST /facebok:実際にメッセージをうけた時に呼ばれるAPI。ここでbotの処理を行う

アプリを登録するうえで、まずはGETのAPIは先に作る必要があります。という訳で、GET APIの作り方を見ていきましょう。

GET /facebook

GETは認証用に解放するAPIです。Facebook Messenger Botに設定したURLが正しいか、このAPIを用いてチェックします。リファレンスを見る限り、次の2つの仕様を満たす必要があるようです。

  • QueryStringで渡されたhub.verify_tokenが、事前に生成したTokenと合っているかチェックする
  • 正しい場合、QueryStringで渡さされたhub.challengeをそのままレスポンスとして返す、それ以外の場合は適当な文字列を返す

この仕様を満たすAPIをLambda上に構築しましょう。Serverlessでは、まずAPIの定義(API Gateway側の設定)をs-function.json内のendpointsに記述します。全体像は最後に貼ります。

まず、「どのパラメータを受け取るか」をrequestParameters内に記述します。今回はQueryStringのhub.verify_tokenhub.challengeが必要なので、この2つを指定します。

facebook-bot/s-function.json
  "requestParameters": {
    "integration.request.querystring.hub.verify_token": "method.request.querystring.hub.verify_token",
    "integration.request.querystring.hub.challenge": "method.request.querystring.hub.challenge"
  }

次に、それらの値がLambda上でどのような名前で取れるのか、requestTemplatesにマッピングを指定します。ここでは先程の2つに加え、methodを定義しています。今回はGETPOSTのリクエストを1つのLambda Functionで取り扱うため、その判定のためにHTTP Methodを渡しています。

ここの記述はVTLという言語で書かれているらしく、詳しく知りたい方はググッてください。
もしくは公式リファレンスを
API Gateway のマッピングテンプレートリファレンス

facebook-bot/s-function.json
  "requestTemplates": {
    "application/json": {
      "verify_token": "$input.params('hub.verify_token')",
      "challenge": "$input.params('hub.challenge')",
      "method": "$context.httpMethod"
    }
  }

次に、レスポンスのマッピングをします。Facebook Messenger Botはプレーンテキストを返す仕様なので、responseTmplates内の記述を次の用に書きかえます。

facebook-bot/s-function.json
  "responses": {
    // 〜中略
    "statusCode": "200",
    "responseParameters": {},
    "responseModels": {
      "application/json;charset=UTF-8": "Empty"
    },
    "responseTemplates": {
      "text/plain": "$input.path('$')"
    }
  }

最後に、GETメソッドのロジックを実装します。Lambdaに登録したハンドラの実装は、handler.js内にあります。ここでexportしたhanderlが、APIコールなどのイベント発火時に呼ばれるような仕組みです。リクエストパラメータは全て仮引数event内に格納され、第3引数のcbを実行することでfunctionの実行結果が返る仕組みです。

コードは、メソッドがGETの場合、verify_tokenを評価して正しければchallengeを返す、というシンプルな構造です。

handler.js
'use strict';

const FB_MESSANGER_TOKEN = 'xxxxxxxxxxxx';

module.exports.handler = function(event, context, cb) {
  switch (event.method.toUpperCase()) {
    case 'GET': {
      const validRequest = event.verify_token === FB_MESSANGER_TOKEN;
      return cb(null, validRequest ? event.challenge : 'Error, wrong validation token');
    }

    default: return cb('Error, Invalid Method');
  }
};

これでGET APIの作成は完了です。デプロイしましょう。
Serverlessには、dash deployというCUIで簡単にデプロイ対象が選択できるコマンドが用意されいてるので、これを使います。

$ sls dash deploy

Serverlessのデプロイ

デプロイが完了しました!

ここまで来たら、先ほどのFacebook Developerに戻り、APIのテストをしましょう。

[Setup Webhooks]を選択。

Webhooksを選択

LambdaのURLと、先ほど生成されたtokenを入力、[messages]にチェックを入れて確認します。指定されたURLから正しくChallengeが返ってきたら成功です。

コールバックURL、トークンを入力

エラーが出る場合
次の項目を確認してください。

URLが正しいか

API Gatewayのコンソールから正しいURLを確認してください。ここに表示されているEndpoint + Serverlessで指定したpathです。
API GatewayのURL

Lambdaのログを確認

該当するLambda Functionの[View logs in CloudWatch]から、ログを確認できます。

Lambda Functionのログ

最新のログを確認してデバッグしましょう

CloudWatchでログの確認

POST /facebook

ここまできたら、あとは実際にbotとしてメッセージを返すAPIを作ります。今回は、簡略化のために「来たメッセージをそのまま返すbot」にしましょう。

POST APIの仕様は、次のようになっています。
* 受けたリクエストに対しては、とりあえず200 OKを返す
* パラメータで渡されたsender_id(ユーザー)に対して、POSTリクエストを投げて応答する

レスポンスでメッセージを返すわけじゃないんですね。なるほど。

早速この仕様に対応したAPIの定義を追加します。s-function.jsonにはendpointを複数指定できるので、先ほどのファイルに追記していきます。POSTリクエストのjsonで渡されたパラメータから、entryのみLambdaに渡してあげる設定にしています。

faceboo/s-function.json
    {
      "path": "facebook",
      "method": "POST",
      "type": "AWS",
      "authorizationType": "none",
      "authorizerFunction": false,
      "apiKeyRequired": false,
      "requestParameters": {},
      "requestTemplates": {
        "application/json": {
          "entry": "$input.json('entry')",
          "method": "$context.httpMethod"
        }
      },
      "responses": {
        "400": {
          "statusCode": "400"
        },
        "default": {
          "statusCode": "200",
          "responseParameters": {},
          "responseModels": {
            "application/json;charset=UTF-8": "Empty"
          },
          "responseTemplates": {
            "text/plain": "$input.path('$')"
          }
        }
      }
    }

handlerにコードを追記します。リクエストを投げるためのモジュールを追加し、

$ npm i request -S

コードはこういう感じです。パラメータ中のsender.idに対してリクエストを投げるだけ。

facebook/handler.js
const request = require('request');

const FB_MESSANGER_TOKEN = 'xxxxx';

module.exports.handler = function(event, context, cb) {
  switch (event.method.toUpperCase()) {
    case 'GET': // 略

    case 'POST': {
      event.entry[0].messaging.filter(m => m.message && m.message.text).forEach(m => sendTextMessage(m.sender.id, m.message.text));
      return cb(null, 'OK');
    }
    // 略
  }
};

function sendTextMessage(sender, text) {
  request({
    url: 'https://graph.facebook.com/v2.6/me/messages',
    qs: { access_token: FB_MESSANGER_TOKEN },
    method: 'POST',
    json: {
      recipient: { id: sender },
      message: { text: text },
    }
  }, function(error, response, body) {
    if (error) {
      console.log('Error sending message: ', error);
    } else if (response.body.error) {
      console.log('Error: ', response.body.error);
    }
  });
}

ここでコーディング自体は終わりなのですが、Serverlessはデフォルトではrequireで依存したモジュールをデプロイしてくれません。昔のバージョンだとまとめてzipにしてデプロイしてくれたのですが(ここで綺麗にハマった)、Lambdaにデプロイするモージュールが10MBまでという制限に対処するため、v0.5.5ではbroserifyやwebpackで最適化された1つのhandler.jsをデプロイするように変更されています。

それらはpluginという形で提供されていて、導入自体は楽なのですが、知らないとつらいです...
というわけで関連モジュールをインストール。browserifyとかbabelとかよく分からない人は、とりあえずコピペしましょう(でも知っておいた方がいいですよ)。

$ npm i serverless-optimizer-plugin babelify babel-preset-es2015 -S

ルートのs-project.jsonに使用するプラグインを記述します。

s-project.json
  "plugins": [
    "serverless-optimizer-plugin"
  ]

そしてプラグインの設定はs-function.jsonに。browserifyするときの設定を記述するのですが、ES2015の記法を使っている場合(Arrow Functionとか)はbabelifyを通さないと死にます。せっかくLambdaがnode v4に対応したのにバベらないといけないなんて...

facebook/s-function.json
  "custom": {
    "excludePatterns": [],
    "optimize": {
      "exclude": ["aws-sdk"],
      "transforms": [
        {
          "name": "babelify",
          "opts": {
            "presets": ["es2015"]
          }
        }
      ]
    }

これで完了です。またsls dash deployコマンドでデプロイしましょう。

動かない時

バンドルされたコードを確認する

きちんとbrowserifyが動いているか確認します。Lambdaのコンソールからデプロイされたコードがダウンロードできるので、ちゃんとbrowseifyされているか確認します。ごちゃごちゃ圧縮されたファイルがダウンロードできたらたぶんOKです。

コードのダウンロード.png

動作確認

作成したFacebookページから、メッセージを送ります。

facebookページからメッセージを送る

ちゃんとレスポンスが返ってきました!!!!!

botの動作確認.png

コード

今回使用したコードの全体像です。コピペしたらたぶん動きます。

実行したコマンド

$ sls project create
$ cd facebook-bot
$ sls function create

$ npm i request serverless-optimizer-plugin babelify babel-preset-es2015 -S
$ npm install  -S

$ sls dash deploy

s-project.json

s-porject.json
{
  "name": "facebook-bot",
  "custom": {},
  "plugins": [
    "serverless-optimizer-plugin"
  ]
}

s-function.json

s-function.json
{
  "name": "facebook-bot",
  "runtime": "nodejs4.3",
  "description": "Serverless Lambda function for project: facebook-bot",
  "customName": false,
  "customRole": false,
  "handler": "handler.handler",
  "timeout": 6,
  "memorySize": 1024,
  "authorizer": {},
  "custom": {
    "excludePatterns": [],
    "optimize": {
      "exclude": ["aws-sdk"],
      "transforms": [
        {
          "name": "babelify",
          "opts": {
            "presets": ["es2015"]
          }
        }
      ]
    }
  },
  "endpoints": [
    {
      "path": "facebook",
      "method": "GET",
      "type": "AWS",
      "authorizationType": "none",
      "authorizerFunction": false,
      "apiKeyRequired": false,
      "requestParameters": {
        "integration.request.querystring.hub.verify_token": "method.request.querystring.hub.verify_token",
        "integration.request.querystring.hub.challenge": "method.request.querystring.hub.challenge"
      },
      "requestTemplates": {
        "application/json": {
          "verify_token": "$input.params('hub.verify_token')",
          "challenge": "$input.params('hub.challenge')",
          "method": "$context.httpMethod"
        }
      },
      "responses": {
        "400": {
          "statusCode": "400"
        },
        "default": {
          "statusCode": "200",
          "responseParameters": {},
          "responseModels": {
            "application/json;charset=UTF-8": "Empty"
          },
          "responseTemplates": {
            "text/plain": "$input.path('$')"
          }
        }
      }
    },
    {
      "path": "facebook",
      "method": "POST",
      "type": "AWS",
      "authorizationType": "none",
      "authorizerFunction": false,
      "apiKeyRequired": false,
      "requestParameters": {},
      "requestTemplates": {
        "application/json": {
          "entry": "$input.json('entry')",
          "method": "$context.httpMethod"
        }
      },
      "responses": {
        "400": {
          "statusCode": "400"
        },
        "default": {
          "statusCode": "200",
          "responseParameters": {},
          "responseModels": {
            "application/json;charset=UTF-8": "Empty"
          },
          "responseTemplates": {
            "text/plain": "$input.path('$')"
          }
        }
      }
    }
  ],
  "events": [],
  "environment": {
    "SERVERLESS_PROJECT": "${project}",
    "SERVERLESS_STAGE": "${stage}",
    "SERVERLESS_REGION": "${region}"
  },
  "vpc": {
    "securityGroupIds": [],
    "subnetIds": []
  }
}

handler.js

handler.js
'use strict';

const request = require('request');

const FB_MESSANGER_TOKEN = 'ここにtokenを書く';

module.exports.handler = function(event, context, cb) {

  switch (event.method.toUpperCase()) {
    case 'GET': {
      const validRequest = event.verify_token === FB_MESSANGER_TOKEN;
      return cb(null, validRequest ? event.challenge : 'Error, wrong validation token');
    }

    case 'POST': {
      event.entry[0].messaging.filter(m => m.message && m.message.text).forEach(m => sendTextMessage(m.sender.id, m.message.text));
      return cb(null, 'OK');
    }

    default: return cb('Error, Invalid Method');
  }
};

function sendTextMessage(sender, text) {
  const messageData = {
    text:text
  }

  request({
    url: 'https://graph.facebook.com/v2.6/me/messages',
    qs: {
      access_token: FB_MESSANGER_TOKEN
    },
    method: 'POST',
    json: {
      recipient: {
        id: sender
      },
      message: messageData,
    }
  }, function(error, response, body) {

    if (error) {
      console.log('Error sending message: ', error);
    } else if (response.body.error) {
      console.log('Error: ', response.body.error);
    }
  });
}

参考