Line Bot Apiを利用して国旗と首都と位置座標を返すBOTを作った

投稿日:

カントリーBot この道 ずっとゆけば あの街に 続いてる 気がする カントリーBot  

というわけで、ものすごく時期を逃して賞味期限切れ感満載なのですが、僕もLineBotApiを使ってBotを書いてみました。

【注意】このBotはもう動きません

【Lumen】LINEの Messaging API を使って再度Botを作ったお話 | Shimabox Blog にて新しく書き換えています。

どんなBot?

  • 日本語で国の名前をつぶやかれたら
    • 国旗を返す
    • 首都名と首都の位置座標を返す
    • 分からなかったら分からないと答える
    • 虐められてもいいようにユーザーのログは残します

そんなBotです。
名前はcountry-botです。

どうやれば作れるの?

ものすごく簡単です。今はググればすぐ情報が出てきます。

以下は僕が参考にしたサイトです。

これらのサイトを参考に、https://business.line.me/services/products/4/introduction でアカウント登録などをしつつ

  • Channel ID
  • Channel Secret
  • MID
  • Callback URL
    • 後述しますが、SSL通信可能なサーバーである必要があります

の取得/設定と、

  • Server IP Whitelistの登録

をして、プログラムを書けば動きます。
※プログラムは以下実装方法にサンプルを書いているのでよかったら参考にしてください

もちろん本家のリファレンスも参照します。

注意点

実装方法

実装方法を以下に書いていきますが、あくまでも参考程度としてください。

素材

まず、このBOTを完成させるには、国旗の画像、国の名前、首都名、首都の座標を知らないと始まりません。
そこで上記を知る上でビンゴの素材を発見しましたのでこれらを活用します。

国の名前、首都名、首都の座標取得用CSV

これは凄い。これがあれば国名から首都名、座標がひけます。

国旗画像

これも凄い。何が凄いって画像名が国コードになっていて上記のCSVとマッチングが簡単に出来るっ!

多分、これらが無ければ作ろうと思わなかっただろうなぁ。。ありがたや。ありがたや。

プログラム

プログラムは以下の通りです。
今回はLaravel(ver5.2.29)上で書きましたが、そこまでLaravelに依存していないと思います。
(routes.phpにすべて書いているしな!)

素材

上記のcsvと画像は以下に置きました。

  • csv
    • storage/app/line-bot/asti-dath2704wc/asti-dath2704wc.csv
      • csvとか公開はしないけどプログラム内で使う素材ってどこに置くか不明だったけどとりあえずここに置いた
  • 画像
    • public/assets/img/line-bot/flags-normal/

app/Http/routes.php

  • Callback URLを、 https://xxxxx.xxx:443/line_bot としたとします
<?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.
|
*/

/*
|--------------------------------------------------------------------------
| Line Bot
|--------------------------------------------------------------------------
*/
Route::post('line_bot', function() {  // 上記のCallback URLで設定したルート(POSTで来る)
    (new LineBot())->reply();
});

/**
 * LineBotAPI 管理
 */
class LineBot
{
    // アカウント設定情報
    /** Channel ID @var string */
    const CHANNEL_ID = "XXXXX"; // 上記サイトを参考に取得したCHANNEL_ID
    /** Channel Secret @var string */
    const CHANNEL_SECRET = "XXXXX"; // 上記サイトを参考に取得したCHANNEL_SECRET
    /** MID @var string */
    const MID = "XXXXX"; // 上記サイトを参考に取得したMID

    /** toChannel @var string */
    const TO_CHANNEL = "1383378250";
    /** eventType(Sending messages) @var string */
    const EVENT_TYPE_SENDING_MESSAGES = "138311608800106203";
    /** eventType(Sending multiple messages) @var string */
    const EVENT_TYPE_SENDING_MULTIPLE_MESSAGES = "140177271400161403";

    /**
     * 相手のプロフィールを取得するAPIのエンドポイント
     * @var string
     * @link https://developers.line.me/restful-api/api-reference#getting_user_profile
     */
    protected $profileApiEndpoint = "https://trialbot-api.line.me/v1/profiles?mids=";

    /**
     * メッセージを送信するAPIのエンドポイント
     * @var string
     * @link https://developers.line.me/bot-api/api-reference#sending_multiple_messages
     */
    protected $sendMessagesApiEndpoint = "https://trialbot-api.line.me/v1/events";

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

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

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

    /**
     * アクセスログを残すか
     * @var boolean
     */
    protected $leaveAccessLog = true;

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

            if ($this->leaveAccessLog) {
                $this->writeAccessLog();
            }

            $text = $this->_trim($this->content->text);

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

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

