近くのフリーWi-Fiを教えてくれるLINE Botを作りました

はじめに

東京都オープンデータカタログサイトにあるなにかしらのデータと、LINE Messaging APIを使って、なにかしらのものを作りたいと思ったので、LINE Botを作りました。

作ったもの

「Wi-Fi天使」というLINE Botを作りました。

友達に追加後、トーク画面から位置情報を送信すると、その位置から近いWi-Fiスポットを検索できます。 今のところ、”東京都”が設置しているフリーWi-Fi(FREE_Wi-Fi_and_TOKYO)のみ検索できます。

よかったら、使ってみてください。

全体像

「Wi-Fi天使」で動いているモジュールの構成を図にしました。

作業1 LINE Official Accountの準備

LINE Official Accountを用意して、LINE Messaging APIを使用できるようにするまでの手順は、インターネット上にたくさんあるので割愛します。

作業2 データベースの準備

Google Cloud SQLのMySQLを使って、フリーWi-Fiの位置情報を集めたデータベースを作成します。

Google Cloud Platform上で、Google Cloud SQLを使用できるようになるまでの手順も、インターネット上にたくさんあるので割愛します。テーブルを作成するSQLと、テーブルに挿入するデータの内容を説明します。

データは今後追加していけばいいので、今回は、初期データとして、東京都オープンデータカタログサイトの公衆無線LANアクセスポイント一覧を使ってみることにしました。

以下のSQLでテーブル作成をしてから、上記のCSVデータを挿入しました。

create database free_wifi_bot_db;

create table free_wifi_bot_db.free_wifi (
    id int AUTO_INCREMENT, 
    prefecture_code char(6),
    no int,
    prefecture_name varchar(10),
    municipality varchar(10),
    name varchar(255),
    name_katakana varchar(255),
    name_english varchar(255),
    address varchar(255),
    detail_address_info varchar(255),
    latitude double(9,6),
    longitude double(9,6),
    provider varchar(255),
    phone_number varchar(20),
    extension_number varchar(20),
    ssid varchar(255),
    provided_area varchar(255),
    url varchar(255),
    note varchar(255),
    date_last_verified date,
    index(id)
);

作業3 Web Botの作成

Google App EngineでWeb Botを作成します。使用した言語は、Pythonです。

Google Cloud Platform上で、Google App Engineを使用できるようになるまでの手順も、インターネット上にたくさんあるので割愛します。「Wi-Fi天使」からHTTP POSTリクエストを受信したとき、リプライメッセージを作成して、「Wi-Fi天使」にリプライメッセージの送信要求を送信する方法を説明します。

LINEユーザから受け取ったメッセージに付加されている緯度経度(ユーザ指定位置)を取得します。

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

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

    try:
        handler.handle(body, signature)
    except InvalidSignatureError:
        abort(400)

    return 'OK'

@handler.add(MessageEvent, message=LocationMessage)
def handle_location(event):
    # 指定された緯度経度
    lat = event.message.latitude
    lon = event.message.longitude

if __name__ == "__main__":
    app.run()

「ユーザ指定位置からWi-Fi位置までの距離を昇順にソートしてTop 5を得る」クエリを作成しました。

SELECT id, name, address, detail_address_info, latitude, longitude, ssid, url, 
GLENGTH(GEOMFROMTEXT(CONCAT('LINESTRING(指定経度 指定緯度,',longitude,' ',latitude,')'))) AS distance 
FROM free_wifi 
ORDER BY distance 
LIMIT 5;

データベースに接続して、クエリを投げます。 指定された緯度経度に近い(Top 5)Wi-Fi情報を取得します。

