はじめに
東京都オープンデータカタログサイトにあるなにかしらのデータと、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..