【Python】近くの喫茶店・カフェを教えてくれる LINE BOT作成の記録・解説

## Introduction
　Pythonの勉強をしながら、折角だから何か形になるものを作ろうと思い、LINE BOTを開発していました。
そして 2018年11月末に、動くところまではひっそり完成していました。  

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">LineBot、まだまだ想定通りじゃないとこもバグもあるけど動くとこまでできた！<br><br>エラー出て頭も働かずもう寝よ...と思って歯磨いてたら、エラー原因これか？と思い付き、その通りだった。<br><br>三上やら三中やら、やはりある</p>&mdash; kangetsu121 (@kangetsu_121) <a href="https://twitter.com/kangetsu_121/status/1067126614310367232?ref_src=twsrc%5Etfw">2018年11月26日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

　開発した LINE BOTの機能は、**「位置情報を送ると、近くの喫茶店・カフェを教えてくれる」**というシンプルなものです。
簡単に試せるので、よろしければ以下の QRコードから友達登録して、是非使ってみてください。
![cafe_guide_qr.png](https://qiita-image-store.s3.amazonaws.com/0/141199/a38bfdfb-23c7-d97a-88b9-d916cbab61d0.png)
 
　なお、本記事は、開発中無駄に遠回りしてしまったり、はまってしまったことなどを、自身の学習の記録としてアウトプットすること・知識の共有をすることを目的としています。
似たようなことに挑戦されている方のお役に立てば幸いです。

**※後述の Fixieを利用している関係で、月当たりの利用可能リクエスト数に制限があるため、そんなに混むことはないと思いますがもし万が一反応しなくなっていたらすみません。**

## 目次
1. 作成した BOT「喫茶案内所」の紹介
2. LINE Botの構成
3. 前提知識紹介
    1. Heroku
    2. Flask
    3. Web API
    4. Webhook
4. LINE BOT作成手順
5. 実装の解説
    - 5-1. サンプルスクリプトの確認
    - 5-2. 「喫茶案内所」のスクリプト解説
6. まとめ
7. 画像引用元サイト
8. 参考記事

## 1. 作成した BOT「喫茶案内所」の紹介
　まず、今回作成した BOTを紹介します。

1. まず友達登録してトーク画面に移動します。
2. トーク画面で下図の手順で位置情報を送ると

![IMG_0248-compressor_edited.png](https://qiita-image-store.s3.amazonaws.com/0/141199/b7440795-5752-ad44-7248-2105d9b02df5.png)
![IMG_0247-compressor_edited.png](https://qiita-image-store.s3.amazonaws.com/0/141199/88ff34cd-2eec-1df9-6c0f-773a47487d68.png)

3. 近くにカフェや喫茶店があれば、その情報とぐるなびのリンクを送ってくれます (マクドナルドはぐるなびではカフェ属性持ち判定)。

![IMG_0249-compressor_resized.png](https://qiita-image-store.s3.amazonaws.com/0/141199/f378a832-513c-8096-3660-54ea95fe0a67.png)

なお、2018/12/31現在、半径 400m以内に喫茶店やカフェがない場合は、何も反応してくれません。。
が、近日中に改修予定です！

詳しい実装については、5で解説を。


## 2. LINE BOTの構成
　初めに、今回作成した LINE BOTの構成を紹介します。
主な登場人物 (?) は、**クライアント**、**サーバーとして利用した Heroku**、**インターフェースとして利用した LINE**の 3種です。
![qiita_img1.png](https://qiita-image-store.s3.amazonaws.com/0/141199/dc3f36b4-822c-7530-3051-5c6396176556.png)

図の左下がクライアント、中心がLINE BOTのアカウント、右が BOTの本体となる Pythonコードが実際に存在し、動作するサーバーです。
今回は、サーバーに Herokuを利用しています。

## 3. 前提知識紹介
　次に、今回の Pythonでの LINE BOT作成に必要となる前提知識を紹介します。
大きく、以下の 4種があります。

1. サーバーとしての **Heroku**
2. Python Webアプリのフレームワークである **Flask**
3. **Web API**
  - [LINE Messaging API](https://developers.line.biz/ja/docs/messaging-api/)
  - [ぐるなび API](https://api.gnavi.co.jp/api/manual/restsearch/)
4. **Webhook**

### 3-1. サーバーとしての Heroku
　[公式サイトの説明](https://jp.heroku.com/about) を引用してみましょう。

> Heroku はコンテナベースのクラウド型 PaaS（サービスとしてのプラットフォーム）です。
> ......
> Heroku はフルマネージドのプラットフォームであるため、開発者がサーバーやハードウェア、インフラストラクチャの管理に煩わされることなく、製品開発に没頭できます。......

Herokuは PaaSと呼ばれるサービスです。
クラウドサービスというと、最近では AWSを連想することが多いのではないかと思いますが、AWSは IaaS (Infrastructure as a Service) です。

IaaSと PaaSの違いとしては、IaaSではインフラまでが用意されているだけで、Webサーバーや DBなどのミドルウェアは自分でインストールしなければなりません。
ただし、その分自由度はあります。

一方、PaaSでは、インフラに加え、初めからミドルウェアなどが用意されており、比較的簡単にアプリをデプロイできたりします。
ただし、その分 IaaSほどの自由度はない様子です。

Herokuは PaaSなので、コードを用意するだけで、Webアプリ、今回で言えば BOTをデプロイできます。
Herokuでは、用意した Herokuサーバーに対し、**gitでコードなどを push** することでデプロイできます。

詳しい利用法については、英語ですが [公式のチュートリアル](https://devcenter.heroku.com/articles/getting-started-with-python) があるので、そちらをなぞるとある程度摑めると思います。

なお、LINEの公式ドキュメントでも、Herokuを利用した [BOTの作成チュートリアル](https://developers.line.biz/ja/docs/messaging-api/building-sample-bot-with-heroku/) があるので、こちらはかなり参考になると思います。

### 3-2. Python Webアプリのフレームワークである Flask
　Pythonを用いた [LINE BOTのサンプル](https://github.com/line/line-bot-sdk-python) は、Webアプリフレームワークの Flaskを利用しています。

Pythonの Webアプリフレームワークといえば Djangoを想像する方が多いと思いますが、Flaskも人気のあるフレームワークで、Djangoよりもシンプルな分使い易いものとなっているようです (公式ドキュメント: http://flask.pocoo.org/docs/1.0/)。

とはいえ、実は LINE BOTをデプロイするだけなら Flaskの知識はほとんど要りません。
私は頑張って [Flaskのチュートリアル](http://flask.pocoo.org/docs/1.0/tutorial/) を終えてから実装してみたのですが、ルーティングにしか使いませんでした。。

　ちなみにルーティングとは、大雑把に言えば URLと関数を紐づけることです。
LINE BOTを作るだけなら、実装によってはこれすら必須でもない気もしてきましたが、最低限ドキュメントの [Routing](http://flask.pocoo.org/docs/1.0/quickstart/#routing) の部分だけ理解できていればよいと思います。
なお、ルーティングに利用している Pythonの文法、**デコレーター** について理解が不安な方・再確認したい方は、是非前回の記事 [Pythonのデコレータの基本：使い方から functools.wrapsの利用まで](https://qiita.com/kangetsu121/items/039b1c236f735a14c472) をご覧ください。


### 3-3. Web API
今回利用している Web APIは、以下の 2種です。

- LINE Messaging API
- ぐるなび API

#### LINE Messaging API
LINE Messaging APIは、LINEの BOTに利用する APIです。
普段 LINEを利用する時に、メッセージを送信したり、画像を送信したり、スタンプを送信したりします。
こうした動作を、実際に人が操作するのではなく、今回だと **Pythonのコードから利用・制御するためのもの** が、LINE Messaging APIです。
これが BOTの自動動作を実現しています。

使い方については、[公式ドキュメント](LINE Messaging API) と、LINE公式が GitHubに上げている[サンプルコード](https://github.com/line/line-bot-sdk-python) を見比べながら進めると、理解し易いと思います。

#### ぐるなび API
ぐるなび APIは、今回喫茶店・カフェの情報を取得するために利用しました。
[レストラン検索 API](https://api.gnavi.co.jp/api/manual/restsearch/) を利用しています。
使い方も簡単ですし、こちらの[テストページ](https://api.gnavi.co.jp/api/tools/) では、様々なパラメータを利用した際の結果の確認もできます。

制約として、当然ですが **ぐるなびに登録されている店舗情報しか取得することはできません**。
このため、実際は近くに他のお店があっても、ぐるなびに登録されていないため、ぐるなび APIを利用した検索では取得できない、といったことが起きてしまいます。
ここは仕方ないですね。。

なお、喫茶店・カフェ情報取得のための APIとして、ホットペッパーの[グルメサーチ API](https://webservice.recruit.co.jp/hotpepper/reference.html) もあったのですが、こちらは 2018年11月現在でリクエスト URLが httpのみとなっていたため、httpsに対応しているぐるなび APIを利用しました。

### 3-4. Webhook
Webhookは、LINEの BOTの動作を理解するために必要な概念です。

詳細は @soarflatさんの記事 ([Webhookとは？](https://qiita.com/soarflat/items/ed970f6dc59b2ab76169)) や、英語ですが[こちらの記事](https://hackernoon.com/webhook-vs-api-whats-the-difference-8d41e6661652) が分かりやすかったと思います。

要約すれば、**「何らかのイベントをトリガーとして、予め指定した URLに POSTリクエストを投げる」** ことです。
今回は、

- トリガーとなる「イベント」は **「クライアントからの喫茶案内所への位置情報の投稿」** 
- 「指定した URL」は **「Heroku・Pythonコードで設定したサーバー URL」** 

に当たります。
後ほど、LINEのアカウント管理画面で「Webhook URL」を設定する箇所が出てきますが、ここで Heroku・Pythonコードで設定したサーバー URLを指定することとなります。

## 4. LINE BOT作成手順
　次に、LINE BOTの作成手順を簡単に紹介します。
詳しくは、やはり [公式ドキュメント](https://developers.line.biz/ja/docs/messaging-api/building-bot/) を参照するのが良いです。
かいつまんで手順を紹介すると、以下の通りとなります。

1. BOT用の **チャネル** を作成
  - チャネル：BOT用の LINEアカウントのようなもの
2. ボットをホストする **サーバー** を作成 (今回の例では Heroku)
3. インターフェースであるチャネルとボット間の通信設定
  - チャネル -> サーバー上のボット (**Webhook URL**)
  - サーバー上のボット -> チャネル (Messaging API呼び出しのための **チャネルアクセストークン**)
4. ボットアプリの実装 (今回の例では Python)

## 5. 実装の解説
　ここまでで、Herokuサーバー、LINE BOTチャネルの準備ができました。
次はいよいよ、BOTの挙動を司る Pythonスクリプトの解説をします。

### 5-1. サンプルスクリプトの確認
まずは、公式が用意してくれている flask-echoという、オウム返しをするスクリプトの実装を見てみます。

```python:app.py
# -*- coding: utf-8 -*-

#  Licensed under the Apache License, Version 2.0 (the "License"); you may
#  not use this file except in compliance with the License. You may obtain
#  a copy of the License at
#
#       https://www.apache.org/licenses/LICENSE-2.0
#
#  Unless required by applicable law or agreed to in writing, software
#  distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
#  WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
#  License for the specific language governing permissions and limitations
#  under the License.

from __future__ import unicode_literals

import os
import sys
from argparse import ArgumentParser

from flask import Flask, request, abort
from linebot import (
    LineBotApi, WebhookParser
)
from linebot.exceptions import (
    InvalidSignatureError
)
from linebot.models import (
    MessageEvent, TextMessage, TextSendMessage,
)

app = Flask(__name__)

# get channel_secret and channel_access_token from your environment variable
channel_secret = os.getenv('LINE_CHANNEL_SECRET', None)
channel_access_token = os.getenv('LINE_CHANNEL_ACCESS_TOKEN', None)
if channel_secret is None:
    print('Specify LINE_CHANNEL_SECRET as environment variable.')
    sys.exit(1)
if channel_access_token is None:
    print('Specify LINE_CHANNEL_ACCESS_TOKEN as environment variable.')
    sys.exit(1)

line_bot_api = LineBotApi(channel_access_token)
parser = WebhookParser(channel_secret)


@app.route("/callback", methods=['POST'])
def callback():
    signature = request.headers['X-Line-Signature']

    # get request body as text
    body = request.get_data(as_text=True)
    app.logger.info("Request body: " + body)

    # parse webhook body
    try:
        events = parser.parse(body, signature)
    except InvalidSignatureError:
        abort(400)

    # if event is MessageEvent and message is TextMessage, then echo text
    for event in events:
        if not isinstance(event, MessageEvent):
            continue
        if not isinstance(event.message, TextMessage):
            continue

        line_bot_api.reply_message(
            event.reply_token,
            TextSendMessage(text=event.message.text)
        )

    return 'OK'


if __name__ == "__main__":
    arg_parser = ArgumentParser(
        usage='Usage: python ' + __file__ + ' [--port <port>] [--help]'
    )
    arg_parser.add_argument('-p', '--port', type=int, default=8000, help='port')
    arg_parser.add_argument('-d', '--debug', default=False, help='debug')
    options = arg_parser.parse_args()

    app.run(debug=options.debug, port=options.port)
```

#### Apache Licence 2.0の表記
```python:app.py-1
#  Licensed under the Apache License, Version 2.0 (the "License"); you may
#  not use this file except in compliance with the License. You may obtain
#  a copy of the License at
#
#       https://www.apache.org/licenses/LICENSE-2.0
#
#  Unless required by applicable law or agreed to in writing, software
#  distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
#  WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
#  License for the specific language governing permissions and limitations
#  under the License.
```
冒頭数行のコメントは、このサンプルが **Apache License 2.0** に従っていることを示すものです。
各種オープンソースソフトウェアのライセンスについては、[知らないと損をする６つのライセンスまとめ](http://mycode.snow69it.net/998/) などで分かり易くまとめてくださっています。

Apache License 2.0はこうしたライセンスの中で最も制限が緩く、改変や商用利用も認められています。
改変などした場合も、Apache License 2.0に従っていることを明記してあればよいので、以降このサンプルを改変したスクリプト中でも、Apache Licence 2.0のコメントは残してあります。

#### LineBotApi, WebhookParserのインスタンス化

```python:app.py-2
# get channel_secret and channel_access_token from your environment variable
channel_secret = os.getenv('LINE_CHANNEL_SECRET', None)
channel_access_token = os.getenv('LINE_CHANNEL_ACCESS_TOKEN', None)
if channel_secret is None:
    print('Specify LINE_CHANNEL_SECRET as environment variable.')
    sys.exit(1)
if channel_access_token is None:
    print('Specify LINE_CHANNEL_ACCESS_TOKEN as environment variable.')
    sys.exit(1)

line_bot_api = LineBotApi(channel_access_token)
parser = WebhookParser(channel_secret)
```
末尾2行で、[LineBotApi](https://github.com/line/line-bot-sdk-python#linebotapi) と [WebhookParser](https://github.com/line/line-bot-sdk-python#webhookparser) のインスタンスを作成しています。
その前準備として、**サーバーの環境変数** に登録した、LINEチャネルの **Channel Secret** と **アクセストークン** を取得しています。
**もしこれらをソースコードにべた書きしていると、GitHubなどにコードを上げた際に自分の BOT用の LINEアカウントが他の人からも利用できるようなリスクがある** ので、サーバーの環境変数に設定して、`os.getenv()` 等で取得するようにしましょう。

#### リクエスト URLと処理関数の紐づけ (Routing)

```python:app.py-3
@app.route("/callback", methods=['POST'])
def callback():
    signature = request.headers['X-Line-Signature']

    # get request body as text
    body = request.get_data(as_text=True)
    app.logger.info("Request body: " + body)

    # parse webhook body
    try:
        events = parser.parse(body, signature)
    except InvalidSignatureError:
        abort(400)

    # if event is MessageEvent and message is TextMessage, then echo text
    for event in events:
        if not isinstance(event, MessageEvent):
            continue
        if not isinstance(event.message, TextMessage):
            continue

        line_bot_api.reply_message(
            event.reply_token,
            TextSendMessage(text=event.message.text)
        )

    return 'OK'
```

ここで、Flaskの `route` メソッドを使って、`/callback` という URLと、`callback` という関数を紐づけています。
なお、Webhook URLは、下図のように LINEのチャネルの設定画面で設定できます。
サンプルが callbackになっているのでそのまま `https://{heroku_URL}/callback` にしていますが、`callback` でなくてもよいのだと思います。
![line_webhookurl-compressor.png](https://qiita-image-store.s3.amazonaws.com/0/141199/12e03ed5-b30f-5e04-6668-11eff48bcdb9.png)
※私の herokuサーバーの URLが書いてあるので隠してます

ここまでで、Herokuの環境変数、LINEチャネルの設定が完了していれば、Gitでサンプルコードを herokuに pushすることで、テキストメッセージをオウム返しする LINE BOTができるかと思います。
次から、今回作成した「喫茶案内所」になるように、このサンプルに加えた改変を解説していきます。

### 5-2. 「喫茶案内所」のスクリプト解説
　喫茶案内所のスクリプトは、以下です (なお、まだまだ作成中で TODOが多く書いてあり、使われていないコードもありますがご了承ください。後ほど GitHubに上げようと思います)。

```python:cafe-guide_app.py
# -*- coding: utf-8 -*-

#  Licensed under the Apache License, Version 2.0 (the "License"); you may
#  not use this file except in compliance with the License. You may obtain
#  a copy of the License at
#
#       http://www.apache.org/licenses/LICENSE-2.0
#
#  Unless required by applicable law or agreed to in writing, software
#  distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
#  WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
#  License for the specific language governing permissions and limitations
#  under the License.

import json
import os
import sys
import urllib.request
import urllib.parse

from flask import Flask, request, abort
from linebot import (
    LineBotApi, WebhookHandler
)
from linebot.exceptions import (
    InvalidSignatureError, LineBotApiError
)
from linebot.models import (
    CarouselColumn, CarouselTemplate, FollowEvent,
    LocationMessage, MessageEvent, TemplateSendMessage,
    TextMessage, TextSendMessage, UnfollowEvent, URITemplateAction
)

# TODO: 位置情報を送るメニューボタンの配置
# TODO: Webサーバを利用して静的ファイルを相対参照

# get api_key, channel_secret and channel_access_token from environment variable
GNAVI_API_KEY = os.getenv('GNAVI_API_KEY')
CHANNEL_SECRET = os.getenv('LINE_CHANNEL_SECRET')
CHANNEL_ACCESS_TOKEN = os.getenv('LINE_CHANNEL_ACCESS_TOKEN')
BOT_SERVER_URL = os.getenv('BOT_SERVER_URL')
os.environ['http_proxy'] = os.getenv('FIXIE_URL')
os.environ['https_proxy'] = os.getenv('FIXIE_URL')

if GNAVI_API_KEY is None:
    print('Specify GNAVI_API_KEY as environment variable.')
    sys.exit(1)
if CHANNEL_SECRET is None:
    print('Specify LINE_CHANNEL_SECRET as environment variable.')
    sys.exit(1)
if CHANNEL_ACCESS_TOKEN is None:
    print('Specify LINE_CHANNEL_ACCESS_TOKEN as environment variable.')
    sys.exit(1)
if BOT_SERVER_URL is None:
    print('Specify BOT_SERVER_URL as environment variable.')
    sys.exit(1)
if os.getenv('FIXIE_URL') is None:
    print('Specify FIXIE_URL as environment variable.')
    sys.exit(1)

# instantiation
# TODO: インスタンス生成はグローバルでなくファクトリメソッドに移したい
# TODO: グローバルに参照可能な api_callerを作成するか, 個々に作成するかどちらが良いか確認
app = Flask(__name__)
line_bot_api = LineBotApi(CHANNEL_ACCESS_TOKEN)
handler = WebhookHandler(CHANNEL_SECRET)

RESTSEARCH_URL = "https://api.gnavi.co.jp/RestSearchAPI/v3/"
DEF_ERR_MESSAGE = """
申し訳ありません、データを取得できませんでした。
少し時間を空けて、もう一度試してみてください。
"""
NO_HIT_ERR_MESSAGE = "お近くにぐるなびに登録されている喫茶店はないようです" + chr(0x100017)
LINK_TEXT = "ぐるなびで見る"
FOLLOWED_RESPONSE = "フォローありがとうございます。位置情報を送っていただくことで、お近くの喫茶店をお伝えします" + chr(0x100059)


def call_restsearch(latitude, longitude):
    query = {
        "keyid": GNAVI_API_KEY,
        "latitude": latitude,
        "longitude": longitude,
        # TODO: category_sを動的に生成
        "category_s": "RSFST18008,RSFST18009,RSFST18010,RSFST18011,RSFST18012"
        # TODO: hit_per_pageや offsetの変更に対応 (e.g., 指定可能にする, 多すぎるときは普通にブラウザに飛ばす, など)
        # TODO: rangeをユーザーアクションによって選択可能にしたい
        # "range": search_range
    }
    params = urllib.parse.urlencode(query, safe=",")
    response = urllib.request.urlopen(RESTSEARCH_URL + "?" + params).read()
    result = json.loads(response)

    if "error" in result:
        if "message" in result:
            raise Exception("{}".format(result["message"]))
        else:
            raise Exception(DEF_ERR_MESSAGE)

    total_hit_count = result.get("total_hit_count", 0)
    if total_hit_count < 1:
        raise Exception(NO_HIT_ERR_MESSAGE)

    return result


@app.route("/callback", methods=['POST'])
def callback():
    signature = request.headers['X-Line-Signature']

    body = request.get_data(as_text=True)
    app.logger.info("Request body: " + body)

    # handle webhook body
    try:
        handler.handle(body, signature)
    except InvalidSignatureError:
        abort(400)
    except LineBotApiError as e:
        app.logger.exception(f'LineBotApiError: {e.status_code} {e.message}', e)
        raise e

    return 'OK'


@handler.add(MessageEvent, message=TextMessage)
def handle_text_message(event):
    line_bot_api.reply_message(
        event.reply_token,
        TextSendMessage(text=event.message.text)
    )


# TODO: ちゃんと例外処理
@handler.add(MessageEvent, message=LocationMessage)
def handle_location_message(event):
    user_lat = event.message.latitude
    user_longit = event.message.longitude

    cafe_search_result = call_restsearch(user_lat, user_longit)
    print("cafe_search_result is: {}".format(cafe_search_result))

    response_json_list = []

    # process result
    for (count, rest) in enumerate(cafe_search_result.get("rest")):
        # TODO: holiday, opentimeで表示を絞りたい
        access = rest.get("access", {})
        access_walk = "徒歩 {}分".format(access.get("walk", ""))
        holiday = "定休日: {}".format(rest.get("holiday", ""))
        image_url = rest.get("image_url", {})
        image1 = image_url.get("shop_image1", "thumbnail_template.jpg")
        if image1 == "":
            image1 = BOT_SERVER_URL + "/static/thumbnail_template.jpg"
        name = rest.get("name", "")
        opentime = "営業時間: {}".format(rest.get("opentime", ""))
        # pr = rest.get("pr", "")
        # pr_short = pr.get("pr_short", "")
        url = rest.get("url", "")

        result_text = opentime + "\n" + holiday + "\n" + access_walk + "\n"
        if len(result_text) > 60:
            result_text = result_text[:56] + "..."

        result_dict = {
            "thumbnail_image_url": image1,
            "title": name,
            # "text": pr_short + "\n" + opentime + "\n" + holiday + "\n"
            # + access_walk + "\n",
            "text": result_text,
            "actions": {
                "label": "ぐるなびで見る",
                "uri": url
            }
        }
        response_json_list.append(result_dict)
    print("response_json_list is: {}".format(response_json_list))
    columns = [
        CarouselColumn(
            thumbnail_image_url=column["thumbnail_image_url"],
            title=column["title"],
            text=column["text"],
            actions=[
                URITemplateAction(
                    label=column["actions"]["label"],
                    uri=column["actions"]["uri"],
                )
            ]
        )
        for column in response_json_list
    ]
    # TODO: GoogleMapへのリンク実装

    messages = TemplateSendMessage(
        alt_text="喫茶店の情報をお伝えしました",
        template=CarouselTemplate(columns=columns),
    )
    print("messages is: {}".format(messages))

    line_bot_api.reply_message(
        event.reply_token,
        messages=messages
    )


@handler.add(FollowEvent)
def handle_follow(event):
    line_bot_api.reply_message(
        event.reply_token, TextSendMessage(text=FOLLOWED_RESPONSE)
    )


@handler.add(UnfollowEvent)
def handle_unfollow():
    app.logger.info("Got Unfollow event")


if __name__ == "__main__":
    # arg_parser = ArgumentParser(
    #     usage='Usage: python ' + __file__ + ' [--port <port>] [--help]'
    # )
    # arg_parser.add_argument('-p', '--port', type=int, default=8000, help='port')
    # arg_parser.add_argument('-d', '--debug', default=False, help='debug')
    # options = arg_parser.parse_args()
    #
    # app.run(debug=options.debug, port=options.port)
    port = int(os.getenv("PORT", 5000))
    app.run(host="0.0.0.0", port=port)

```

#### 環境変数の取得

```python:cafe-guide_app.py-1
# get api_key, channel_secret and channel_access_token from environment variable
GNAVI_API_KEY = os.getenv('GNAVI_API_KEY')
CHANNEL_SECRET = os.getenv('LINE_CHANNEL_SECRET')
CHANNEL_ACCESS_TOKEN = os.getenv('LINE_CHANNEL_ACCESS_TOKEN')
BOT_SERVER_URL = os.getenv('BOT_SERVER_URL')
os.environ['http_proxy'] = os.getenv('FIXIE_URL')
os.environ['https_proxy'] = os.getenv('FIXIE_URL')

if GNAVI_API_KEY is None:
    print('Specify GNAVI_API_KEY as environment variable.')
    sys.exit(1)
if CHANNEL_SECRET is None:
    print('Specify LINE_CHANNEL_SECRET as environment variable.')
    sys.exit(1)
if CHANNEL_ACCESS_TOKEN is None:
    print('Specify LINE_CHANNEL_ACCESS_TOKEN as environment variable.')
    sys.exit(1)
if BOT_SERVER_URL is None:
    print('Specify BOT_SERVER_URL as environment variable.')
    sys.exit(1)
if os.getenv('FIXIE_URL') is None:
    print('Specify FIXIE_URL as environment variable.')
    sys.exit(1)
```

公式サンプルで環境変数に設定していた 2種に加え、ぐるなび APIキー、Herokuサーバーの URL、Herokuのアドオンである、固定 IPを発行する `Fixie` ([Python用のドキュメントはこちら](https://devcenter.heroku.com/articles/fixie#using-with-python)) の URLを設定しています。
Fixieは、セキュリティ対策として、LINEの APIを呼び出すサーバーを制限するホワイトリストに固定の IPを指定するため利用しています (ここまでする必要はないと思いますが、折角なので使ってみました)。
Fixieを使わない場合、Herokuの IPは動的に変わってしまうため、ホワイトリストに固定の IPを指定できないためです。

なお、ここでは WebhookParserではなく、WebhookHandlerを利用しています。

#### ぐるなび API利用の関数定義

続いて、ぐるなび APIを呼び出す関数の定義です。

```python:cafe-guide_app.py-2
def call_restsearch(latitude, longitude):
    query = {
        "keyid": GNAVI_API_KEY,
        "latitude": latitude,
        "longitude": longitude,
        # TODO: category_sを動的に生成
        "category_s": "RSFST18008,RSFST18009,RSFST18010,RSFST18011,RSFST18012"
        # TODO: hit_per_pageや offsetの変更に対応 (e.g., 指定可能にする, 多すぎるときは普通にブラウザに飛ばす, など)
        # TODO: rangeをユーザーアクションによって選択可能にしたい
        # "range": search_range
    }
    params = urllib.parse.urlencode(query, safe=",")
    response = urllib.request.urlopen(RESTSEARCH_URL + "?" + params).read()
    result = json.loads(response)

    if "error" in result:
        if "message" in result:
            raise Exception("{}".format(result["message"]))
        else:
            raise Exception(DEF_ERR_MESSAGE)

    total_hit_count = result.get("total_hit_count", 0)
    if total_hit_count < 1:
        raise Exception(NO_HIT_ERR_MESSAGE)

    return result
```

位置情報に含まれている緯度、経度情報をこの関数に渡して、辞書形式で APIに渡すクエリを作成し、送信して結果を受け取ります。
なお、`category_s` に複数のパラメータを渡すために、`params = urllib.parse.urlencode(query, safe=",")` と、`urllib` の `safe` オプションを使っています。
`request` モジュールではエンコードしたくない文字列 (今回で言うと ",") の指定が面倒だったため、あえてこちらを利用しました。

#### 位置情報送信イベントを受け取った際の処理定義

```python:cafe-guide_app.py-3
@handler.add(MessageEvent, message=LocationMessage)
def handle_location_message(event):
    user_lat = event.message.latitude
    user_longit = event.message.longitude

    cafe_search_result = call_restsearch(user_lat, user_longit)
    print("cafe_search_result is: {}".format(cafe_search_result))

    response_json_list = []

    # process result
    for (count, rest) in enumerate(cafe_search_result.get("rest")):
        # TODO: holiday, opentimeで表示を絞りたい
        access = rest.get("access", {})
        access_walk = "徒歩 {}分".format(access.get("walk", ""))
        holiday = "定休日: {}".format(rest.get("holiday", ""))
        image_url = rest.get("image_url", {})
        image1 = image_url.get("shop_image1", "thumbnail_template.jpg")
        if image1 == "":
            image1 = BOT_SERVER_URL + "/static/thumbnail_template.jpg"
        name = rest.get("name", "")
        opentime = "営業時間: {}".format(rest.get("opentime", ""))
        # pr = rest.get("pr", "")
        # pr_short = pr.get("pr_short", "")
        url = rest.get("url", "")

        result_text = opentime + "\n" + holiday + "\n" + access_walk + "\n"
        if len(result_text) > 60:
            result_text = result_text[:56] + "..."

        result_dict = {
            "thumbnail_image_url": image1,
            "title": name,
            # "text": pr_short + "\n" + opentime + "\n" + holiday + "\n"
            # + access_walk + "\n",
            "text": result_text,
            "actions": {
                "label": "ぐるなびで見る",
                "uri": url
            }
        }
        response_json_list.append(result_dict)
    print("response_json_list is: {}".format(response_json_list))
    columns = [
        CarouselColumn(
            thumbnail_image_url=column["thumbnail_image_url"],
            title=column["title"],
            text=column["text"],
            actions=[
                URITemplateAction(
                    label=column["actions"]["label"],
                    uri=column["actions"]["uri"],
                )
            ]
        )
        for column in response_json_list
    ]
    # TODO: GoogleMapへのリンク実装

    messages = TemplateSendMessage(
        alt_text="喫茶店の情報をお伝えしました",
        template=CarouselTemplate(columns=columns),
    )
    print("messages is: {}".format(messages))

    line_bot_api.reply_message(
        event.reply_token,
        messages=messages
    )
```

位置情報メッセージを受け取ったらそこから緯度、経度を取り出し、上で定義した `call_restsearch` に渡して、その結果をカルーセルに順次表示しています。

なお、ここでいくつかはまったポイントがありました。

- カルーセル利用時、結果一つ当たりのテキスト最大文字数は 60文字まで
エラーが出るため、Herokuでデバッグした上記制限が判明しました。
この対応のため、以下処理で文字数の調整をしています。

```python
if len(result_text) > 60:
    result_text = result_text[:56] + "..."
```

- 画像が登録されていない結果が含まれるのに画像情報を受け取ろうとするとエラーとなる

店舗によっては、画像がぐるなびに登録されていない場合があります。
その場合に、画像情報を結果として表示しようとすると、エラーとなっていました。
このため、以下処理で画像データがない場合はデフォルトのサムネイルを参照するようにし、エラーを回避しています。

```python
image_url = rest.get("image_url", {})
image1 = image_url.get("shop_image1", "thumbnail_template.jpg")
if image1 == "":
    image1 = BOT_SERVER_URL + "/static/thumbnail_template.jpg"
```

## 6. まとめ
　(自分で 2018年を締切としていたため) 駆け足になった感がありますが、いかがでしたでしょうか。
私は遠回りをしてしまいましたが、Pythonで LINE BOTを作成する場合、Pythonの基礎を除いて最低限必要な知識・技能は、以下で十分かと思います。

- Gitを利用できる
- Herokuでサーバーを起ち上げる
- Web APIの知識 (ドキュメントを読んで利用方法が分かれば十分)
- LINEの SDK、ドキュメント等を読んで理解できる
- Herokuなどでログを読み、デバッグできる

なお、詳しく説明していない個所もありますが、ぜひ各種ドキュメントを読んで挑戦してみてください。
Herokuや LINEの SDKなど、英語が主になってしまいますが、今後開発を続ける上では避けられない部分ではあるので、LINE BOTの作成を機会に取り組んでみるのも良いと思います。

また、LINE BOTは HTMLや CSSなど、フロントエンドの知識・デザイン性が不要なので、その点ではハードルが低く、何か作ってみたい方にはお勧めです。

近々加筆・修正をするかと思いますが、本記事が LINE BOTに挑戦しようとされている方の一助となれば幸いです。


## 7. 画像引用元サイト
喫茶案内所の画像、またデフォルトのサムネイル画像に利用したコーヒーのイラストは、以下サイト様のものを使用させていただきました。

[フリーイラスト素材 「趣味で作ったイラストを配るサイト」](https://illustration.jp.net/)

また、解説の画像に利用したアイコン等は、以下サイト様から利用させていただきました。

- LINEアイコン: https://line.me/ja/logo
- herokuアイコン: https://brand.heroku.com/
- Pythonアイコン: https://www.python.org/community/logos/
- スマートフォン: http://flat-icon-design.com/
- 位置情報アイコン: http://icooon-mono.com/

## 8. 参考記事
　今回の LINE BOT作成に当たり、主に以下記事を参考にさせていただきました。ありがとうございました！

- [Flex Message を使ってリッチUIなLINEボットを作る](https://qiita.com/tag1216/items/1a1ad0d963dc8f7413d5)