Facebook Messenger Platform BETA を利用して国旗と首都と位置座標を返すBOTを作った

投稿日:

先日、Line Bot Apiを利用して国旗と首都と位置座標を返すBOTを作った を書きました。
ついでといってはなんですが、FacebookのMessenger Platform BETAを使って同じ様なBotを書いてみたので記しておきます。
はい、今更です。

どんなBot?

  • 日本語で国の名前をつぶやかれたら
    • 国旗の画像、首都名、首都の位置座標(GoogleMapsのURL)を返す
      • 上記はGeneral template(画像 + テキスト + ボタン)を使います
    • 分からなかったら分からないと答える
      • この場合はテキスト送信

そんなBotです。名前はCoutry-botです。
(といっても後述しますが公開はしていません)

プログラムは今回もLaravel(ver5.2.29)で書きました。

先に完成形

完成形はこんな感じです。
(gifなので重いです)

Country-botイメージ

手順

ググったら腐るほど手順は出てきますが、自分がやった手順を以下に書いていきます。
画像多めです。

先に大雑把に手順を書いておくと、

  1. Botのフェイスブックページを作る
  2. フェイスブックアプリを作る
  3. Webhooks設定
  4. Webhooks用プログラムを書く(確認リクエストの処理)
  5. トークンの確認
  6. アプリの購読処理
  7. メッセージ受信時のプログラムを書く

となります。

1. Botのフェイスブックページを作る

まず、Botのフェイスブックページを作ります。
※そもそもアカウントが無いと作れないので、Facebookアカウントは必須です

  1. こちらのリンクGetting Started – Messenger Platformから、Pageを選びます。

  2. Facebookページを作成画面から右上のブランドまたは製品を選びます。上部のプルダウンからアプリページを選択、ブランドまたは製品名欄に任意のアプリ名(アルファベットの場合、先頭を大文字にしないと怒られる)を入れます。そしてスタートボタンをクリックします。

  3. 基本データを入力します(自分で遊ぶだけだったら適当でいいんじゃないかな)。

  4. プロフィール写真があれば設定します。 ※スキップでもOK

  5. 3 お気に入りに追加と、4 ページ優先ターゲットは任意で (スキップを選んでも) 大丈夫かと。最後に保存するをクリックします。

以上で、Botのフェイスブックページが作れました。

2. フェイスブックアプリを作る

続いてフェイスブックアプリ(Bot用)を作ります。

  1. こちらのリンクGetting Started – Messenger Platformから、Facebook Appをクリックします。

  2. 開発者登録を行なっていない場合、Facebookの開発者登録してよって言われるのでRegister Nowをクリックします。

  3. フロートが立ち上がるので、はいを選択して登録するをクリックします。

  4. 以下の画面になるので、basic setupをクリックします。

  5. 新しいアプリIDを作成のフロートが立ち上がるので、表示名 / 連絡先メールアドレス / カテゴリ (自分は、ページ用アプリを選択しました) を入力して、アプリIDを作成をクリックします。

  6. セキュリティチェック(虎が写っている写真を選べとかそういうやつ。地味にムズカシイ。)が行われるので答えます。

  7. セキュリティチェックが済むと以下画面に遷移します。

開発者登録をしている場合

開発者登録を既にしている場合、Getting Started – Messenger Platformから、Facebook Appをクリックすると以下の画面になるのでSkip and Create App IDをクリックします。そうすれば、新しいアプリIDを作成のフロートが立ち上がります。

以上で、Bot用のフェイスブックアプリが作れました。

3. Webhooks設定

  1. 前作業の続きです。Messangerスタートをクリックします。

  2. フロートが立ち上がるので、ここでもスタートをクリックします。

  3. 以下画面になるので、Setup Webhooksをクリックします。

  4. フロートが立ち上がります。ここでは一旦コールバックURLトークンを確認の項目だけ埋めます。
    13-country_bot___Messenger___開発者向けFacebook

  • コールバックURL
    • Facebookから呼ばれるURLを書きます。https必須です。
  • トークンを確認
    • WebhooksでFacebookに返却するトークンを書きます。

一旦ここまでにして、続いてWebhooks用プログラムを書きます。

4. Webhooks用プログラムを書く(確認リクエストの処理)

ここでやることは以下になります。

Webhooksを使用したAPIアップデートの受信 – グラフAPI

確認リクエストの処理

新しい購読を追加したり、既存の購読を修正すると、コールバックサーバーの有効性を確認するために、FacebookサーバーからコールバックURLに対してGETリクエストが実行されます。このURLには次のパラメータを使用して、クエリ文字列が追加されます。