@handler.add(MessageEvent, message=LocationMessage)
def handle_location(event):
    # 指定された緯度経度
    lat = event.message.latitude
    lon = event.message.longitude

    # データベースに接続
    db = sqlalchemy.create_engine(
        sqlalchemy.engine.url.URL(
            drivername='mysql+pymysql',
            username=MYSQL_USER,
            password=MYSQL_PASSWORD,
            database=MYSQL_DATABASE,
            query={
                'unix_socket': '/cloudsql/{}'.format(MYSQL_CONNECTION_NAME)
            }
        ),
    )

    # クエリ
    select_sql = 'SELECT id, name, address, detail_address_info, latitude, longitude, ssid, url, '
    select_sql += 'GLENGTH(GEOMFROMTEXT(CONCAT(\'LINESTRING({} {},\',longitude,\' \',latitude,\')\'))) AS distance '.format(lon, lat)
    select_sql += 'FROM free_wifi '
    select_sql += 'ORDER BY distance '
    select_sql += 'LIMIT {}'.format(MAX_WIFI_NUM)
   
    # 返答メッセージに書く情報を格納するリスト
    response_json_list = []
    
    with db.connect() as conn:
        recent_votes = conn.execute(select_sql).fetchall()
        # クエリ実行結果の行数分ループ
        for row in recent_votes:
            map_id = len(response_json_list) + 1
            name = row[1]
            ssid = row[6]
            distance = float(row[8])
            km_per_degree = 40075.0 / 360.0
            km = distance * km_per_degree
            m = math.floor(km * 1000)
            title = str(map_id) + ':' + name

            result = {
                "title": title,
                "text": '指定位置から{}m\nSSID:{}'.format(m, ssid)
            }

            response_json_list.append(result)

得られたWi-Fi情報をカルーセル型メッセージにして送ります。

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

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

    try:
        handler.handle(body, signature)
    except InvalidSignatureError:
        abort(400)

    return 'OK'

@handler.add(MessageEvent, message=LocationMessage)
def handle_location(event):
    # 略

    columns = [
        CarouselColumn(
            title=column["title"],
            text=column["text"],
            actions=[
                # 略
            ]
        )
        for column in response_json_list
    ]

    messages = [
        # 略
        TemplateSendMessage(
            alt_text="近くのWi-Fi情報",
            template=CarouselTemplate(columns=columns),
        )
    ]

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

if __name__ == "__main__":
    app.run()

もし、位置情報以外(テキストメッセージ等)を受信したとき、「位置情報を送ってね」と返信することにしました。

@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):
    line_bot_api.reply_message(
        event.reply_token,
        [
            TextSendMessage(text='下のボタンから位置情報を送ってね')
        ]
    )

Wi-Fiの位置が分かるよう、地図画像をLINEユーザに送ります。Wi-Fiの位置がピンで示されている地図画像をGoogle Maps APIで取得します。

@app.route("/imagemap/<path:url>/<size>")
def imagemap(url, size):
    ImageFile.LOAD_TRUNCATED_IMAGES = True
    byte_io = BytesIO()
    map_image_url = urllib.parse.unquote(url)
    response = requests.get(map_image_url)
    img = Image.open(BytesIO(response.content))
    img_resize = img.resize((int(size), int(size)))
    img_resize.save(byte_io, 'PNG')
    byte_io.seek(0)
    
    return send_file(byte_io, mimetype='image/png')

@handler.add(MessageEvent, message=LocationMessage)
def handle_location(event):
    # 指定された緯度経度
    lat = event.message.latitude
    lon = event.message.longitude

    # 略

    # 地図画像を取得するURL内、Wi-Fi位置を示すマーカ座標の羅列部分
    wifi_markers_url = ''

    # 表示するWi-Fiのうち、
    # 指定された緯度経度から最大距離となるWi-Fiまでの距離を求める
    # (地図画像のズームレベルを決めるために使う)
    max_km = 0.0
    
    with db.connect() as conn:
        recent_votes = conn.execute(select_sql).fetchall()
        # クエリ実行結果の行数分ループ
        for row in recent_votes:
            map_id = len(response_json_list) + 1
            latitude = float(row[4])
            longitude = float(row[5])
            distance = float(row[8])
            km_per_degree = 40075.0 / 360.0
            km = distance * km_per_degree
            max_km = km
            
            wifi_markers_url += '&markers=color:red|label:{}|{},{}'.format(map_id, latitude, longitude)

    # 東京近郊から「Wi-Fi天使」にメッセージを送ったとき、
    # 表示されるWi-Fi位置は遠くても100kmほどである
    # Google Maps APIのズームレベルが1増加する毎に、
    # 地図の表示領域が1/2になることを利用して、
    # 取得する地図画像のズームレベルを決める
    zoom = 7.0
    limit_km = 100.0
  
    while limit_km > max_km:
        zoom += 1
        limit_km /= 2

    # 地図画像を取得するURL
    map_image_url = 'https://maps.googleapis.com/maps/api/staticmap?center={},{}&zoom={}&size=520x520&scale=2&maptype=roadmap&key={}'.format(lat, lon, zoom, GOOGLE_MAPS_STATIC_API_KEY)
    map_image_url += '&markers=color:blue|label:|{},{}'.format(lat, lon)
    map_image_url += wifi_markers_url
  
    imagesize = 1040

    messages = [
        ImagemapSendMessage(
            base_url = 'https://{}/imagemap/{}'.format(request.host, urllib.parse.quote_plus(map_image_url)),
            alt_text = '地図',
            base_size = BaseSize(height=imagesize, width=imagesize),
            actions = []
        )
    ]

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

