2016/4/13、FacebookがMessenger Botを発表しました。国内ではすでにLineもbot市場(?)に参入していて、これからbotをベースとしたサービスも続々と増えていくでしょう。Facebook Messenger Botとは何か、そして何が実現できるのか。それを知るためにも、AWS Lambda + Serverlessフレームワークを使ってbotを構築してみました。
巷には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ページができたら、次はbotアプリの登録をします。Facebook Developerへの登録が必要だった気もしますが、既に登録済みだったため手順は分からず..まあそれほど難しくなかった気がします。
[新しいアプリを追加]を選択
アプリの選択では[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]を選択。
APIの作成
早速Lambda Function(実行されるAPI)を書いていくのですが、その前にFacebook Messenger Botの仕様を確認します。公式のドキュメントに一度目を通しておくといいでしょう。
Messenger Platform - Getting Started
Messenger Botを構築するには、任意のサーバーでGET
とPOST
の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_token
とhub.challenge
が必要なので、この2つを指定します。
"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
を定義しています。今回はGET
とPOST
のリクエストを1つのLambda Functionで取り扱うため、その判定のためにHTTP Methodを渡しています。
ここの記述はVTLという言語で書かれているらしく、詳しく知りたい方はググッてください。
もしくは公式リファレンスを
API Gateway のマッピングテンプレートリファレンス
"requestTemplates": {
"application/json": {
"verify_token": "$input.params('hub.verify_token')",
"challenge": "$input.params('hub.challenge')",
"method": "$context.httpMethod"
}
}
次に、レスポンスのマッピングをします。Facebook Messenger Botはプレーンテキストを返す仕様なので、responseTmplates
内の記述を次の用に書きかえます。
"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
を返す、というシンプルな構造です。
'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
デプロイが完了しました!
ここまで来たら、先ほどのFacebook Developerに戻り、APIのテストをしましょう。
[Setup Webhooks]を選択。
LambdaのURLと、先ほど生成されたtokenを入力、[messages]にチェックを入れて確認します。指定されたURLから正しくChallengeが返ってきたら成功です。
エラーが出る場合
次の項目を確認してください。
URLが正しいか
API Gatewayのコンソールから正しいURLを確認してください。ここに表示されているEndpoint + Serverlessで指定したpathです。
Lambdaのログを確認
該当するLambda Functionの[View logs in CloudWatch]から、ログを確認できます。
最新のログを確認してデバッグしましょう
POST /facebook
ここまできたら、あとは実際にbotとしてメッセージを返すAPIを作ります。今回は、簡略化のために「来たメッセージをそのまま返すbot」にしましょう。
POST APIの仕様は、次のようになっています。
* 受けたリクエストに対しては、とりあえず200 OKを返す
* パラメータで渡されたsender_id(ユーザー)に対して、POSTリクエストを投げて応答する
レスポンスでメッセージを返すわけじゃないんですね。なるほど。
早速この仕様に対応したAPIの定義を追加します。s-function.json
にはendpointを複数指定できるので、先ほどのファイルに追記していきます。POSTリクエストのjsonで渡されたパラメータから、entry
のみLambdaに渡してあげる設定にしています。
{
"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
に対してリクエストを投げるだけ。
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
に使用するプラグインを記述します。
"plugins": [
"serverless-optimizer-plugin"
]
そしてプラグインの設定はs-function.json
に。browserifyするときの設定を記述するのですが、ES2015の記法を使っている場合(Arrow Functionとか)はbabelifyを通さないと死にます。せっかくLambdaがnode v4に対応したのにバベらないといけないなんて...
"custom": {
"excludePatterns": [],
"optimize": {
"exclude": ["aws-sdk"],
"transforms": [
{
"name": "babelify",
"opts": {
"presets": ["es2015"]
}
}
]
}
これで完了です。またsls dash deploy
コマンドでデプロイしましょう。
動かない時
バンドルされたコードを確認する
きちんとbrowserifyが動いているか確認します。Lambdaのコンソールからデプロイされたコードがダウンロードできるので、ちゃんとbrowseifyされているか確認します。ごちゃごちゃ圧縮されたファイルがダウンロードできたらたぶんOKです。
動作確認
作成したFacebookページから、メッセージを送ります。
ちゃんとレスポンスが返ってきました!!!!!
コード
今回使用したコードの全体像です。コピペしたらたぶん動きます。
実行したコマンド
$ 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
{
"name": "facebook-bot",
"custom": {},
"plugins": [
"serverless-optimizer-plugin"
]
}
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
'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);
}
});
}