hub.mode – このパラメータには文字列「subscribe」が追加されます。
hub.challenge – ランダムな文字列
hub.verify_token – 購読を作成したときに指定したverify_token値。
サーバーでこれらのリクエストのいずれかを受信したときには、次の操作が必要です。

hub.verify_tokenが、購読の作成時に指定したものと一致することを確認する。これは、リクエストがFacebookによって行われたものであり、構成した購読に関連することをサーバー側で確認するためのセキュリティチェックです。
レスポンスをGET値のみを含むhub.challengeリクエストにレンダリングする。これは、このサーバーがコールバックを受け入れるように構成されていることを確認するためのものです。さらにFacebook側ではセキュリティの確認に使用されます。
PHPではパラメータ名内の.が_に変換されます。

Facebookから、

  • hub.mode(hub_mode) === ‘subscribe’
  • hub.verify_token(hub_verify_token)
    • 3.Webhooks設定->トークンを確認で書いたトークン
  • hub.challenge(hub_challenge)

を送るよ。そうしたら、hub.challenge(hub_challenge) の値をHTTPステータス200で返却してね。よろ。
って感じです。

Webhooks用プログラム

上記の3.Webhooks設定で定めたコールバックURLは、 https://xxxxx.xxx:443/facebook_messenger_bot とします。

app/Http/routes.php

<?php

/*
|--------------------------------------------------------------------------
| Application Routes
|--------------------------------------------------------------------------
|
| Here is where you can register all of the routes for an application.
| It's a breeze. Simply tell Laravel the URIs it should respond to
| and give it the controller to call when that URI is requested.
|
*/

/*
|--------------------------------------------------------------------------
| Facebook Messenger Bot
|--------------------------------------------------------------------------
| get  でFacebook からの確認を受け付ける
*/
Route::get('facebook_messenger_bot', function() { // 3.Webhooks設定で書いたコールバックURLのエンドポイント
    if (
        Request::input('hub_mode') === 'subscribe'
        && Request::input('hub_verify_token') === '3.Webhooks設定->トークンを確認で書いたトークン'
    ) {
        return response(Request::input('hub_challenge'));
    }

    Log::error('----- Facebook-Messenger-Bot Authentication Failure -----');

    return response('Error');
});

以下も修正します。
そうしないとCSRFでうんたらかんたらです。

app/Http/Middleware/VerifyCsrfToken.php

<?php

namespace App\Http\Middleware;

use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as BaseVerifier;

class VerifyCsrfToken extends BaseVerifier
{
    /**
     * The URIs that should be excluded from CSRF verification.
     *
     * @var array
     */
    protected $except = [
        'facebook_messenger_bot' // 上記のroutes.phpで設定したルートを書いておく
    ];
}

ここまで出来たら、3.Webhooks設定のフロートに戻り確認して保存をクリックします。 
うまくFacebookにお返事が出来ていたら、以下の様にWebhooksが完了ステータスになります。
14-country_bot___Messenger___開発者向けFacebook

エラーとなる場合

Webhooksが上手くいっていない場合、以下の様にエラーが表示されるので今までの手順やプログラムとにらめっこしながら修正してください。
country_bot_―_Messenger___開発者向けFacebook

5. トークンの確認

  1. トークン生成のFacebookページを選択から自身のアプリを選択します。
    15-country_bot___Messenger___開発者向けFacebook

  2. すると、Facebookでログインフロートが立ち上がるので○○としてログインをクリックします。

  3. OKでおk
    17-Facebookでログイン 7.47.42

  4. すると、ページアクセストークンにトークンが表示されるのでコピーしておきます。
    18-country_bot___Messenger___開発者向けFacebook

6. アプリの購読処理

続いて、アプリの購読処理をします。
これをしないとアプリにメッセージを送ってもシカッティングされます。

Webhook Reference – Messenger Platform

  1. Webhooksのページを選択から自身のアプリを選択します。

  2. フォローをするをクリックします。
    20-country_bot___Messenger___開発者向けFacebook

  3. フォローをするを選択した後はフォローをやめることも出来ます。
    21-country_bot___Messenger___開発者向けFacebook

curlで購読処理

上記以外にも、curlで購読処理が出来ます。

curl -X POST "https://graph.facebook.com/v2.6/me/subscribed_apps?access_token=PAGE_ACCESS_TOKEN"

PAGE_ACCESS_TOKENは5. トークンの確認でコピーしたトークン

7. メッセージ受信時のプログラムを書く

ここまで来たらBotとおしゃべりできる様になっているので、Botにどうお話させるかを書いていきます。
先に書いた通り、国名を受け取ったら国旗の画像、国名、首都名、首都の座標を返す処理を書きます。