if __name__ == "__main__":
    app.run()

Googleマップへのリンク、および、Wi-Fi提供元HPへのリンクを設定します。

@handler.add(MessageEvent, message=LocationMessage)
def handle_location(event):
    # 略

    with db.connect() as conn:
        recent_votes = conn.execute(select_sql).fetchall()
        # クエリ実行結果の行数分ループ
        for row in recent_votes:
            latitude = float(row[4])
            longitude = float(row[5])
            url = row[7]

            result = {
                # 略
                "action1": {"type": 'uri', "label": 'Googleマップで開く', "uri": 'https://www.google.com/maps/search/?api=1&query={},{}'.format(str(latitude), str(longitude))},
                "action2": {"type": 'uri', "label": 'Wi-Fi提供元HP', "uri": url } 
            }

            response_json_list.append(result)

以下、コードの全文です。GitHubにも置きました。

#!/usr/bin/python3.6
# -*- coding: utf-8 -*-
import logging
import sys
import os
from os.path import join, dirname
from flask import Flask, request, abort, send_file
from linebot import LineBotApi, WebhookHandler
from linebot.exceptions import InvalidSignatureError
from linebot.models import (
    CarouselColumn, CarouselTemplate, URITemplateAction, TemplateSendMessage, MessageEvent, TextMessage, LocationMessage, LocationSendMessage,TextSendMessage, StickerSendMessage, MessageImagemapAction, ImagemapArea, ImagemapSendMessage, BaseSize
)
from io import BytesIO, StringIO
from PIL import Image, ImageFile
import requests
import urllib.parse
import math
import sqlalchemy
from dotenv import load_dotenv

load_dotenv(verbose=True)
dotenv_path = join(dirname(__file__), '.env')
load_dotenv(dotenv_path)

LINE_BOT_CHANNEL_ACCESS_TOKEN = os.environ.get('LINE_BOT_CHANNEL_ACCESS_TOKEN')
LINE_BOT_CHANNEL_SECRET = os.environ.get('LINE_BOT_CHANNEL_SECRET')
GOOGLE_MAPS_STATIC_API_KEY = os.environ.get('GOOGLE_MAPS_STATIC_API_KEY')
MYSQL_CONNECTION_NAME = os.environ.get('MYSQL_CONNECTION_NAME')
MYSQL_USER = os.environ.get('MYSQL_USER')
MYSQL_PASSWORD = os.environ.get('MYSQL_PASSWORD')
MYSQL_DATABASE = os.environ.get('MYSQL_DATABASE')
MAX_WIFI_NUM = os.environ.get('MAX_WIFI_NUM')

app = Flask(__name__)

line_bot_api = LineBotApi(LINE_BOT_CHANNEL_ACCESS_TOKEN)
handler = WebhookHandler(LINE_BOT_CHANNEL_SECRET)

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

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

    try:
        handler.handle(body, signature)
    except InvalidSignatureError:
        abort(400)

    return 'OK'

@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):
    line_bot_api.reply_message(
        event.reply_token,
        [
            TextSendMessage(text='下のボタンから位置情報を送ってね')
        ]
    )

@app.route("/imagemap/<path:url>/<size>")
def imagemap(url, size):
    ImageFile.LOAD_TRUNCATED_IMAGES = True
    byte_io = BytesIO()
    map_image_url = urllib.parse.unquote(url)
    response = requests.get(map_image_url)
    img = Image.open(BytesIO(response.content))
    img_resize = img.resize((int(size), int(size)))
    img_resize.save(byte_io, 'PNG')
    byte_io.seek(0)
    
    return send_file(byte_io, mimetype='image/png')

@handler.add(MessageEvent, message=LocationMessage)
def handle_location(event):
    lat = event.message.latitude
    lon = event.message.longitude

    db = sqlalchemy.create_engine(
        sqlalchemy.engine.url.URL(
            drivername='mysql+pymysql',
            username=MYSQL_USER,
            password=MYSQL_PASSWORD,
            database=MYSQL_DATABASE,
            query={
                'unix_socket': '/cloudsql/{}'.format(MYSQL_CONNECTION_NAME)
            }
        ),
    )

    select_sql = 'SELECT id, name, address, detail_address_info, latitude, longitude, ssid, url, '
    select_sql += 'GLENGTH(GEOMFROMTEXT(CONCAT(\'LINESTRING({} {},\',longitude,\' \',latitude,\')\'))) AS distance '.format(lon, lat)
    select_sql += 'FROM free_wifi '
    select_sql += 'ORDER BY distance '
    select_sql += 'LIMIT {}'.format(MAX_WIFI_NUM)
   
    response_json_list = []
    wifi_markers_url = ''
    max_km = 0.0
    
    with db.connect() as conn:
        recent_votes = conn.execute(select_sql).fetchall()
        
        for row in recent_votes:
            map_id = len(response_json_list) + 1
            id = int(row[0])
            name = row[1]
            address = row[2]
            detail_address_info = row[3]
            latitude = float(row[4])
            longitude = float(row[5])
            ssid = row[6]
            url = row[7]
            distance = float(row[8])
            km_per_degree = 40075.0 / 360.0
            km = distance * km_per_degree
            m = math.floor(km * 1000)
            max_km = km
            title = str(map_id) + ':' + name

            if len(title) > 40:
                title = title[:37] + '...'
                
            result = {
                "title": title,
                "text": '指定位置から{}m\nSSID:{}'.format(m, ssid),
                "action1": {"type": 'uri', "label": 'Googleマップで開く', "uri": 'https://www.google.com/maps/search/?api=1&query={},{}'.format(str(latitude), str(longitude))},
                "action2": {"type": 'uri', "label": 'Wi-Fi提供元HP', "uri": url } 
            }

            response_json_list.append(result)

            wifi_markers_url += '&markers=color:red|label:{}|{},{}'.format(map_id, latitude, longitude)

    zoom = 7.0
    limit_km = 100.0
  
    while limit_km > max_km:
        zoom += 1
        limit_km /= 2

    map_image_url = 'https://maps.googleapis.com/maps/api/staticmap?center={},{}&zoom={}&size=520x520&scale=2&maptype=roadmap&key={}'.format(lat, lon, zoom, GOOGLE_MAPS_STATIC_API_KEY)
    map_image_url += '&markers=color:blue|label:|{},{}'.format(lat, lon)
    map_image_url += wifi_markers_url
  
    imagesize = 1040

    columns = [
        CarouselColumn(
            title=column["title"],
            text=column["text"],
            actions=[
                URITemplateAction(
                    label=column["action1"]["label"],
                    uri=column["action1"]["uri"],
                ),
                URITemplateAction(
                    label=column["action2"]["label"],
                    uri=column["action2"]["uri"],
                )
            ]
        )
        for column in response_json_list
    ]

    messages = [
        ImagemapSendMessage(
            base_url = 'https://{}/imagemap/{}'.format(request.host, urllib.parse.quote_plus(map_image_url)),
            alt_text = '地図',
            base_size = BaseSize(height=imagesize, width=imagesize),
            actions = []
        ),
        TemplateSendMessage(
            alt_text="近くのWi-Fi情報",
            template=CarouselTemplate(columns=columns),
        )
    ]

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

if __name__ == "__main__":
    app.run()

参考

東京都オープンデータカタログサイトホームページ

イメージマップメッセージを使って終電に乗り遅れないボットを作りました – LINE ENGINEERING

Python フリーWiFiスポットを地図にプロットしてみる – 1.21 jigowatts

近くの神社を教えてくれる LINE Bot を作ってみました。 – hawk, camphora, avocado and so on..

Google Maps APIのズームレベルまとめ – Qiita

コメントを残す

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