経緯
なんか面白い物が作りたいと思い、位置情報を送ると近くの餃子屋さんと焼肉屋さんを教えてくれるLINE BOTを作りました。
なぜ餃子と焼肉かと言うと、私が好きだからです。
参考
【公式リファレンス】
https://developers.line.biz/ja/docs/messaging-api
【LINE SDK for PHP】
https://github.com/line/line-bot-sdk-php
【[LINE Bot] 位置情報から食べログ3.5以上の優良店を検索するbot作った】
(これをお手本につくりました。)
https://qiita.com/NARI_Creator/items/f29112e6f604c86b3c0d
【ぐるなびAPIの取得方法 そのまま使えるソースコード付き】
https://enjoy-surfing.com/gurunavi/
【LineBotを作るときの雛形 for Laravel】
https://qiita.com/sh-ogawa/items/2238e579d7ee538025a0
【LaravelでLINEにチャットボットをつくる(QRコード作成)】
https://blog.capilano-fw.com/?p=4285#i-4
大変参考になりました🙏ありがとうございます。
仕様
1.餃子が食べたいか焼肉が食べたいか入力する
2.位置情報を送信する
3.近くのお店を教えてくれる
簡単な仕様はこんな感じです。👽
作成前にやっておくこと
herokuのアプリケーションを作る
ここら辺みておけばできるんじゃないかなーと思います。
Heroku アカウント登録してデプロイするまでの簡単な使い方
Laravelをherokuにデプロイする(データベースはMySQL)
herokuはクレカを登録すると無料で1ヶ月あたり1000時間使えます。
無料プランだと30分ごとにスリープしてしまうのでスリープ後の初アクセスはレスポンスが遅いですが、バッチ処理をしておけば問題ないみたいです。
LINE Message APIの登録
LINE BOTのアカウントを作ります。
公式ドキュメント
公式が割と丁寧に説明してくれてるので、見ながらやったらできると思います。
実装
LINE Messaging API SDKをインストールする
$ composer require linecorp/line-bot-sdk
環境変数を追加する
#line
LINE_SECRET_TOKEN =
LINE_ACCESS_TOKEN =
トークンはアカウントのBasic Settingから参照できます。
ルートの作成
ユーザーがLINE BOTにメッセージを送ると、LINEプラットフォームから指定されたアプリケーションのURLにHTTPリクエストを送るので、そのURLを決めます。
Route::post('line','LineController@post')->name('line.post');
ちなみにリクエストはPOSTで送られます🐥
あとついでにControllerも作っちゃいます。
クライアントを作成する
今回はサービスクラスにまとめました。
<?php
namespace App\Services;
use LINE\LINEBot;
use LINE\LINEBot\HTTPClient\CurlHTTPClient;
class LineService
{
/**
* linesdkの使用開始
*
* @return LINEBot
*/
public static function lineSdk()
{
$token = config('services.line.token');
$secret = config('services.line.secret');
$httpClient = new CurlHTTPClient($token);
$bot = new LINEBot($httpClient,['channelSecret' => $secret]);
return $bot;
}
}
$token
と$service
は.envに登録したLINE_SECRET_TOKEN
とLINE_ACCESS_TOKEN
ですが、configのserviceに登録してからもってきてます。
基本公式のREADMEに書いてあるまんまですね🧐
署名を検証する
ラインプラットフォーム以外のわるものから危険なリクエストが送られてくる可能性があるので、検証する必要があります。
ラインのプラットフォームからリクエストが送られてきた時は、リクエストヘッダーにX-Line-Signature
というものが含まれます。これを検証するのですが、
チャネルシークレットを秘密鍵として、HMAC-SHA256アルゴリズムを使用してリクエストボディのダイジェスト値を取得します。
ダイジェスト値をBase64エンコードした値と、リクエストヘッダーのX-Line-Signatureに含まれる署名が一致することを確認します。
と丁寧に書かれているのでその通りに検証します。
<?php
namespace App\Http\Controllers;
use App\Services\LineService;
use LINE\LINEBot\SignatureValidator;
class LineController extends Controller
{
public function post()
{
$signarure = request()->header('X-Line-Signature');
$validateSignature = SignatureValidator::validateSignature($httpRequestBody, $channelSecret, $signarure);
if ($validateSignature) {
return response()->json(200);
} else {
abort(400);
}
}
}
とりあえずsignatureの検証ができれば200
、できなければ400
を返すようにします。
このSignatureValidator::validateSignature
が何をしているのかと言うと、
リクエストボディのダイジェスト値をbase64にエンコードして、リクエストヘッダーのX-Line-Signatureと比較しています。
public static function validateSignature($body, $channelSecret, $signature)
{
if (!isset($signature)) {
throw new InvalidSignatureException('Signature must not be empty');
}
return hash_equals(base64_encode(hash_hmac('sha256', $body, $channelSecret, true)), $signature);
}
こういうことです🙂
$channelSecret = config('services.line.secret');
$httpRequestBody = request()->getContent();
$hash = hash_hmac('sha256', $httpRequestBody, $channelSecret, true);
$signature = base64_encode($hash);
Webhook URLを登録する
次LINEのコントロールパネルからWebhook URLを登録します。
herokuのurl(×××××.herokuapp.com/line)登録して、verifyボタンを押します。
success
というメッセージがでてきたらOKです🙏
コントローラで200を返さないようにしないとエラーになります。
テキストメッセージが送られてきたら返事を返してみる
とりあえず、テキストメッセージが送られてきたらこんにちは!
と返してみます。
$bot = LineService::lineSdk();
try{
$events = $bot->parseEventRequest($httpRequestBody, $signature);
foreach ($events as $event) {
if ($event instanceof LINE\LINEBot\Event\MessageEvent\TextMessage) {
$bot->replyText($event->getReplyToken(), 'こんにちは!');
}
}
}catch(\Exception $e){
Log::debug($e);
}
parseEventRequest
で署名が正当であるか検証して、正当であればリクエストをパースします。
webhookで送られてくるイベントはいろいろ種類がある↓🙄
Webhookイベントオブジェクト
SDK
送られてきたイベントがテキストメッセージであるかは
$event instanceof LINE\LINEBot\Event\MessageEvent\TextMessage
のインスタンスであるかで確認します。
応答できるリクエストには応答トークンというものが発行されるので、$event->getReplyToken()
で取得して、応答メッセージと一緒に$bot->replyText
で返しています🤛
イベントの種類でレスポンスを変えてみる
とりあえず、以下のイベントに対するレスポンスを実装します。
・フォローイベント
・フォロー解除イベント
・スタンプメッセージイベント
・テキストメッセージイベント
・位置情報メッセージイベント
try{
$events = $bot->parseEventRequest($httpRequestBody, $signature);
foreach ($events as $event) {
//フォローイベント
if($event instanceof FollowEvent){
$followMessage = '友達登録ありがとう!何が食べたいか入力してね。近くのお店を見つけるよ!(例:餃子、ぎょうざ、ぎょーざ、焼肉、焼き肉、やきにく など)';
$bot->replyText($event->getReplyToken(), $followMessage);
continue;
}
//フォロー解除イベント
else if ($event instanceof UnfollowEvent) {
continue;
}
//スタンプメッセージイベント
else if($event instanceof StickerMessage){
$stampMessage = '可愛いスタンプだね〜!何が食べたいか入力してくれたら近くのお店を見つけるよ!(例:餃子、ぎょうざ、ぎょーざ、焼肉、焼き肉、やきにく など)'
$bot->replyText($event->getReplyToken(), $stampMessage);
continue;
}
//テキストメッセージイベント
else if($event instanceof TextMessage){
//TODO テキストメッセージ実装
}
//位置情報メッセージイベント
else if($event instanceof LocationMessage){
//TODO 位置情報実装
}
}
}catch(\Exception $e){
Log::debug($e);
}
エイリアスはこちらを参考に🙆🏻♀️
テキストメッセージに対するリスポンス
このBOTでは最初に何を食べたいか入力してもらってから位置情報を要求します🐥
餃子か焼肉かを判定したいのですが、
餃子、ぎょうざ、ぎょーざ、ギョーザ
などいろいろなパターンの餃子と焼肉に対応したいと思います。
単純にif文に全部ぶち込みます。多分よくない🙄
/**
* 入力された文字が餃子か判定
*
* @param $text
* @return bool
*/
public function isGyoza($text)
{
return ($text === '餃子' || $text === 'ぎょうざ' || $text === 'ぎょーざ' || $text === 'ギョーザ' || $text === 'ギョーザ');
}
/**
* 入力された文字が餃子か判定
*
* @param $text
* @return bool
*/
public function isYakiniku($text)
{
return ($text === '焼肉' || $text === '焼き肉' || $text === 'やきにく' || $text === 'ヤキニク' || $text === 'ヤキニク');
}
焼肉か餃子かの結果はここで受け取ります。↓
焼肉か餃子が入力された場合は後述するrequireLocation
で位置情報を求めます💡
また、putCategory
でDBにuser_id
とcategory
を保存するようにしました。
category
は餃子であれば1、焼肉は2が保存されます。
もし他の単語が入力された場合は、テキストメッセージを返します👀
/**
* 送られてきたメッセージに対するレスポンス
*
* @param $bot
* @param $event
* @return void
*/
public function getMessage($bot, $event)
{
try{
$text = $event->getText();
if ($this->isGyoza($text)) {
//DBに保存
$user_id = $event->getUserId();
$category = Line::GYOZA;
$this->putCategory($user_id,$category);
//位置情報を求める
$word = '餃子';
$this->requireLocation($bot, $event, $word);
} else if ($this->isYakiniku($text)) {
//DBに保存
$user_id = $event->getUserId();
$category = Line::YAKINIKU;
$this->putCategory($user_id,$category);
//位置情報を求める
$word = '焼肉';
$this->requireLocation($bot, $event, $word);
}else{
$bot->replyText($event->getReplyToken(), '焼肉か餃子しか調べられないよ、、ごめんね。');
}
}catch(\Exception $e){
Log::debug($e);
}
}
/**
* カテゴリーとユーザーIDをDBに保存
*
* @param $user_id
* @param $category
* @return void
*/
public function putCategory($user_id, $category)
{
Line::create([
'user_id' => $user_id,
'category' => $category
]);
}
/**
* 位置情報を求めるメッセージを送る
*
* @param $bot
* @param $event
* @param $word //餃子か焼肉か
* @return void
*/
public function requireLocation($bot, $event, $word)
{
$uri = new UriTemplateActionBuilder('現在地を送る!', 'line://nv/location');
$message = new ButtonTemplateBuilder(null, $word.'が食べたいんだね!今どこにいるか教えてほしいな!', null, [$uri]);
$bot->replyMessage($event->getReplyToken(), new TemplateMessageBuilder('位置情報を送ってね', $message));
}
・UriTemplateActionBuilder
このアクションが関連づけられたコントロールがタップされると、第二引数に登録されているURLが開きます。
line://nv/location
は、lineで位置情報が開かれます。
・ButtonTemplateBuilder
ボタンテンプレートが作られます。引数は、タイトル、本文、画像URL、アクションの順番です。
アクションは配列にしなきゃダメなので注意!
・TemplateMessageBuilder
テンプレートメッセージを作ります。引数は代替テキスト、ButtonTemplateBuilderです。
送られた住所から経度と緯度を取得する
getLatitude()
とgetLongitude()
という関数で緯度経度が取れるので、これをつかいます🙋♀️
https://line.github.io/line-bot-sdk-php/source-class-LINE.LINEBot.Event.MessageEvent.LocationMessage.html#65-68
try{
$events = $bot->parseEventRequest($httpRequestBody, $signature);
foreach ($events as $event) {
//フォローイベント
if($event instanceof FollowEvent){
$followMessage = '友達登録ありがとう!何が食べたいか入力してね。近くのお店を見つけるよ!(例:餃子、ぎょうざ、ぎょーざ、焼肉、焼き肉、やきにく など)';
$bot->replyText($event->getReplyToken(), $followMessage);
continue;
}
//フォロー解除イベント
else if ($event instanceof UnfollowEvent) {
continue;
}
//スタンプメッセージイベント
else if($event instanceof StickerMessage){
$stampMessage = '可愛いスタンプだね〜!何が食べたいか入力してくれたら近くのお店を見つけるよ!(例:餃子、ぎょうざ、ぎょーざ、焼肉、焼き肉、やきにく など)';
$bot->replyText($event->getReplyToken(), $stampMessage);
continue;
}
//テキストメッセージイベント
else if($event instanceof TextMessage){
(new LineService())->getMessage($bot,$event);
continue;
}
//位置情報メッセージイベント
else if($event instanceof LocationMessage){
(new GurunaviService())->returnGurunaviList($bot, $event,$event->getLatitude(), $event->getLongitude()); //これを追加
}
else{
break;
}
}
ぐるなびから情報を取得する
ではreturnGurunaviList
を実装します。
レストラン検索APIを使用します。
必要なパラメータはこのくらい?🙄↓
・keyid(ぐるなびから与えられるアクセスキー)
・category_s(カテゴリーコード)
https://api.gnavi.co.jp/api/manual/categorysmaster/
このAPIを叩けばカテゴリーコードの一覧が取得できます。
・latitude(緯度)
・longitude(経度)
・range(緯度/経度からの検索範囲(半径))
1:300m、2:500m、3:1000m、4:2000m、5:3000mって感じです。最大3000m?
/**
*
* カテゴリーが存在すればぐるなびで検索をかける
*
* @param $bot
* @param $event
* @param $lat
* @param $lng
*/
public function returnGurunaviList($bot, $event, $lat, $lng)
{
//DBにカテゴリーがあるか検証
$category = Line::where(['user_id' => $event->getUserId()])->latest()->first();
if ($category) {
$getGurunaviResult = $this->getGurunavi($lat, $lng, $category);
if (property_exists($getGurunaviResult, 'error')) {
$bot->replyText($event->getReplyToken(), 'お店が見つからなかったよ〜、ごめんね。');
} else {
$category->delete();
(new FlexMessage())->returnFlexMessage($event, $getGurunaviResult);
}
} else {
$bot->replyText($event->getReplyToken(), '先に何が食べたいか教えてね!');
}
}
/**
*
* ぐるなびで検索
*
* @param $lat
* @param $lng
* @param $category
* @return mixed
*/
public static function getGurunavi($lat, $lng, $category)
{
$endpoint = 'https://api.gnavi.co.jp/RestSearchAPI/v3/';
//自分のアクセスキー
$keyId = config('services.gurunavi.key');
//検索範囲
$range = 5;
//カテゴリーコードを取得
if ($category->category === 1) {
$categoryCode = 'RSFST14008';
} else {
if ($category->category === 2) {
$categoryCode = 'RSFST05001';
} else {
$categoryCode = 'RSFST05003';
}
}
$url = $endpoint.'?keyid='.$keyId.'&category_s='.$categoryCode.'&latitude='.$lat.'&longitude='.$lng.'&range='.$range;
$json = file_get_contents($url);
return json_decode($json);
}
returnGurunaviList
でカテゴリーがあることを検証してから、getGurunavi
でぐるなび検索をかけています。DBに保存してあったカテゴリーをぐるなびのカテゴリーコードと照らし合わせています。(最初からぐるなびのカテゴリーコードを格納したほうがいいですね😳)
ちなみにここまできてカテゴリーが餃子と焼肉以外だったらたこ焼きのカテゴリーコードで検索がかかるようにしました🙄
そして検索結果はjsonで返ってくるので、デコードして返します。
検索結果はreturnFlexMessage
で色々といじって返します🙋♀️
食べログの検索結果をFlex Messageに変換して送る
Flex Messageはjsonで書くっぽいのですが、絶対できないと思ったので、
LINE Bot Designerを入れました。
LINE Bot DesignerでFlex Messageのテンプレートデザインを確認しながら作成し、jsonを配列に変換します。
先ほどのデコードした検索結果をreturnFlexMessage
で受け取って、generateFlexTemplateContent
でいじくりまわして最終的にcurlでPOSTします。
ここにヘッダーとかボディとかかいてあります😌
/**
* ぐるなびの情報を送信
*
* @param $event
* @param $gurunavi
* @return void
*/
public function returnFlexMessage($event,$gurunavi)
{
$token = config('services.line.token');
$postJson = $this->generateFlexTemplateContent($gurunavi);
$result = json_encode(['replyToken' => $event->getReplyToken(), 'messages' => [$postJson]]);
$curl = curl_init();
//curl_exec() の返り値を文字列で返す
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
//POSTリクエスト
curl_setopt($curl, CURLOPT_POST, true);
//ヘッダを指定
curl_setopt($curl, CURLOPT_HTTPHEADER, array('Authorization: Bearer '.$token, 'Content-type: application/json'));
//リクエストURL
curl_setopt($curl, CURLOPT_URL, 'https://api.line.me/v2/bot/message/reply');
//送信するデータ
curl_setopt($curl, CURLOPT_POSTFIELDS, $result);
$curlResult = curl_exec($curl);
curl_close($curl);
return $curlResult;
}
generateFlexTemplateContent
では取得したぐるなびの情報をeachでまわして、Flex Messageのテンプレートに当てはめています。
テンプレートはjsonで書くのですが、LINE Bot Designerを使うと、デザインを確認しながら簡単にjsonを作れます。
あとはここを参考にjsonの形を整えます。🙋♀️
/**
* ぐるなびの情報をflexMessageテンプレートにあてめる
*
* @param $gurunavis
* @return array
*/
private function generateFlexTemplateContent($gurunavis)
{
$lists = [];
foreach ($gurunavis->rest as $rest) {
$lists[] = $this->getFlexTemplate($rest);
}
$contents = ["type" => "carousel", "contents" => $lists];
return ['type' => 'flex', 'altText' => 'searchResult', 'contents' => $contents];
}
/**
* flexMessageテンプレート
*
* @param $gurunavi
* @return array
*/
private function getFlexTemplate($gurunavi)
{
return [
"type" => "bubble",
"hero" => [
"type" => "image",
"url" => $gurunavi->image_url->shop_image1 ? $gurunavi->image_url->shop_image1: "https://scdn.line-apps.com/n/channel_devcenter/img/fx/01_1_cafe.png",
"size" => "full",
"aspectRatio" => "20:13",
"aspectMode" => "cover",
"action" => [
"type" => "uri",
"label" => "Line",
"uri" => $gurunavi->url_mobile ? $gurunavi->url_mobile : '不明',
]
],
"body" => [
"type" => "box",
"layout" => "vertical",
"contents" => [
[
"type" => "text",
"text" => $gurunavi->name ? $gurunavi->name : '不明',
"size" => "xl",
"weight" => "bold",
"wrap" => true
],
[
"type" => "box",
"layout" => "baseline",
"margin" => "md",
"contents" => [
[
"type" => "text",
"text" => $gurunavi->opentime ? $gurunavi->opentime : '不明',
"flex" => 0,
"margin" => "md",
"size" => "sm",
"color" => "#999999",
"wrap" => true
]
]
],
[
"type" => "box",
"layout" => "vertical",
"spacing" => "sm",
"margin" => "lg",
"contents" => [
[
"type" => "box",
"layout" => "baseline",
"spacing" => "sm",
"contents" => [
[
"type" => "text",
"text" => "種類",
"flex" => 1,
"size" => "sm",
"color" => "#AAAAAA"
],
[
"type" => "text",
"text" => $gurunavi->code->category_name_l[0] ? $gurunavi->code->category_name_l[0] : '不明',
"flex" => 5,
"size" => "sm",
"color" => "#666666",
"wrap" => true
]
]
],
[
"type" => "box",
"layout" => "baseline",
"spacing" => "sm",
"contents" => [
[
"type" => "text",
"text" => "場所",
"flex" => 1,
"size" => "sm",
"color" => "#AAAAAA"
],
[
"type" => "text",
"text" => $gurunavi->access->station.'徒歩'.$gurunavi->access->walk.'分' ? $gurunavi->access->station.'徒歩'.$gurunavi->access->walk.'分' : '不明',
"flex" => 5,
"size" => "sm",
"color" => "#666666",
"wrap" => true
]
]
],
[
"type" => "box",
"layout" => "baseline",
"spacing" => "sm",
"contents" => [
[
"type" => "text",
"text" => "駐車場",
"flex" => 2,
"size" => "sm",
"color" => "#AAAAAA"
],
[
"type" => "text",
"text" => $gurunavi->parking_lots ? $gurunavi->parking_lots : '不明',
"flex" => 5,
"size" => "sm",
"color" => "#666666",
"wrap" => true
]
]
]
]
]
]
],
"footer" => [
"type" => "box",
"layout" => "vertical",
"flex" => 0,
"spacing" => "sm",
"contents" => [
[
"type" => "button",
"action" => [
"type" => "uri",
"label" => "ぐるなびで見る",
"uri" => $gurunavi->url_mobile ? $gurunavi->url_mobile : '不明'
],
"height" => "sm",
"style" => "link"
],
[
"type" => "spacer",
"size" => "sm"
]
]
]
];
}
}
完成🥰
エンジニアになって1ヶ月目に作ろうとして挫折して、4ヶ月経ったのでもう一回やってみたら割とできてびっくりしました。
まだまだきれいなソースコード書けないし、サービスクラスの使い分けやメソッドの分け方もきっとぐちゃぐちゃなのでこれからも精進します🌻
LINEのリファレンスすごくわかりやすかった!
何か違うよ!ってところがあれば教えてください🙏