素材

まずこのBOTを完成させるには、国旗の画像、国の名前、首都名、首都の座標を知らないと始まらないので以下の素材を活用します。

上記のcsvと画像は以下に置いておきます。

  • csv
    • storage/app/facebook-bot/asti-dath2704wc/asti-dath2704wc.csv
  • 画像
    • public/assets/img/facebook-bot/flags-normal/

Line Bot Apiを利用して国旗と首都と位置座標を返すBOTを作った | Shimabox Blog
を参考にしてください。

メッセージ受信時のプログラム

プログラムは以下の通りです。
※ Webhooks用プログラムで書いたものに追記しています

app/Http/routes.php

/*
|--------------------------------------------------------------------------
| Facebook Messenger Bot
|--------------------------------------------------------------------------
| get  でFacebook からの確認を受け付ける
| post でMessengerからの投稿を受け付ける
*/
Route::get('facebook_messenger_bot', function() { // 3.Webhooks設定で書いたコールバックURLのエンドポイント
    if (
        Request::input('hub_mode') === 'subscribe'
        && Request::input('hub_verify_token') === '3.Webhooks設定->トークンを確認で書いたトークン'
    ) {
        return response(Request::input('hub_challenge'));
    }
 
    Log::error('----- Facebook-Messenger-Bot Authentication Failure -----');
 
    return response('Error');
});

// メッセージ受信時の処理
Route::post('facebook_messenger_bot', function() {
    (new FacebookMessengerBot())->reply();
});

/**
 * Facebook Messenger Bot 管理
 */
class FacebookMessengerBot
{
    // アカウント設定情報
    /** トークン @var string */
    const ACCESS_TOKEN = "5. トークンの確認でコピーしたトークン";

    /**
     * メッセージを送信するAPIのエンドポイント
     * @var string
     * @link https://developers.facebook.com/docs/messenger-platform/send-api-reference#request
     */
    protected $sendMessagesApiEndpoint = "https://graph.facebook.com/v2.6/me/messages?access_token=";

    /**
     * csvのパス
     * @var string
     */
    protected $csvRelativePath = '/app/facebook-bot/asti-dath2704wc/h2704world.csv';

    /**
     * 公開画像のURI
     * @var string
     */
    protected $assetImgUri = '/assets/img/facebook-bot/flags-normal/';

    /**
     * 相手から送信されたメッセージのデータ
     * @var stdClass
     */
    protected $content = null;

    /**
     * 返答
     */
    public function reply()
    {
        try {
            $this->content = $this->specifyContent();

            if ($this->content->text === '') {
                $this->_replyText();
                return;
            }

            $result = $this->searchFromCsv($this->content->text);

            if (count($result) < 1) {
                $this->_replyText($this->content->text);
                return;
            }

            $this->_reply($result);

        } catch (Exception $e) {
            Log::error('----- System Error Log Start -----');
            Log::error($e->getMessage());
            Log::error($e->getTraceAsString());
            Log::error('----- System Error Log End -----');
        }
    }

    /**
     * 送信されたメッセージからデータの内容を抜き取る
     * @return stdClass ->fromUserId:メッセージを送ってきたユーザーのID, ->text: 送信されたメッセージ
     */
    protected function specifyContent()
    {
        $input = file_get_contents('php://input');
        $json = json_decode($input);
        $messaging = $json->entry{0}->messaging{0};

        $ret = new stdClass();
        $ret->fromUserId = $messaging->sender->id;

        if (property_exists($messaging->message, 'text')) {
            $ret->text = $this->_trim($messaging->message->text);
        } else {
            $ret->text = '';
        }

        return $ret;
    }

    /**
     * トリム(半角・全角スペース・改行コード)
     * @param string $value
     * @return string
     */
    protected function _trim($value)
    {
        return preg_replace('/(\s| |\n)/u', '', $value);
    }

    /**
     * CSVから検索
     * @param string $text
     * @return array
     */
    protected function searchFromCsv($text)
    {
        $csvPath = storage_path() . $this->csvRelativePath;
        $csv = $this->createCsv($csvPath)
                    ->setSearchColumns([1, 2])
                    ->setRequiredColumns([
                        0 => 'code',
                        5 => 'capitaljp',
                        6 => 'capitalen',
                        7 => 'lat',
                        8 => 'lon'
                    ])
                    ;

        return $csv->findBy($text);
    }

    /**
     * CSV検索モデル作成
     * @param string $csvPath
     * @return CSV
     */
    protected function createCsv($csvPath)
    {
        return new Csv($csvPath);
    }

