今日のランチを決めてくれるLINE Bot を作ったら盛り上がった話（改良版）

# はじめに
この記事は、「[今日のランチを決めてくれるLINE Bot を作ったら盛り上がった話](https://qiita.com/cog1t0/items/ce21c80626af47c1c1d0)」の記事の改良版です！
元記事は、後半の実装の説明が雑で初心者さんには分かりにくいところが多々あったので、この記事ではなるべくコード触るのが初めての人でも分かりやすいように手順立てています

# 事前準備
[5分でつくるLINEBot](https://qiita.com/cog1t0/items/0d121ee6d48a8c1634fc)　を参考に、オウム返しBotが動く状態まではやっておいてください。

```js:コードの初期状態
var CHANNEL_ACCESS_TOKEN = '①で発行したチャネルアクセストークンを貼り付け'; 
var line_endpoint = 'https://api.line.me/v2/bot/message/reply';
const logSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('log');
var reply_token, json

//ポストで送られてくるので、ポストデータ取得
function doPost(e) {
  // 動作確認用のログ出力
  // log_to_sheet("A", "doPost")

  //JSONをパースする
  json = JSON.parse(e.postData.contents);

  //返信するためのトークン取得
  reply_token= json.events[0].replyToken;
  if (typeof reply_token === 'undefined') {
    return;
  }

  // 返信するメッセージを作成
  messages = test_message()

  // テスト動作ができたら、your_messageの中身を追加して、自分のオリジナルのLINEBotにしてみましょう
  // messages = your_message()

  // メッセージの中身を確認したい時には以下のコメントアウトを外して、sheet「log」に書き込まれる内容を確認しましょう
  // log_to_sheet("A", messages)

  // メッセージを返信
  send_reply_message(messages)

  return ContentService.createTextOutput(JSON.stringify({'content': 'post ok'})).setMimeType(ContentService.MimeType.JSON);
}

// LINE bot にリプライをする関数
function send_reply_message(messages) {
  UrlFetchApp.fetch(line_endpoint, {
    'headers': {
      'Content-Type': 'application/json; charset=UTF-8',
      'Authorization': 'Bearer ' + CHANNEL_ACCESS_TOKEN,
    },
    'method': 'post',
    'payload': JSON.stringify({
      'replyToken': reply_token,
      'messages': messages,
    }),
  });
}

// 動作確認用のオウム返しのメッセージを作成する関数
function test_message() {
  //送られたLINEメッセージを取得
  var user_message = json.events[0].message.text;  

  //送られたメッセージをそのままオウム返し
  var reply_messages = [user_message];
 
  // メッセージを返信
  var messages = reply_messages.map(function (v) {
    return {'type': 'text', 'text': v};    
  });

  return messages
}

// 自分で編集する関数
function your_message() {
  // 好きな処理を書く
  var your_messages

  return your_messages
}

// 処理の確認用にログを出力する関数
function log_to_sheet(column, text) {
  if(logSheet.getRange(column + "1").getValue() == ""){
    lastRow = 0
  } else if(logSheet.getRange(column + "2").getValue() == ""){
    lastRow = 1
  } else {
    var lastRow = logSheet.getRange(column + "1").getNextDataCell(SpreadsheetApp.Direction.DOWN).getRow();
    // 無限に増えるので1000以上書き込んだらリセット
    console.log("lastRow", lastRow)
    if(lastRow >= 1000){
      logSheet.getRange(column + "1:" + column + "10").clearContent()
      lastRow = 0
    }
  }
  var putRange = column + String(lastRow + 1)
  logSheet.getRange(putRange).setValue(text);
}
```

sheet1に以下の情報を貼り付けてください。
```csvs
店名	ジャンル	おすすめ度(0~100)	住所
龍祥軒 	中華	80	東京都港区芝浦3-6-8
武蔵	ラーメン	70	東京都港区芝浦3-12-5
はんぞう	和食	70	東京都港区芝浦3-6-8トキビル
鉄板王国	肉	70	東京都港区芝浦3-7-15チトセビル
錦里	中華	90	東京都港区芝浦3-11-6 芝浦Kビル2F
壱角家	ラーメン	60	東京都港区芝浦３丁目７−１５ チトセビル
江戸前芝浜	和食	90	東京都港区芝２丁目２２−２３冨味ビル１階
大阪焼肉・ホルモン ふたご	肉	60	東京都港区芝５丁目１２−９
```

このような感じ
![スクリーンショット 2022-10-12 23.04.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27192/6f980971-2b4c-0e62-2a82-f3fab4e0ee6f.png)

# やりたいこと
①「中華」「ラーメン」「和食」「肉」が入力されたら、そのジャンルのお店からランダムでひとつの情報を返信する
②お店の情報に住所が設定されている場合は、位置情報を返信する
③「おまかせ」という文字が入力されたら、おすすめ度70以上のお店からランダムでひとつの情報を返信する
# 実装
## ①「中華」「ラーメン」「和食」「肉」が入力されたら、そのジャンルのお店からランダムでひとつの情報を返信する

:::note
まずはざっくり書いてから、使いやすいように整理をしていきます
:::

## ざっくり実装

### Lv.1 最低限動くものを作る

:::note warn
初期状態のコードの一番下に付け足していきます
初期状態のコードのsend_reply_messageなどの関数を消すと動かなくなるので注意してください
:::

スプレッドシート上の全データは以下で取得できます

```js
sheet.getDataRange().getValues();
```

このデータを順番に検査してジャンルが一致する行番号を新しい配列に詰めていきましょう

まずは「中華」に一致するものを取得してみましょう
```js:コード.gsの一番下に追加する
function searchData(){
  // sheet1 を指定
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('sheet1');
  
  // 全データを取得
  const all_data = sheet.getDataRange().getValues();
  // 条件に合致するデータを詰める配列
  let target_rows = [];

  // 実行ログでデータを確認する
  console.log(all_data)

  // 全データを順番にチェック
  for(let i = 0; i < all_data.length; i++) {
    console.log(all_data[i][1])

    // all_data[i]　でi行目のデータを取得
    // all_data[i][1] で1行目の1番目(0から始まるので本当は2番目)のデータを取得
    if(all_data[i][1] == "中華"){
      target_rows.push(i+1)
    }
  }

  // 実行ログでデータを確認する  
  console.log(target_rows)
}
```

書けたら挙動を確認します。
searchdDataを選択し、実行ボタンを押すと、console.log() の内容が実行ログで確認できます
![スクリーンショット 2022-10-14 22.52.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27192/3544406c-4289-673b-eae4-1391d7da4e90.png)

「中華」は2行目のデータと6行目のデータであるのは正しいので、期待通りの挙動をしてくれています。

### Lv.2 使いやすいように整理

先ほどの実装のままでもいいのですが、今のままでは、「中華」しか検索できないようになっています。
ラーメンや和食でも検索できるように、検索ワードは外から持ってくるようにしてみましょう

```js
function searchData(keyword){ // 引数keywordを追加 <= new
  // sheet1 を指定
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('sheet1');
  
  // 全データを取得
  const all_data = sheet.getDataRange().getValues();
  // 条件に合致するデータを詰める配列
  let target_rows = [];

  // 実行ログでデータを確認する
  console.log(all_data)

  // 全データを順番にチェック
  for(let i = 0; i < all_data.length; i++) {
    console.log(all_data[i][1])

    // all_data[i]　でi行目のデータを取得
    // all_data[i][1] で1行目の1番目(0から始まるので本当は2番目)のデータを取得
    if(all_data[i][1] == keyword){ // 引数keyword　と同じものかどうかをチェック <= new
      target_rows.push(i+1)
    }
  }

  // 実行ログでデータを確認する  
  console.log(target_rows)
}
```

この状態で再度「searchData」を実行してみると以下のようになります
![スクリーンショット 2022-10-14 23.10.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27192/d693a511-a892-8cff-3515-29c99dcaffdd.png)

### Lv.3 小さい単位でテストをしてみる
keywordを指定してテストをしてみるには以下のようにtest用の関数を追加してそれを実行します
```js
function test(){
  searchData("ラーメン")
}
```
![スクリーンショット 2022-10-14 23.15.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27192/23f7a001-b4f3-c6fe-6171-5687bfb165e9.png)

すると正しく情報が取れています。

:::note
外から持ってこれるものは、なるべく外から持ってきて、シンプルにする方が、バグも少なく使いやすいプログラムになります。
:::

その他、以下の部分が、その場では必要なく、外から持ってこれます

```js
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('sheet1');

const all_data = sheet.getDataRange().getValues();
```

```js
if(all_data[i][1] == "中華"){
  target_rows.push(i+1)
}
```
の[1]の部分を外から持ってくるようにすると、ジャンル以外の検索にも対応できるようになります

```js:関数test を追加 & さっき追加したsearchDataを書き換え 
function test(){
  // sheet1 を指定
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('sheet1');
  // 全データを取得
  const all_data = sheet.getDataRange().getValues();

  console.log(searchData("ラーメン", all_data, 2)) // searchDataの処理の結果をログに出力。
  // 0番はじまりだと分かりにくいのでスプレッドシート上の2番目という指定にし、seachDataで使うときに-1するように
}

function searchData(keyword, all_data, target_column){
    // 条件に合致するデータを詰める配列
  let target_rows = [];
  console.log(all_data)
  // 全データを順番にチェック
  for(let i = 0; i < all_data.length; i++) {
    console.log(all_data[i][target_column-1])
    if(all_data[i][target_column-1] == keyword){
      target_rows.push(i+1)
    }
  }
  return target_rows // 関数の処理の結果をreturn で返却する
}
```

続いて、配列から、ランダムに数字を取り出す関数を作ります
```js: コード.gsの一番下に追加
function randomSelect(array, max){
  var randomSelect = [];
  while(randomSelect.length < max && array.length > 0){
    const rand = Math.floor(Math.random() * array.length);
    randomSelect.push(array[rand]);
    array.slice(rand, 1); // 結果が重複しないように、元の配列からは削除
  }
  if(max == 1 && randomSelect.length == 1){
    // ひとつだけ取り出す場合は、配列から取り出して返却
    return randomSelect[0]
  } else {
    return randomSelect
  }
}
```

テスト用の関数を修正して内容を試してみましょう
```js:関数testを書き換え
function test(){
  // sheet1 を指定
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('sheet1');
  // 全データを取得
  const all_data = sheet.getDataRange().getValues();

  const target_rows = searchData("ラーメン", all_data, 2)
  console.log(target_rows) // searchDataの処理の結果をログに出力

  console.log(randomSelect(target_rows, 1))
}
```

実行のたびに、値がランダムに選ばれました。


ここまででいったんLINEBotを実装してみましょう
＊　[5分でつくるLINEBot](https://qiita.com/cog1t0/items/0d121ee6d48a8c1634fc)　を参考に、オウム返しBotが動く状態まではやっておいてください。

```js:doPostを書き換え
//ポストで送られてくるので、ポストデータ取得
function doPost(e) {
  // 動作確認用のログ出力
  // log_to_sheet("A", "doPost")

  //JSONをパースする
  json = JSON.parse(e.postData.contents);

  //返信するためのトークン取得
  reply_token= json.events[0].replyToken;
  if (typeof reply_token === 'undefined') {
    return;
  }

  //送られたLINEメッセージを取得
  var user_message = json.events[0].message.text;  

  // シート名を指定してsheetとして持っておく
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('sheet1');

  // 対応できるジャンルを配列として用意しておく
  let menu_texts = ["中華","肉", "ラーメン", "和食"]

  // 全データを取得
  const all_data = sheet.getDataRange().getValues();
  
  // 返信するメッセージ
  let messages

  // user_messageが配列の中のどれかと一致した場合
  if( menu_texts.includes(user_message) ) {
    let target_rows = searchData(user_message, all_data, 2); // 2列目は「ジャンル」
    let target_row = randomSelect(target_rows, 1) // ランダムに一つを選択

    messages = [{
      'type': 'text',
      'text': "店名：" + sheet.getRange(target_rows[0],1).getValue()
    }];
  }

  // メッセージの中身を確認したい時には以下のコメントアウトを外して、sheet「log」に書き込まれる内容を確認しましょう
  // log_to_sheet("A", messages)

  // メッセージを返信
  send_reply_message(messages)

  return ContentService.createTextOutput(JSON.stringify({'content': 'post ok'})).setMimeType(ContentService.MimeType.JSON);
}
```

このように指定したジャンルのお店の名前が返却されたらOKです！
![スクリーンショット 2022-10-18 1.43.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27192/1824a7a6-85ea-48e8-7497-a4f771175ea1.png)

:::note warn
動かなかった場合は
①log_to_sheetの関数を至る所に埋め込んで「どこまでは動いていて、どこから動かないのか」を特定する
②想定通りに動かなかった原因を調べる
③関数に分割しているので、test関数で部分的に関数を動かして動作を試してみる
など駆使して、バグに対応してみましょう
コピペでも一文字足りないと動かないものなので、注意深く見てください
:::

## ②お店の情報に住所が設定されている場合は、位置情報を返信する

GASだと住所から、位置情報を計算できるので、
[【GoogleMaps APIを使わずに!!】GASで住所から各種情報を取得するTips](https://qiita.com/s_maeda_fukui/items/eecca243030617c40a7b) などを参考に実装します

住所から緯度経度を取得する関数を実装します
```js:コード.gsの一番下に追加
function getLatLng(address) {
  var
    geocoder = Maps.newGeocoder() // Creates a new Geocoder object.
    , geocoder = geocoder.setLanguage('ja') // Use Japanese
    , response = geocoder.geocode(address).results[0]; // ets the approximate geographic points for a given address.

    return {
      "latitude": response.geometry.location.lat,
      "longitude": response.geometry.location.lng
    }
}
```


```js:doPostを書き換え
// 中略

  // user_messageが配列の中のどれかと一致した場合
  if( menu_texts.includes(user_message) ) {
    let target_rows = searchData(user_message, all_data, 2); // 2列目は「ジャンル」
    let target_row = randomSelect(target_rows, 1) // ランダムに一つを選択

    // シートからお店の住所の情報(4列目)を取り出します
    let address = sheet.getRange(target_row,4).getValue()
    
    if(!address){
      // 住所がない場合
      messages = [{
        'type': 'text',
        'text': "店名：" + sheet.getRange(target_rows[0],1).getValue()
      }];
    } else {
      tmp = {
          "type": "location",
          "title": sheet.getRange(target_row,1).getValue(),
          "address": address
      }
      // 住所がある場合
      // ふたつの{}をObject.assignで合体させる
      messages = [Object.assign(tmp, getLatLng(address))];
    }
  }
```

このように、位置情報が返却されれば成功です！
![S__4530248.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27192/b21bc826-ef73-87c8-5bcd-d7c0b284c57e.jpeg)


## ③「おまかせ」という文字が入力されたら、おすすめ度70以上のお店からランダムでひとつの情報を返信する
今度はジャンルではなく、おすすめ度の点数で出力するお店の情報を変更します

searchDataをコピーして以下のような関数を追加します。
引数scoreよりも高い点数のお店の情報のみ返却します
```js:コード.gsの一番下に追加
function searchDataByScore(score, all_data, target_column){
    // 条件に合致するデータを詰める配列
  let target_rows = [];
  console.log(all_data)
  // 全データを順番にチェック
  for(let i = 1; i < all_data.length; i++) {
    console.log(all_data[i][target_column-1])
    if(all_data[i][target_column-1] > score){
      target_rows.push(i+1)
    }
  }
  return target_rows // 関数の処理の結果をreturn で返却する
}
```

```js:doPostを書き換え
//ポストで送られてくるので、ポストデータ取得
function doPost(e) {
  // 動作確認用のログ出力
  // log_to_sheet("A", "doPost")

  //JSONをパースする
  json = JSON.parse(e.postData.contents);

  //返信するためのトークン取得
  reply_token= json.events[0].replyToken;
  if (typeof reply_token === 'undefined') {
    return;
  }

  //送られたLINEメッセージを取得
  var user_message = json.events[0].message.text;  

  // シート名を指定してsheetとして持っておく
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('sheet1');

  // 対応できるジャンルを配列として用意しておく
  let menu_texts = ["中華","肉", "ラーメン", "和食"]

  // 全データを取得
  const all_data = sheet.getDataRange().getValues();
  
  // 返信するメッセージ
  let messages,target_rows,target_row
  // user_messageが配列の中のどれかと一致した場合
  if( menu_texts.includes(user_message) ) {
    target_rows = searchData(user_message, all_data, 2); // 2列目は「ジャンル」
    target_row = randomSelect(target_rows, 1) // ランダムに一つを選択
  } else if (user_message == "おまかせ") {
    target_rows = searchDataByScore(69, all_data, 3); // 3列目は「おすすめ度」
    target_row = randomSelect(target_rows, 1) // ランダムに一つを選択
  }

  // シートからお店の住所の情報(4列目)を取り出します
  let address = sheet.getRange(target_row,4).getValue()
  
  if(!address){
    // 住所がない場合
    messages = [{
      'type': 'text',
      'text': "店名：" + sheet.getRange(target_rows[0],1).getValue()
    }];
  } else {
    tmp = {
        "type": "location",
        "title": sheet.getRange(target_row,1).getValue(),
        "address": address
    }
    // 住所がある場合
    messages = [Object.assign(tmp, getLatLng(address))];
  }

  // メッセージの中身を確認したい時には以下のコメントアウトを外して、sheet「log」に書き込まれる内容を確認しましょう
  // log_to_sheet("A", messages)

  // メッセージを返信
  send_reply_message(messages)

  return ContentService.createTextOutput(JSON.stringify({'content': 'post ok'})).setMimeType(ContentService.MimeType.JSON);
}
```

これで「おまかせ」という文字にも対応することできました。
![S__4530251.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27192/768aac6e-b7d1-3529-c293-eb93ae6533a8.jpeg)


## おまけ
このままでも良いですが、リッチメニューを設定すると、使う人はいちいち文字を入力する手間が省け、使いやすくなるので設定してみてください
![S__4530252.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27192/58068ddd-49a8-30b4-9534-bff2a0fddee3.jpeg)

参考:[LINE Botでリッチメニューを表示する](https://qiita.com/bow_arrow/items/32ac5d2b4c67bd0c1dc2)


# おわり
「[今日のランチを決めてくれるLINE Bot を作ったら盛り上がった話](https://qiita.com/cog1t0/items/ce21c80626af47c1c1d0)」の実装を、なるべく順序立てて丁寧に解説してみました。
作るときには以下を意識してみるといいんじゃないかと思います

:::note
・作りたいものを、できるだけ詳細に文字で書き起こす（今回の例で言うと、①ジャンルでお店の情報を取得、②位置情報があればそれを返す、③おまかせ機能）
:::

:::note
・作りたいものを、分割して細かくゴールを持つ（それぞれの関数を作るくらいの小さなゴールをたくさん持つ）
:::

:::note
・小さく作って、小さく試す(一気に作って一気に試そうとすると大抵うまく動かないので、小さくはじめて小さくテストを行う)
:::

:::note
・うまく動かなくなったら、オウム返しに戻って、ちょっとずつプログラムを確認していく
:::

よき開発ライフを！