LINE BOTでお店ランキング表示サービスをつくろう!(4/5)

GASでWebアプリケーション作成

LINE側でのMessaging API利用登録の次は、LINEボットの心臓部となる「Webアプリケーション」を作成します!
ランキング表示のプログラムは、前回作成したお店ランキング表示のGASスクリプトをベースとします。
追加で検討しなければならない課題は、「ユーザとの対話をどのように実装するか」です。

ユーザと対話するには、「誰と会話をしていて」(ユーザID)、「何について話していて」(会話の状態)、「どんなメッセージを受け取ったか」(メッセージ履歴)を記憶しておく必要があります。
これらをプログラムの変数に記憶しようとしても、リクエストがある度に初期化されてしまうので、永続的に記憶可能なデータストアに保存しておかなければいけません。

データストアとして最初に思い浮かぶのは、MySQLやPostgreSQLといったデータベースですが、ここはGASを使っているという前提で考えると、「スプレッドシート」(Google表計算ソフト)を使うというのがスマートな選択かもしれません。
ExcelとVBAの関係のように、スプレッドシートとGoogle Apps Scriptは一蓮托生のような関係性なので、これを利用しましょう。
スプレッドシートの1行分を1ユーザのデータ領域として、「ユーザID」「処理状況」「検索住所」「検索キーワード」「検索結果」などを保存して、そのデータを参照しながら応答メッセージを組み立てるようにします。
では、「お店ランキン蘭子さん」のソースを公開します。
Main.gs
/*==============================
 LINE BOTのメイン処理
==============================*/

//LINEアクセストークン
var ACCESS_TOKEN = "xxxxxxxxxxxxxxxxxxxx";

//絵文字
var EMOJI_HAPPY = "\uDBC0\uDC01"  //わーい
var EMOJI_MOON_GRIN = "\uDBC0\uDC8D"; //えへへ
var EMOJI_FROZEN = "\uDBC0\uDC83";  //固まる
var EMOJI_TIRED = "\uDBC0\uDC17";  //ごめん

var EMOJI_SHINY = "\uDBC0\uDC2D";  //きらきら
var EMOJI_OK = "\uDBC0\uDC33";  //OK
var EMOJI_EIGHTHNOTE = "\uDBC0\uDC39";  //音符
var EMOJI_STAR = "\uDBC0\uDCB2";  //スター

var EMOJI_TRAIN = "\uDBC0\uDC47"; //電車
var EMOJI_BUILDING = "\uDBC0\uDC4C"; //ビル
var EMOJI_POSTOFFICE = "\uDBC0\uDC4D"; //郵便局
var EMOJI_HOSPITAL = "\uDBC0\uDC4E"; //病院
var EMOJI_SCHOOL = "\uDBC0\uDC4F"; //学校
var EMOJI_SPA = "\uDBC0\uDC51";  //温泉