    /**
     * 文字だけの返答
     * @param string $text
     */
    protected function _replyText($text='')
    {
        $_text = $text === '' ? '???' : $text;
        $message1 = '【'.$_text.'】ですね。';
        $message2 = 'む、';
        $message3 = 'むむっ、、';
        $message4 = 'くぅ~!';

        if ($text !== '') {
            $message5 = 'わかりませんでした。。';
        } else {
            $message5 = '文字を入力してください。。';
        }

        $this->sendMessage($message1);
        usleep(500000);
        $this->sendMessage($message2);
        $this->sendMessage($message3);
        usleep(1000000);
        $this->sendMessage($message4);
        usleep(500000);
        $this->sendMessage($message5);
    }

    /**
     * メッセージを送信
     * @param string $message
     */
    protected function sendMessage($message)
    {
        $post = [];
        $post['recipient']['id'] = $this->content->fromUserId;
        $post['message']['text'] = $message;

        $this->callApi(json_encode($post));
    }

    /**
     * 返答
     * @param array $result
     */
    protected function _reply(array $result)
    {
        if (count($result) < 1) {
            return;
        }

        $cd = mb_strtolower($result['code']);
        $capitaljp = $result['capitaljp'];
        $capitalen = $result['capitalen'];
        $lat = $result['lat'];
        $lon = $result['lon'];

        $imgFileName = $cd . '.png';
        $imgUrl = asset($this->assetImgUri . $imgFileName);

        $z = 14;  // 縮尺:2(最小ズーム)~14(最大ズーム)
        $t = 'h'; // t=m 市街地図, t=k 航空写真, t=h 航空写真に市街地図を重ねたもの
        $mapUrl = "https://maps.google.co.jp/maps?ll={$lat},{$lon}&q={$lat},{$lon}&z={$z}&t={$t}";

        $button = [];
        $button['type'] = 'web_url';
        $button['url'] = $mapUrl;
        $button['title'] = 'Google Map';

        $buttons = [];
        $buttons[] = $button;

        $element = [];
        $element['title'] = $this->content->text;
        $element['image_url'] = $imgUrl;
        $element['subtitle'] = $capitaljp . ' ('.$capitalen.')';
        $element['buttons'] = $buttons;

        $elements = [];
        $elements[] = $element;

        $payload = [];
        $payload['template_type'] = "generic";
        $payload['elements'] = $elements;

        $attachment = [];
        $attachment['type'] = "template";
        $attachment['payload'] = $payload;

        $post = [];
        $post['recipient']['id'] = $this->content->fromUserId;
        $post['message']['attachment'] = $attachment;

        // 一旦答えてから
        $this->sendMessage('【'.$this->content->text.'】ですね。わかります。');
        // 本回答
        $this->callApi(json_encode($post));
    }

    /**
     * API実行
     * @param string $post
     * @throws Exception
     */
    protected function callApi($post) {
        $url = $this->sendMessagesApiEndpoint . self::ACCESS_TOKEN;

        $ch = curl_init($url);
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_HTTPHEADER, $this->header());
        curl_setopt($ch, CURLOPT_POSTFIELDS, $post);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
        $result = curl_exec($ch);
        curl_close($ch);

        // $resultの中身
        // 成功時 : '{"recipient_id":"1077056032343827","message_id":"mid.1469055194194:78662f24e940834b02"}'
        // 失敗時 : '{"error":{"message":"Invalid OAuth access token.","type":"OAuthException","code":190,"fbtrace_id":"Fwc47aT5j1m"}}'
        $response = json_decode($result);
        if (
            !$response
            || property_exists($response, 'error') === true
        ) {
            $e = !$response ? 'failure on the results of the json_decode' : $result;
            throw new Exception($e);
        }
    }

    /**
     * ヘッダー
     * @return array
     */
    protected function header()
    {
        return [
            'Content-Type: application/json; charser=UTF-8'
        ];
    }
}

/**
 * CSV検索モデル
 */
class Csv
{
    /**
     * csvのパス
     * @var string
     */
    protected $path = '';

    /**
     * デフォルトで検索をするカラムの列番号<br>
     * 複数あれば or で検索<br>
     * 1列目は0としてください<br>
     * デフォルト [0]
     * @var array
     */
    protected $searchColumns = [0];

    /**
     * (検索で見つかったら)値を返して欲しいカラムの列番号とキー名<br>
     * 1列目は0としてください<br>
     * ['列番号' => 'キー名', '列番号' => 'キー名', ...]<br>
     * 設定しなければ検索で見つかった行をそのまま返します
     * @var array
     */
    protected $requiredColumns = [];