            if (count($result) < 1) {
                $this->_replyText($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
     */
    protected function specifyContent()
    {
        $input = file_get_contents('php://input');
        $json = json_decode($input);

        return $json->result{0}->content;
    }

    /**
     * トリム(半角・全角スペース・改行コード)
     * @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(1500000);
        $this->sendMessage($message4);
        usleep(1000000);
        $this->sendMessage($message5);
    }

    /**
     * メッセージを送信
     * @param string $message
     */
    protected function sendMessage($message)
    {
        $content = [
            'contentType' => 1,
            'toType' => 1,
            'text' => $message
        ];
        $postData = $this->makePostData(self::EVENT_TYPE_SENDING_MESSAGES, $content);
        $this->callApi($this->sendMessagesApiEndpoint, $postData);
    }

    /**
     * 返答
     * @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);

        // テキストで返事をする
        $responseText = [
            'contentType' => 1,
            'toType' => 1,
            'text' => '【'.$this->_trim($this->content->text).'】ですね。わかります。'
        ];

        // 画像で返事をする
        $responseImage = [
            'contentType' => 2,
            'toType' => 1,
            'originalContentUrl' => $imgUrl,
            'previewImageUrl' => $imgUrl
        ];

        // Locationを返す
        $responseLocation = [
            'contentType' => 7,
            'toType' => 1,
            'text' => $capitaljp . ' ('.$capitalen.')',
            'location' => [
                'title' => $capitaljp . ' ('.$capitalen.')',
                'latitude' => $lat,
                'longitude' => $lon
            ]
        ];

        $messages[] = $responseText;
        $messages[] = $responseImage;
        $messages[] = $responseLocation;

        $postData = $this->makePostData(self::EVENT_TYPE_SENDING_MULTIPLE_MESSAGES, $messages);
        $result = $this->callApi($this->sendMessagesApiEndpoint, $postData);

        // $resultの中身
        // 成功 : {"failed":[],"messageId":"1467843217927","timestamp":1467843217927,"version":1}
        // 失敗 : {"statusCode":"422","statusMessage":"invalid users"}
        $response = json_decode($result);
        if (
            !$response
            || property_exists($response, 'failed') === false
            || count($response->failed) !== 0
        ) {
            Log::error('----- Response Error Log Start -----');
            Log::error(var_export($result, true));
            Log::error('----- Response Error Log End -----');
        }
    }

    /**
     * ポストデータ作成
     * @param string $eventType
     * @param array $content
     * @return array
     */
    protected function makePostData($eventType, array $content)
    {
        $_content = $this->makePostData4Content($eventType, $content);

        return [
            "to" => [$this->content->from],
            "toChannel" => "1383378250",
            "eventType" => $eventType,
            "content" => $_content
        ];
    }

    /**
     * ポストデータ用content作成
     * @param string $eventType
     * @param array $data
     * @return array
     */
    protected function makePostData4Content($eventType, array $content)
    {
        if ($eventType === self::EVENT_TYPE_SENDING_MESSAGES) {
            return $content;
        }

        if ($eventType === self::EVENT_TYPE_SENDING_MULTIPLE_MESSAGES) {
            return [
                "messageNotified" => 0,
                "messages" => $content
            ];
        }
    }

    /**
     * API実行
     * @param string $url
     * @param array $postData
     * @return mixed
     */
    protected function callApi($url, array $postData = [])
    {
        $ch = curl_init($url);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_HTTPHEADER, $this->header());

        if (count($postData) > 0) {
            curl_setopt($ch, CURLOPT_POST, true);
            curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
            curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($postData));
        }

        $result = curl_exec($ch);
        curl_close($ch);

        return $result;
    }

    /**
     * アクセスログ
     */
    protected function writeAccessLog()
    {
        $url = $this->profileApiEndpoint . $this->content->from;
        $result = $this->callApi($url);

        // Log::info(var_export($result, true));
        // 成功 : '{"contacts":[{"displayName":"XXX","mid":"XXX","pictureUrl":"XXX","statusMessage":"XXX"}],"count":1,"display":1,"pagingRequest":{"start":1,"display":1,"sortBy":"MID"},"start":1,"total":1}'
        // 失敗 : '{"contacts":[],"count":0,"display":1,"pagingRequest":{"start":1,"display":1,"sortBy":"MID"},"start":1,"total":1}'

        Log::info('----- Access Log Start -----');
        Log::info('----- User -> ' . json_decode($result)->contacts{0}->displayName);
        Log::info('----- Text -> ' . $this->content->text);
        Log::info('----- From -> ' . $this->content->from);
        Log::info('----- Access Log End   -----');
    }

    /**
     * ヘッダー
     * @return array
     */
    protected function header()
    {
        return [
            'Content-Type: application/json; charser=UTF-8',
            'X-Line-ChannelID: ' . self::CHANNEL_ID,
            'X-Line-ChannelSecret: ' . self::CHANNEL_SECRET,
            'X-Line-Trusted-User-With-ACL: ' . self::MID
        ];
    }
}

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

2016/07/14 ソースを修正しました
アクセスログを取る所で

Log::info('----- IP   -> ' . Request::ip());

って書いていたけど、これLineサーバのIPや。。(当たり前やん)  
というわけで消しました。

Laravelの場合、以下も修正します。
そうしないと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 = [
        'line_bot' // 上記のroutes.phpで設定したルートを書いておく
    ];
}

特徴

特徴としては、以下の通りです。

  • 国名が見つかったら、Sending multiple messagesを使って国旗、首都名、位置座標を一括で返す
    • ただし、メッセージは分割されて送信される
  • 国名が見つからなかったら、Sending messagesを使って時間をずらしながら川平慈英風に返答する

会話してみる

上記まで行ったら、お友達になって会話してみます。
見事に返答されたらOKです。

ちなみに我が家のトイレにある、世界地図(子供用)に載っている国は網羅出来ていると思われます。

まとめ

というわけでLineBotApiを使ってBotを書いてみました。

このBotと会話していると、こんなメロディーが僕の頭のなかで流れました。

カントリ〜ボォ〜ット この道〜 ず〜っとゆけば〜 あの街に〜 続いて〜る 気がす〜る カントリ〜ボォ〜ット

うそです。

こちらを参考にさせて頂きました

作成者: shimabox

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

コメントする

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

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