/*
 POSTリクエスト受信
  request:受信リクエスト
*/
function doPost(request) {
  //POSTリクエストをJSONデータにパース
  var receiveJSON = JSON.parse(request.postData.contents);
  
  for (var i = 0; i < receiveJSON.events.length; i++){
    var event =  receiveJSON.events[i];
    
    //ユーザID取得
    var userId = event.source.userId;
    //応答トークン取得
    var replyToken = event.replyToken;
    
    //イベント種別取得
    var eventType = event.type;
    switch(eventType){
      case "follow":  //友だち追加
        
        //ユーザ情報を削除(ゴミが残っていた時のため)
        deleteUserInfo(userId);
        //新しくユーザ情報を追加
        addUserInfo(userId);
        
        var messages = [];
        //ウェルカムメッセージ
        var msg1 = welcomeMessage();
        messages.push(msg1);
        //住所確認メッセージ
        var msg2 = confirmAddressMessage();
        messages.push(msg2);
        
        //LINEの応答
        replyMessage(replyToken, messages);
        
        break;
      case "message":  //メッセージ受信
        
        //応答メッセージの作成
        var messages = createReplyMessage(userId, event);
        
        //LINEの応答
        if (messages.length > 0){
          replyMessage(replyToken, messages);
        }
        
        break;
      case "unfollow":  //ブロック
        
        //ユーザ情報を削除
        deleteUserInfo(userId);
        
        break;
      case "postback":  //ボタン操作による応答
        
        //「最初から検索」を選択
        if (event.postback.data == "gotoFirst"){
          //最初から検索するための処理
          gotoFirst(userId);
          
          var messages = [];
          //住所確認メッセージ
          var msg1 = confirmAddressMessage();
          messages.push(msg1);
          
          //LINEの応答
          replyMessage(replyToken, messages);
        }
        //「続きを見る」を選択
        else if (event.postback.data == "nextResults"){
          //次の結果表示
          var messages = nextResultsMessage(userId);
          
          //LINEの応答
          if (messages.length > 0){
            replyMessage(replyToken, messages);
          }
        }
        
        break;
    }
  }
}
/*
 応答メッセージの作成
  userId:ユーザID
  event:LINEから受信したeventデータ
*/
function createReplyMessage(userId, event){
  var messages = [];
  
  //ユーザ情報の行番号を取得
  var userRow = getUserDataRow(userId);
  if (userRow == -1){
    //ユーザ情報がない場合は、新しくユーザ情報を追加
    userRow = addUserInfo(userId);
  }
  
  //ユーザ情報から処理状況を取得
  var status = getUserStatus(userRow);
  switch(status){
    case "address":  //住所確認中
      
      var messageType = event.message.type;
      if (messageType != "text" && messageType != "location"){
        //メッセージがテキスト、位置情報以外ならば終了
        return messages; 
      }
      
      //ユーザ情報の検索条件クリア
      clearSearchOption(userRow);
      //ユーザ情報の検索結果クリア
      clearSearchResult(userRow);
      
      var location = new Object();
      var searchStatus;
      
      //テキスト受信
      if (messageType == "text"){  
        //テキストメッセージ取得
        var address = event.message.text;
        //改行をスペースに変換
        address = address.replace(/\r?\n/g, " ");
        
        //ユーザ情報に検索住所を保存
        setSearchAddress(userRow, address);
        
        //【Geocoding API】住所→緯度・経度
        var ret1 = getLocation(address);
        if (typeof(ret1) == "object"){
          location = ret1;
          searchStatus = "OK";
        } else {
          searchStatus = ret1;
        }
        
      }
      //位置情報受信
      else if (messageType == "location") {
        searchStatus = "LINE_LOCATION";
        
        //LINEから送られてきた緯度・経度を取得
        location.lat = event.message.latitude;
        location.lng = event.message.longitude;
        //LINEから送られてきた住所を取得
        var address = event.message.address;
        //ユーザ情報に検索住所を保存
        setSearchAddress(userRow, address);
      }
      
      //位置情報取得成功
      if (searchStatus == "OK" || searchStatus == "LINE_LOCATION"){
        //キーワード確認メッセージ作成
        var msg1 = confirmKeywordMessage();
        messages.push(msg1);
        
        //ユーザ情報に位置情報を保存
        setSearchLocation(userRow, location.lat, location.lng);
        //ユーザ情報の処理状況を「keyword」(キーワード確認中)に更新
        setUserStatus(userRow, "keyword");
      }
      //検索結果ゼロ
      else if (searchStatus == "ZERO_RESULTS"){
        //住所検索結果ゼロのメッセージ作成
        var msg1 = zeroAddressMessage();
        messages.push(msg1);
      }
      //Google Maps APIのエラー
      else {
        //Google Maps APIのエラーメッセージ作成
        var msg1 = googleErrorMessage("【Geocoding API】", searchStatus);
        messages.push(msg1);
      }
      
      break;
    case "keyword":  //キーワード確認中
    case "searched":  //検索終了後
      
      var messageType = event.message.type;
      if (messageType != "text"){
        //メッセージがテキスト以外ならば終了
        return messages; 
      }
      
      //ユーザ情報の検索結果クリア
      clearSearchResult(userRow);
      
      //テキストメッセージ取得
      var keyword = event.message.text;
      //改行をスペースに変換
      keyword = keyword.replace(/\r?\n/g, " ");
      //前後のスペースを除去
      keyword = keyword.trim();
      //「なし」は指定なしの意味なので、キーワードをクリア
      if (keyword == "なし"){
        keyword = "";
      }
      
      //ユーザ情報に検索キーワードを保存
      setSearchKeyword(userRow, keyword);
      
      //ユーザ情報から緯度・経度を取得
      var location = getSearchLocation(userRow);
      var placesList = new Array();
      
      //【Places API】検索エリアのお店情報取得
      var ret1 = getPlace(location.lat, location.lng, keyword);
      if (typeof(ret1) == "object"){
        placesList = ret1;
        searchStatus = "OK";
      } else {
        searchStatus = ret1;
      }
      
      //お店情報取得成功
      if (searchStatus == "OK"){
        //検索結果を保存
        setSearchResult(userRow, placesList);
        //ユーザ情報の処理状況を「searched」(検索終了後)に更新
        setUserStatus(userRow, "searched");
        
        //検索完了メッセージ作成
        var msg1 = searchFinishMessage(userRow);
        messages.push(msg1);
        //お店情報メッセージ作成
        var msg2 = placesMessage(userRow);
        messages.push(msg2);
      }
      //検索結果ゼロ
      else if (searchStatus == "ZERO_RESULTS"){
        //お店検索結果ゼロのメッセージ作成
        var msg1 = zeroPlaceMessage(userRow);
        messages.push(msg1);
        //ユーザ情報の処理状況を「address」(住所確認中)に更新
        setUserStatus(userRow, "address");
      }
      //Google Maps APIのエラー
      else {
        //Google Maps APIのエラーメッセージ作成
        var msg1 = googleErrorMessage("【Places API】", searchStatus);
        messages.push(msg1);
        //ユーザ情報の処理状況を「address」(住所確認中)に更新
        setUserStatus(userRow, "address");
      }
      
      break;
  }
  
  return messages;
}
/*
 LINEの応答
  replyToken:応答トークン
  messages:応答メッセージ
*/
function replyMessage(replyToken, messages){
  var replyURL = "https://api.line.me/v2/bot/message/reply";
  UrlFetchApp.fetch(replyURL, {
    "headers": {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + ACCESS_TOKEN
    },
    "method": "post",
    "payload": JSON.stringify({
      "replyToken": replyToken,
      "messages": messages
    }),
  });
}
/*
 最初から検索するための処理
  userId:ユーザID
*/
function gotoFirst(userId){
  //ユーザ情報の行番号を取得
  var userRow = getUserDataRow(userId);
  if (userRow == -1){
    //ユーザ情報がない場合は、新しくユーザ情報を追加
    userRow = addUserInfo(userId);
  }
  //ユーザ情報の処理状況を「address」(住所確認中)に更新
  setUserStatus(userRow, "address");
  //ユーザ情報の検索条件クリア
  clearSearchOption(userRow);
  //ユーザ情報の検索結果クリア
  clearSearchResult(userRow);
}
/*
 次の結果表示
  userId:ユーザID
*/
function nextResultsMessage(userId){
  var messages = [];
  
  //ユーザ情報の行番号を取得
  var userRow = getUserDataRow(userId);
  if (userRow == -1){
    //ユーザ情報がない場合は、新しくユーザ情報を追加
    userRow = addUserInfo(userId);
  }
  
  //ユーザ情報から処理状況を取得
  var status = getUserStatus(userRow);
  //検索終了後の場合
  if (status == "searched"){
    //お店情報メッセージ作成
    var msg1 = placesMessage(userRow);
    messages.push(msg1);
  }
  
  return messages;
}

/*==============================
 LINEメッセージ作成
==============================*/
/*
 ウェルカムメッセージ作成
*/
function welcomeMessage(){
  var msg = {
    "type": "text",
    "text": "友だち登録ありがとうございます" + EMOJI_HAPPY +"\n\n"
          + "周辺のレストラン情報を、口コミランキング順に紹介いたします"
          + EMOJI_SHINY + EMOJI_SHINY + "\n\n"
          + "Powered by Google Maps API でございます" + EMOJI_MOON_GRIN
  };  
  return msg;
}
/*
 住所確認メッセージ作成
*/
function confirmAddressMessage(){
  var msg = {
    "type": "text",
    "text": "それでは、お店を調べたい場所の「住所」を教えてね!\n\n"
          + "近くにある「ランドマーク」(目印となる場所)"
          + "の名前でもお調べしますよ。\n"
          + EMOJI_TRAIN + EMOJI_BUILDING + EMOJI_POSTOFFICE + EMOJI_HOSPITAL + EMOJI_SCHOOL + EMOJI_SPA + "\n\n"
          + "現在地の情報ならば、左下の+ボタンから「位置情報」を送信すると簡単かも" + EMOJI_OK
  };
  return msg;
}
/*
 キーワード確認メッセージ作成
*/
function confirmKeywordMessage(){
  var msg = {
    "type" : "text",
    "text" : "気になるお店のジャンルはあるかしら?\n\n"
           + "例えば「寿司」「ラーメン」「イタリアン」「タイ料理」とかで絞り込むことができるわ"
           + EMOJI_HAPPY + "\n\n"
           + "特にない場合は「なし」とつぶやいてね!"
  };
  return msg;
}
/*
 住所検索結果ゼロのメッセージ作成
*/
function zeroAddressMessage(){
  var msg = {
    "type": "text",
    "text": "ごめんなさい!\n"
          + "教えてくれた住所が見つからなかったの" + EMOJI_TIRED + "\n\n"
          + "もう一度、別の住所をお願いできるかしら" + EMOJI_MOON_GRIN
  };
  return msg;
}
/*
 お店検索結果ゼロのメッセージ作成
  row:ユーザ情報の対象行
*/
function zeroPlaceMessage(row){
  //検索住所
  var address = getSearchAddress(row);
  //検索キーワード
  var keyword = getSearchKeyword(row);
  if (keyword == ""){
    keyword = "指定なし";
  }
  
  var msg = {
    "type": "text",
    "text": "残念!\n"
          + "お店がみつからなかったわ" + EMOJI_TIRED + "\n\n"
          + "検索場所:" + address + "\n"
          + "ジャンル:" + keyword + "\n\n"
          + "他の場所とジャンルで探してみましょうか?\n"
          + "もう一度、住所から教えてね" + EMOJI_MOON_GRIN
  };
  return msg;
}
/*
 Google Maps APIのエラーメッセージ作成
  title:エラータイトル
  status:エラーのステータス
*/
function googleErrorMessage(title, status){
  var msg;
  
  switch(status){
    case "OVER_QUERY_LIMIT":
    //利用制限超過
      msg = {
        "type": "text",
        "text": "あらら。パワー切れみたい!\n\n"
              + "沢山の方からご利用いただいたので、本日はサービス終了です。\n"
              + "1日に利用できるリクエスト数が決まっているの" + EMOJI_TIRED + "\n\n"
              + "明日のご利用をお待ちしております" + EMOJI_MOON_GRIN
      };
      
      break;
    default:
    //その他のエラー
      msg = {
        "type": "text",
        "text": "ガガガガガガガガ・・・ピー!\n\n"
              + "あわわわわ!システムエラーで固まっちゃった" + EMOJI_FROZEN + "\n\n"
              + "エンジニアさんに連絡して調べてもらうので、また後日ご利用くださいね" + EMOJI_TIRED
      };
      
      //エラーを管理者にメール通知
      sendErrorMail(title + status);
      
      break;
  };
  
  return msg;
}
/*
 検索完了メッセージ作成
  row:ユーザ情報の対象行
*/
function searchFinishMessage(row){
  //検索住所
  var address = getSearchAddress(row);
  //検索キーワード
  var keyword = getSearchKeyword(row);
  if (keyword == ""){
    keyword = "(指定なし)";
  }
  //検索件数
  var resultCnt = getResultCnt(row);
  
  var msg = {
    "type": "text",
    "text": "お待たせしました!検索結果が出ましたよ" + EMOJI_EIGHTHNOTE + "\n\n"
          + "検索場所:" + address + "\n"
          + "ジャンル:" + keyword + "\n\n"
          + "【検索結果】" + resultCnt + "件\n\n"
          + "口コミランキング順にお店を紹介します" + EMOJI_STAR + EMOJI_STAR + EMOJI_STAR + "\n\n"
          + "検索するジャンルを変更したい場合は、新しいジャンルをつぶやいてくださいね" + EMOJI_OK
  };
  
  return msg;
}
/*
 お店情報メッセージ作成
  row:ユーザ情報の対象行
*/
function placesMessage(row){
  //検索件数
  var resultCnt = getResultCnt(row);
  //表示完了件数
  var dispCnt = getDispCnt(row);
  
  //検索結果を表示完了件数以降から5件分取得
  var resultsList = new Array();
  var startCol = 9 + dispCnt;  
  var values = sheet1.getRange(row, startCol, 1, 5).getValues();
  for (var i = 0; i < values[0].length; i++){
    var result = values[0][i];
    if (result != ""){
      resultsList.push(result);
    }
  }
  
  //表示完了件数を更新
  var updateCnt = dispCnt + 5;
  if (updateCnt > resultCnt) updateCnt = resultCnt;
  setDispCount(row, updateCnt);
  
  //表示する検索結果分のメッセージを作成
  var bubbleList = new Array();
  for (var i = 0; i < resultsList.length; i++){
    var result = resultsList[i].split("\n");
    
    //評価
    var rating = result[0];
    var star = "";
    if (isNaN(rating) == false){
      rating = Math.round(rating * 10) / 10;
      var cnt = Math.floor(Number(rating));
      var star = new Array(cnt+1).join("★");
    }
    //名前
    var name = result[1];
    //周辺住所
    var vicinity = result[2];
    //写真ID
    var photoID = "";
    if (result.length > 3){
      photoID = result[3];
    }
    
    //Flexメッセージに設定するBubbleメッセージを作成
    var bubbleMsg = {
      "type": "bubble",
      "body": {
        "type": "box",
        "layout": "vertical",
        "contents": [
          {
            "type": "text",
            "text": rating + " " + star,
            "wrap": true,
            "color": "#ff8c00"
          },
          {
            "type": "text",
            "text": name,
            "wrap": true
          },
          {
            "type": "text",
            "text": vicinity,
            "wrap": true,
            "color": "#778899",
            "margin": "xl"
          }
        ]
      },
      "footer": {
        "type": "box",
        "layout": "horizontal",
        "contents": [
          {
            "type": "button",
            "action": {
              "type": "uri",
              "label": "詳細表示",
              "uri": "https://maps.google.co.jp/maps?q="
                     + encodeURIComponent(name + " " + vicinity)
                     + "&z=15&iwloc=A"
            }
          },
          {
            "type": "button",
            "action": {
              "type": "postback",
              "label": "最初から検索",
              "data": "gotoFirst",
            }
          }
        ]
      },
      "styles": {
        "footer": {
          "backgroundColor": "#b0c4de",
          "separator": true,
          "separatorColor": "#c0c0c0"
        }
      }
    };
    
    //最初の5件は画像付き
    if (dispCnt == 0){
      var imgUrl = "";
      if (photoID != ""){
        imgUrl = getPhotoURL(photoID);
      } else {
        imgUrl = "https://www.delta-ss.com/labo/data/cmn/noimage.png";
      }
      var imgContent = {
        "type": "image",
        "url": imgUrl,
        "size": "full",
        "aspectRatio": "20:13",
        "aspectMode": "cover",
      };
      bubbleMsg.hero = imgContent;
    }
    
    bubbleList.push(bubbleMsg);
  }
  
  //続きがある場合のBubbleメッセージを作成
  var nextCnt = resultCnt - dispCnt - resultsList.length;
  if (nextCnt > 0){
    var bubbleMsg2 = {
      "type": "bubble",
      "body": {
        "type": "box",
        "layout": "vertical",
        "contents": [
          {
            "type": "text",
            "text": "続きのお店情報があります!",
            "wrap": true,
            "color": "#c71585"
          },
          {
            "type": "text",
            "text": "【残り件数】" + nextCnt + "件",
            "wrap": true,
            "margin": "xl"
          }
        ]
      },
      "footer": {
        "type": "box",
        "layout": "horizontal",
        "contents": [
          {
            "type": "button",
            "action": {
              "type": "postback",
              "label": "続きを見る",
              "data": "nextResults",
            }
          }
        ]
      },
      "styles": {
        "footer": {
          "backgroundColor": "#e3adc1",
          "separator": true,
          "separatorColor": "#c0c0c0"
        }
      }
    };
    bubbleList.push(bubbleMsg2);
  }
  
  //Flexメッセージ作成
  var flexMsg = {
    "type": "flex",
    "altText": "口コミランキング順お店情報",
    "contents": {
      "type": "carousel",
      "contents": bubbleList
    }
  };
  return flexMsg;
}
GoogleMapsAPI.gs
/*==============================
 Google Maps APIの操作
==============================*/

//Google Maps APIのAPIキー
var MAPS_APIKEY1 = "AIzaXXXXXXXXXXXXXXXXXXXXXXXXX";
var MAPS_APIKEY2 = "AIzaYYYYYYYYYYYYYYYYYYYYYYYYY";
var MAPS_APIKEY3 = "AIzaZZZZZZZZZZZZZZZZZZZZZZZZZ";

/*
 位置情報取得
  address:検索場所
*/
function getLocation(address) {
  //【Geocoding API】住所→緯度・経度
  var geocodeApiUrl = "https://maps.googleapis.com/maps/api/geocode/json";
  geocodeApiUrl += "?key=" + MAPS_APIKEY1;
  geocodeApiUrl += "&address=" + encodeURIComponent(address);
  
  //Geocoding APIにリクエスト
  var options = { muteHttpExceptions: true };
  var geocodeJson = UrlFetchApp.fetch(geocodeApiUrl, options);
  
  //JSON文字列をパースしてオブジェクトにする
  var geocodeData = JSON.parse(geocodeJson.getContentText());
  
  //緯度・経度の取得
  var location = new Object();
  if (geocodeData.status == "OK"){
    //緯度・経度を返却
    location.lat = geocodeData.results[0].geometry.location.lat;
    location.lng = geocodeData.results[0].geometry.location.lng;
    return location;
  } else {
    //エラーの場合、APIの結果ステータスを返却
    return geocodeData.status;
  }
}

/*
 お店情報取得
  lat:検索緯度
  lng:検索経度
  keyword:検索キーワード
*/
function getPlace(lat, lng, keyword) {
  //【Places API】検索エリアのお店情報取得
  var placeApiUrl = "https://maps.googleapis.com/maps/api/place/nearbysearch/json";
  placeApiUrl += "?key=" + MAPS_APIKEY2;
  placeApiUrl += "&location=" + lat + "," + lng;
  placeApiUrl += "&radius=500";
  placeApiUrl += "&types=restaurant";
  placeApiUrl += "&keyword=" + encodeURIComponent(keyword);
  placeApiUrl += "&language=ja";
  
  //Places APIにリクエスト
  options = { muteHttpExceptions: true };
  var placeJson = UrlFetchApp.fetch(placeApiUrl, options);
  
  //JSON文字列をパースしてオブジェクトにする
  var placeData = JSON.parse(placeJson.getContentText());
  
  //お店情報取得
  var placesList = new Array();
  var nextPageToken = undefined;
  if (placeData.status == "OK"){
    //resultsをplacesList配列に結合
    placesList = placesList.concat(placeData.results);
    //next_page_tokenを取得
    nextPageToken = placeData.next_page_token;
  } else {
    //エラーの場合、APIの結果ステータスを返却
    return placeData.status;
  }
  
  //next_page_tokenが取得された場合は次ページあり。
  //next_page_tokenが取得できなくなるまで、
  //次ページ情報の取得を繰り返す。
  while (nextPageToken != undefined){
    //2秒程間隔をおく(連続リクエストすると取得に失敗する)
    Utilities.sleep(2000);
    
    //【Places API】次ページのお店情報取得
    placeApiUrl = "https://maps.googleapis.com/maps/api/place/nearbysearch/json";
    placeApiUrl += "?key=" + MAPS_APIKEY2;
    placeApiUrl += "&pagetoken=" + nextPageToken;
    
    //Places APIにリクエスト
    options = { muteHttpExceptions: true };
    placeJson = UrlFetchApp.fetch(placeApiUrl, options);
    
    //JSON文字列をパースしてオブジェクトにする
    placeData = JSON.parse(placeJson.getContentText());
    
    if (placeData.status == "OK"){
      //resultsをplacesList配列に結合
      placesList = placesList.concat(placeData.results);
      //next_page_tokenを取得
      nextPageToken = placeData.next_page_token;
    } else {
      nextPageToken = undefined;
    }
  }
  
  //ソートを正しく行うため、
  //ratingが設定されていないものを
  //一旦「-1」に変更する。
  for (var i = 0; i < placesList.length; i++) {
    if (placesList[i].rating == undefined){
      placesList[i].rating = -1;
    }
  }
  
  //ratingの降順でソート(連想配列ソート)
  placesList.sort(function(a,b){
    if(a.rating > b.rating) return -1;
    if(a.rating < b.rating) return 1;
    return 0;
  });
  
  //お店情報を返却
  return placesList;
}

/*
 写真URL取得
  photoID:写真ID
*/
function getPhotoURL(photoID) {
  //【Places API】Place PhotoのURL
  var url = "https://maps.googleapis.com/maps/api/place/photo";
  url += "?key=" + MAPS_APIKEY3;
  url += "&maxwidth=400";
  url += "&photoreference=" + photoID;
  return url;
}
【編集注】
Places APIの結果が英語で取得されることが多くなったため、nearbysearchにパラメータ「language=ja」を追加しました。

SpreadSheet.gs
/*==============================
 スプレッドシートの操作
==============================*/

//スプレッドシート
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sheet1 = ss.getSheetByName("UserData");

//// Add Data ////
/*
 新しくユーザ情報を追加
  userId:ユーザID
*/
function addUserInfo(userId){
  var row = sheet1.getLastRow() + 1;
  //ユーザID
  sheet1.getRange(row, 1).setValue(userId);
  //処理状況
  sheet1.getRange(row, 2).setValue("address");
  //ユーザ情報の検索条件クリア
  clearSearchOption(row);
  //ユーザ情報の検索結果クリア
  clearSearchResult(row);
  
  return row;
}

//// Delete Data ////
/*
 ユーザ情報を削除
  userId:ユーザID
*/
function deleteUserInfo(userId){
  var userRow = getUserDataRow(userId);
  if (userRow > 0){
    sheet1.deleteRow(userRow);
  }
}

//// Clear Data ////
/*
 ユーザ情報の検索条件クリア
  row:ユーザ情報の対象行
*/
function clearSearchOption(row){
  //検索住所
  sheet1.getRange(row, 3).setValue("");
  //検索緯度
  sheet1.getRange(row, 4).setValue("");
  //検索経度
  sheet1.getRange(row, 5).setValue("");
  //検索キーワード
  sheet1.getRange(row, 6).setValue("");
}
/*
 ユーザ情報の検索結果クリア
  row:ユーザ情報の対象行
*/
function clearSearchResult(row){
  //検索件数
  sheet1.getRange(row, 7).setValue(0);
  //表示完了件数
  sheet1.getRange(row, 8).setValue(0);
  //結果1〜60
  for (var i = 0; i < 60; i++){
    var col = i + 9;
    sheet1.getRange(row, col).setValue("");
  }
}

//// Get Data ////
/*
 ユーザ情報の行番号を取得
  userId:ユーザID
*/
function getUserDataRow(userId){
  var row = -1;
  var values = sheet1.getDataRange().getValues();
  for (var i = 0; i < values.length; i++){
    if (values[i][0] == userId){
      row = i + 1;
      break;
    }
  }
  return row;
}

/*
 ユーザ情報から処理状況を取得
  row:ユーザ情報の対象行
*/
function getUserStatus(row){
  var status = sheet1.getRange(row, 2).getValue();
  return status;
}
/*
 ユーザ情報から検索住所を取得
  row:ユーザ情報の対象行
*/
function getSearchAddress(row){
  var address = sheet1.getRange(row, 3).getValue();
  return address;
}
/*
 ユーザ情報から検索緯度・検索経度を取得
  row:ユーザ情報の対象行
*/
function getSearchLocation(row){
  var location = new Object();
  location.lat = sheet1.getRange(row, 4).getValue();
  location.lng = sheet1.getRange(row, 5).getValue();
  
  return location;
}
/*
 ユーザ情報から検索キーワードを取得
  row:ユーザ情報の対象行
*/
function getSearchKeyword(row){
  var keyword = sheet1.getRange(row, 6).getValue();
  return keyword;
}
/*
 ユーザ情報から検索件数を取得
  row:ユーザ情報の対象行
*/
function getResultCnt(row){
  var resultCnt = sheet1.getRange(row, 7).getValue();
  return resultCnt;
}
/*
 ユーザ情報から表示完了件数を取得
  row:ユーザ情報の対象行
*/
function getDispCnt(row){
  var dispCnt = sheet1.getRange(row, 8).getValue();
  return dispCnt;
}

//// Set Data ////
/*
 ユーザ情報の処理状況を保存
  row:ユーザ情報の対象行
  status:処理状況
*/
function setUserStatus(row, status){
  //処理状況
  sheet1.getRange(row, 2).setValue(status);
}
/*
 ユーザ情報に検索住所を保存
  row:ユーザ情報の対象行
  address:検索住所
*/
function setSearchAddress(row, address){
  //検索住所
  sheet1.getRange(row, 3).setValue(address);
}
/*
 ユーザ情報に検索緯度・検索経度を保存
  row:ユーザ情報の対象行
  lat:検索緯度
  lng:検索経度
*/
function setSearchLocation(row, lat, lng){
  //検索緯度
  sheet1.getRange(row, 4).setValue(lat);
  //検索経度
  sheet1.getRange(row, 5).setValue(lng);
}
/*
 ユーザ情報に検索キーワードを保存
  row:ユーザ情報の対象行
  keyword:検索キーワード
*/
function setSearchKeyword(row, keyword){
  //検索キーワード
  sheet1.getRange(row, 6).setValue(keyword);
}
/*
 検索結果を保存
  row:ユーザ情報の対象行
  placesList:お店情報リスト
*/
function setSearchResult(row, placesList){
  //検索件数
  sheet1.getRange(row, 7).setValue(placesList.length);
  //表示完了件数
  sheet1.getRange(row, 8).setValue(0);
  //結果1〜60
  for (var i = 0; i < 60; i++){
    var col = i + 9;
    
    if (i < placesList.length){
      //[評価]
      var rating = placesList[i].rating;
      //ratingが0以下のものは「(口コミ情報なし)」に表示変更
      if (rating <= 0) rating = "(口コミ情報なし)";
      //[名前]
      var name = placesList[i].name;
      //[周辺住所]
      var vicinity = placesList[i].vicinity;
      //[写真ID]
      var photoID = "";
      if (placesList[i].photos != undefined){
        photoID = placesList[i].photos[0].photo_reference;
      }
      
      //結果保存 [評価+(改行)+名前+(改行)+周辺住所+(改行)+写真ID]
      var result = rating + "\n" + name + "\n" + vicinity + "\n" + photoID;
      sheet1.getRange(row, col).setValue(result);
    }
  }
}
/*
 ユーザ情報に表示完了件数を保存
  row:ユーザ情報の対象行
  count:表示完了件数
*/
function setDispCount(row, count){
  //表示完了件数
  sheet1.getRange(row, 8).setValue(count);
}

ポイント解説

[26〜32行目] Main.gs
/*
 POSTリクエスト受信
  request:受信リクエスト
*/
function doPost(request) {
  //POSTリクエストをJSONデータにパース
  var receiveJSON = JSON.parse(request.postData.contents);
doPostメソッドで、LINEからWebhook送信されるPOSTリクエストを受信します。

POSTリクエストから取得できるデータは、次のようなJSONデータです。
{"events":[{"type":"message","replyToken":"3ecaxxxxxxxxxxxxxxxxxxxxdbc150f4","source":{"userId":"U4fxxxxxxxxxxxxxxxxxxxx4564b72480","type":"user"},"timestamp":1555561472473,"message":{"type":"text","id":"9712851623997","text":"金沢 近江町市場"}}],"destination":"U268xxxxxxxxxxxxxxxxxxxx17bcb5c15"}


[42〜108行目] Main.gs
//イベント種別取得
var eventType = event.type;
switch(eventType){
  case "follow":  //友だち追加
    :(中略)
    break;
  case "message":  //メッセージ受信
    :(中略)
    break;
  case "unfollow":  //ブロック
    :(中略)
    break;
  case "postback":  //ボタン操作による応答
    :(中略)
    break;
}
event.typeからイベント種別が分かります。
リクエスト種類から何を操作されたかを判別して、処理を分岐しましょう。

イベント種別(event.type)
follow 友だち追加
message メッセージ受信
unfollow ブロック
postback ボタン操作による応答


[131〜135行目] Main.gs
var messageType = event.message.type;
if (messageType != "text" && messageType != "location"){
  //メッセージがテキスト、位置情報以外ならば終了
  return messages; 
}
イベント種別が「message」の場合、event.message.typeからメッセージ種類を取得しましょう。
住所入力の処理では、メッセージ種別が「text」「location」のみを対象として、他のメッセージは無視します。

メッセージ種別(event.message.type)
text テキストメッセージ
sticker スタンプメッセージ
image 画像メッセージ
video 動画メッセージ
audio 音声メッセージ
location 位置情報メッセージ


[145〜176行目] Main.gs
//テキスト受信
if (messageType == "text"){  
  //テキストメッセージ取得
  var address = event.message.text;
  :(中略)
}
//位置情報受信
else if (messageType == "location") {
  :(中略)
  //LINEから送られてきた緯度・経度を取得
  location.lat = event.message.latitude;
  location.lng = event.message.longitude;
  //LINEから送られてきた住所を取得
  var address = event.message.address;
  :(中略)
}
「text」の場合、event.message.textからテキストメッセージを取得します。
「location」の場合、event.message.latitudeから緯度、event.message.longitudeから経度、event.message.addressから住所を取得します。


[81〜105行目] Main.gs
case "postback":  //ボタン操作による応答
  
  //「最初から検索」を選択
  if (event.postback.data == "gotoFirst"){
    :(中略)
  }
  //「続きを見る」を選択
  else if (event.postback.data == "nextResults"){
    :(中略)
  }
リクエスト種類が「postback」の場合、event.postback.dataの内容から選択されたボタンが判別できます。


[278〜296行目] Main.gs
/*
 LINEの応答
  replyToken:応答トークン
  messages:応答メッセージ
*/
function replyMessage(replyToken, messages){
  var replyURL = "https://api.line.me/v2/bot/message/reply";
  UrlFetchApp.fetch(replyURL, {
    "headers": {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + ACCESS_TOKEN
    },
    "method": "post",
    "payload": JSON.stringify({
      "replyToken": replyToken,
      "messages": messages
    }),
  });
}
応答メッセージ送信は、「https://api.line.me/v2/bot/message/reply」のアドレスを叩きます。

ヘッダ情報
Authorization Messaging APIのチャネル基本設定で取得した「アクセストークン」を指定します。
ペイロード情報(データ部分)
replyToken 受信リクエストのevent.replyTokenから取得できる「応答トークン」を指定します。
messages 応答メッセージのJSONデータを指定します。

複数のメッセージを返す場合は、messagesに配列のJSONデータを指定できます。
単一のテキストメッセージ:
{
    "type": "text",
    "text": "メッセージひとつ"
}
複数のテキストメッセージ:
[
    {
        "type": "text",
        "text": "1つ目のメッセージ"
    },
    {
        "type": "text",
        "text": "2つ目のメッセージ"
    }
]


[510〜670行目] Main.gs
//表示する検索結果分のメッセージを作成
var bubbleList = new Array();
for (var i = 0; i < resultsList.length; i++){
  :(中略)
  //Flexメッセージに設定するBubbleメッセージを作成
  var bubbleMsg = {
    "type": "bubble",
    "body": {
      "type": "box",
      "layout": "vertical",
      "contents": [
        {
          "type": "text",
          "text": rating + " " + star,
          "wrap": true,
          "color": "#ff8c00"
        },
        {
          "type": "text",
          "text": name,
          "wrap": true
        },
        {
          "type": "text",
          "text": vicinity,
          "wrap": true,
          "color": "#778899",
          "margin": "xl"
        }
      ]
    },
    "footer": {
      "type": "box",
      "layout": "horizontal",
      "contents": [
        {
          "type": "button",
          "action": {
            "type": "uri",
            "label": "詳細表示",
            "uri": "https://maps.google.co.jp/maps?q="
                   + encodeURIComponent(name + " " + vicinity)
                   + "&z=15&iwloc=A"
          }
        },
        {
          "type": "button",
          "action": {
            "type": "postback",
            "label": "最初から検索",
            "data": "gotoFirst",
          }
        }
      ]
    },
    "styles": {
      "footer": {
        "backgroundColor": "#b0c4de",
        "separator": true,
        "separatorColor": "#c0c0c0"
      }
    }
  };
  :(中略)
  bubbleList.push(bubbleMsg);
}
:(中略)
//Flexメッセージ作成
var flexMsg = {
  "type": "flex",
  "altText": "口コミランキング順お店情報",
  "contents": {
    "type": "carousel",
    "contents": bubbleList
  }
};
お店情報の応答メッセージには、柔軟にレイアウトできる「Flex Message」を使用しました。

Flexメッセージは、コンテナ、ブロック、コンポーネントの3層から構成されます。
最初は複雑に思えますが、ルールを覚えてしまえば、HTMLやCSSを組む感じでオシャレなレイアウトのメッセージを作成できると思います。

Flexメッセージの詳しい仕様は、LINE公式のドキュメントを参照してください。
Flex Messageを使う [LINE Developers]
Flex Messageのレイアウト [LINE Developers]

Flexメッセージを使う時のポイントです。

1.テキストの途中で改行が使えない。
1行分をはみ出した時の自動改行は「"wrap": true」の指定でできるのですが、意図した位置で「\n」の改行コードを指定して改行することはできません。
Flexメッセージはボックス構成のレイアウトになっているので、テキストのボックスを分けることで改行と同じことはできます。

2. 絵文字が使えない。
普通のテキストメッセージでは絵文字が使えるのですが、Flexメッセージ内には使えないようです。
メッセージを華やかに飾るには、画像を表示するしかないですね。

3.横並びメッセージは10個まで。
「カルーセル」コンテナに、「バブル」コンテナを複数含ませることで、横にスクロールするメッセージが表示できます。
非常に便利なのですが、カルーセルに入るバブルは最大10個までです。
それ以上のメッセージバブルを表示したい場合は、カルーセルを分割するように設計しましょう。

あなたへのおすすめ記事