    /**
     * ヘッダー行があるか
     * @var boolean
     */
    protected $existsHeader = true;

    /**
     * コンストラクタ
     * @param string $path CSVのパス
     */
    public function __construct($path)
    {
        $this->path = $path;
    }

    /**
     * デフォルトで検索をするカラムの列番号 setter
     * @param array $in
     * @return \Csv
     */
    public function setSearchColumns(array $in) {
        $this->searchColumns = $in;
        return $this;
    }

    /**
     * (検索で見つかったら)値を返して欲しいカラムの列番号とヘッダーの名前 setter<br>
     * 1列目は0としてください
     * @param array $in
     * @return \Csv
     */
    public function setRequiredColumns(array $in) {
        $this->requiredColumns = $in;
        return $this;
    }

    /**
     * 検索
     * @param string $needle
     * @return array
     */
    public function findBy($needle)
    {
        $ret = [];

        if (!file_exists($this->path)) {
            return $ret;
        }

        $this->setAutoDetectLineEndings(1);

        $file = new SplFileObject($this->path);
        $file->setFlags(SplFileObject::READ_CSV);
        foreach ($file as $index => $row) {
            if (
                ($this->existsHeader && $index === 0) // ヘッダー
                || $row[0] === null // 空行
            ) {
                continue;
            }

            foreach ($this->searchColumns as $index) {
                if (
                    isset($row[$index])
                    && $this->toUTF8($row[$index]) === $needle
                ) {
                    $ret = $this->pick($row);
                    break;
                }
            }
        }

        $this->setAutoDetectLineEndings(0);

        return $ret;
    }

    /**
     * CSVの値(SJIS)をUTF-8に変換
     * @param [string|array] $val
     * @return [string|array]
     */
    protected function toUTF8($val)
    {
        if (is_string($val)) {
            return $this->_toUTF8($val, 'UTF-8', 'SJIS');
        }

        if (is_array($val)) {
            return array_map(array($this, 'toUTF8'), $val);
        }
    }

    /**
     * CSVの値(SJIS)をUTF-8に変換
     * @param string $val
     * @return string
     */
    protected function _toUTF8($str)
    {
        return mb_convert_encoding($str, 'UTF-8', 'SJIS');
    }

    /**
     * 選別
     * @param array $row
     * @return array
     */
    protected function pick($row)
    {
        if (count($this->requiredColumns) < 1) {
            return $this->toUTF8($row);
        }

        $ret = [];
        foreach ($this->requiredColumns as $index => $name) {
            if (!isset($row[$index])) {
                continue;
            }
            $ret[$name] = $row[$index];
        }

        return $this->toUTF8($ret);
    }

    /**
     * auto_detect_line_endings の切り替え<br>
     * MACで作成されたCSVだと改行コードがCRになってしまい認識できないので
     * @param int $val 0 => 'OFF' | 1 => 'ON'
     */
    protected function setAutoDetectLineEndings($val)
    {
        ini_set('auto_detect_line_endings', $val);
    }
}

会話してみる

上記まで行ったら、MessengerからBotを探して会話してみます。
見事に返答されたらOKです。

Botを公開する?

ここまでの作業で自分自身によるBotとの会話は出来るようになったと思います。
じゃあどうやったら人類に公開出来るの?って話ですが、その為にはアプリのダッシュボード(https://developers.facebook.com/apps/{APP ID}/review-status/)などからpages_messaging (Send/Receive API)の利用申請が必要なようです。
ちょっと進んでみたのですが、スクリーンキャストとかプライバシーポリシーのURLが必要だったりとか敷居が高いというか個人でここまでやるのは…なので止めました。
このあたり、Line Botより公開の手軽さは無いです。

友達に見せるには?

友達に見せるだけならアプリのダッシュボードから友達をテスターとして追加すればOKです。

22-country_bot_―_役割___開発者向けFacebook

  • 上記はアプリのダッシュボード(https://developers.facebook.com/apps/{APP ID}/roles/)
    • 左メニュー役割を選択
    • テスターを追加からユーザーを追加

こうすると、テスターとして追加されたユーザーもこのBotとおしゃべりできます。
でも正直めんどい。。

まとめ

というわけでFacebookのMessenger Platform BETAを使ってBotを書いてみました。

Line Botと比べると、Botを使うまでの設定/手順が煩雑だったかなという感想です。
(公開方法の敷居も高い。。)
でもApiのレスポンス形式が大体統一されていたので、プログラムが書きやすかったのはこっちかな。。

参考にさせて頂きました

作成者: shimabox

Web系のプログラマをやっています。 なるべく楽しく生きていきたい。

コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください