【30分でやる】Google Spread Sheetで管理するLINE BOTの作り方

#はじめに
LINEのFAQボットつくりたいなー、と思い立ち、その時ついでに思い付いたいくつかの自分のニーズにこたえられる要件を追加して実装してみました。思ったのはこんなの。

* LINEのBOT
* FAQのメンテナンスが簡単
* サーバー環境の構築とかがめんどくさいのでそれも簡単にやりたい

⇒　Google Spread Sheet × Google Apps Script × LINE の組み合わせが最強だった。

--- 

##やったこと
* LINE上で質問をすると、それに対してテキストの部分一致で対象の「質問」を抽出して、それに対する「回答」を出してくれるFAQBOTを作りました。
* 質問・回答の追加・更新などのメンテナンスはGoogle Spread Sheet上で管理。
* あと「誰が」「どんな質問したか」もわかるようにしてあります。

--- 

##準備するもの
ホントのホントに1からやってみようと思ってやったので、準備したのはこのぐらいです。
さすがにここから躓くことはないかな。。。

* LINEのアカウント
* Googleのアカウント

---

#手順

##作業1. LINEの開発用Channel作成　～　TOKEN発行

連携させたいLINE公式アカウントのChannel発行とTOKENの発行をまずやります。
といっても、私はこちらの記事の「Channelを作成する」までを参考にさせていただきました。
そのまんまで問題ないです（記載されてる通り、developersの画面は後々利用するので、発行後はひらきっぱにしておいてください）。

https://qiita.com/nkjm/items/38808bbc97d6927837cd

##作業2. Google Spread Sheet上での準備

スプレッドシートの準備をします。

スプレッドシートを作成したら、後々使うシートを２つ作成して下さい。
その名も「faq」と「maybe」と「debug」。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/0b915eae-26ff-a7e4-b1e6-a249cc65b0a7.png)

--- 

シートを作成したら「faq」のシートに、準備用でテキトーにFAQ用の質問と回答をA列とB列それぞれに記載しておきましょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/c1cd2c50-98e7-76df-ff1d-40dc6466f60d.png)

--- 

##作業3.スクリプトエディタでGASを編集

スクリプトエディタを開きます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/61d7c111-c0b3-fcbe-d454-0e97be6583c1.png)

開いたスクリプトエディタ上に、次のソースコードをコピペ。

--- 

```js

// 利用しているシート
var SHEET_ID = '（①スプレッドシートのIDをコピペ）';
// 利用しているSSのシート名（※変えるとみえなくなる）
var SHEET_NAME = 'faq';
// 利用しているもしかしてSSのシート名（※変えるとみえなくなる）
var SHEET_NAME_MAYBE = 'maybe';

// LINE Message API アクセストークン
var ACCESS_TOKEN = '（②LINEのdevelopersアカウント上で取得したTOKENをコピペ）';
// 通知URL
var PUSH = "https://api.line.me/v2/bot/message/push";
// リプライ時URL
var REPLY = "https://api.line.me/v2/bot/message/reply";
// プロフィール取得URL
var PROFILE = "https://api.line.me/v2/profile";

/**
 * doPOST
 * POSTリクエストのハンドリング
 */
function doPost(e) {
  var json = JSON.parse(e.postData.contents);
  reply(json);
}

/** 
 * doGet
 * GETリクエストのハンドリング
 */
function doGet(e) {
    return ContentService.createTextOutput("SUCCESS");
}

/** 
 * reply
 * ユーザからのアクションに返信する
 */
function reply(data) {
  // POST情報から必要データを抽出
  var lineUserId = data.events[0].source.userId;
  var postMsg    = data.events[0].message.text;
  var replyToken = data.events[0].replyToken;
  var action    = data.events[0].message.action;
  // 記録用に検索語とuserIdを記録
//  debug(postMsg, lineUserId);
  debug(action, lineUserId);
  
  // 検索語に対しての回答をSSから取得
  var answers = findResponseArray(postMsg);
  
  // 回答メッセージを作成
  var replyText = '「' + postMsg + '」ですね。かしこまりました。以下、回答です。';
  // 回答の有無に応じて分岐
  if (answers.length === 0) {
    // 「類似の検索キーワード」がないかチェック
    var mayBeWord = findMaybe(postMsg);
    if (typeof mayBeWord === "undefined") {
      // 回答がない場合の定型文
      sendMessage(replyToken, '答えが見つかりませんでした。別のキーワードで質問してみてください。');        
    } else {
      sendMayBe(replyToken, mayBeWord);
    }
  } else {
    // 回答がある場合のメッセージ生成
    answers.forEach(function(answer) {
      replyText = replyText + "\n\n＝＝＝＝＝＝＝＝＝＝＝＝＝\n\nQ：" + answer.key + "\n\nA：" + answer.value;
    });
    
    // 1000文字を超える場合は途中で切る
    if (replyText.length > 1000) {
      replyText = replyText.slice(0,1000) + "……\n\n＝＝＝＝＝＝＝＝＝＝＝＝＝\n\n回答文字数オーバーです。詳細に検索キーワードを絞ってください。";
    }
    // メッセージAPI送信
    sendMessage(replyToken, replyText);
  }
}

// SSからデータを取得
function getData() {
  var sheet = SpreadsheetApp.openById(SHEET_ID).getSheetByName(SHEET_NAME);
  var data = sheet.getDataRange().getValues();

  return data.map(function(row) { return {key: row[0], value: row[1], type: row[2]}; });
}

// SSから「もしかして」データを取得
function getMayBeData() {
  var sheet = SpreadsheetApp.openById(SHEET_ID).getSheetByName(SHEET_NAME_MAYBE);
  var data = sheet.getDataRange().getValues();
  return data.map(function(row) { return {key: row[0], value: row[1], type: row[2]}; });
}

// 単語が一致したセルの回答を配列で返す
function findResponseArray(word) {
  // スペース検索用のスペースを半角に統一
  word = word.replace('　',' ');
  // 単語ごとに配列に分割
  var wordArray = word.split(' ');
  return getData().reduce(function(memo, row) {
    // 値が入っているか
    if (row.value) {
      // AND検索ですべての単語を含んでいるか
      var matchCnt = 0;
      wordArray.forEach(function(wordUnit) {
        // 単語を含んでいればtrue
        if (row.key.indexOf(wordUnit) > -1) {
          matchCnt = matchCnt + 1;
        }
      });
      if (wordArray.length === matchCnt) {
        memo.push(row);
      }
    }
    return memo;
  }, []) || [];
}

// 単語が一致したセルの回答を「もしかして」を返す
function findMaybe(word) {
  return getMayBeData().reduce(function(memo, row) { return memo || (row.key === word && row.value); }, false) || undefined;
}

// 画像形式でAPI送信
function sendMessageImage(replyToken, imageUrl) {
  // replyするメッセージの定義
  var postData = {
    "replyToken" : replyToken,
    "messages" : [
      {
        "type": "image",
        "originalContentUrl": imageUrl
      }
    ]
  };
  return postMessage(postData);
}

// LINE messaging apiにJSON形式でデータをPOST
function sendMessage(replyToken, replyText) {  
  // replyするメッセージの定義
  var postData = {
    "replyToken" : replyToken,
    "messages" : [
      {
        "type" : "text",
        "text" : replyText
      }
    ]
  };
  return postMessage(postData);
}

// LINE messaging apiにJSON形式で確認をPOST
function sendMayBe(replyToken, mayBeWord) {  
  // replyするメッセージの定義
  var postData = {
    "replyToken" : replyToken,
    "messages" : [
      {
        "type" : "template",
        "altText" : "もしかして検索キーワードは「" + mayBeWord + "」ですか？",
        "template": {
          "type": "confirm",
          "actions": [
            {
                "type":"postback",
                "label":"はい",
                "data":"action=detail",
            },
            {
                "type": "message",
                "label": "いいえ",
                "text": "いいえ、違います。"
            }
          ],
          "text": "答えが見つかりませんでした。もしかして検索キーワードは「" + mayBeWord + "」ですか？"
        }

      }
    ]
  };
  return postMessage(postData);
}

// LINE messaging apiにJSON形式でデータをPOST
function postMessage(postData) {  
  // リクエストヘッダ
  var headers = {
    "Content-Type" : "application/json; charset=UTF-8",
    "Authorization" : "Bearer " + ACCESS_TOKEN
  };
  // POSTオプション作成
  var options = {
    "method" : "POST",
    "headers" : headers,
    "payload" : JSON.stringify(postData)
  };
  return UrlFetchApp.fetch(REPLY, options);      
}

/** ユーザーのアカウント名を取得
 */
function getUserDisplayName(userId) {
  var url = 'https://api.line.me/v2/bot/profile/' + userId;
  var userProfile = UrlFetchApp.fetch(url,{
    'headers': {
      'Authorization' :  'Bearer ' + ACCESS_TOKEN,
    },
  })
  return JSON.parse(userProfile).displayName;
}

// userIdシートに記載
function lineUserId(userId) {
  var sheet = SpreadsheetApp.openById(SHEET_ID).getSheetByName('userId');
  sheet.appendRow([userId]);
}

// debugシートに値を記載
function debug(text, userId) {
  var sheet = SpreadsheetApp.openById(SHEET_ID).getSheetByName('debug');
  var date = new Date();
  var userName = getUserDisplayName(userId);
  sheet.appendRow([userId, userName, text, Utilities.formatDate( date, 'Asia/Tokyo', 'yyyy-MM-dd HH:mm:ss')]);
}

```

--- 


コピペした中の「①スプレッドシートのIDをコピペ」にスプレッドシートのIDをコピペ
（まんまかよ）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/abed10cc-2c00-32f6-ff67-e544f0905eb1.png)

--- 


※スプレッドシートのIDは「スプレッドシート画面を開いた時のURLのここ」です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/56ac8b0a-7868-bd86-e742-0909484c1d97.png)

--- 


続いてコピペした中の「②LINEのdevelopersアカウント上で取得したTOKENをコピペ」にスプレッドシートのIDをコピペ
（再度まんま）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/4a91985d-c9ca-521d-6770-9113d4ca759b.png)

--- 


※TOKENは、さっき作ったLINE developersのアカウント画面で発行したもの。ここ。
（または再発行してもよし）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/2801d797-447d-2f52-fd12-663124e22189.png)

--- 


##作業3. アプリケーションを公開
GASの画面で「ウェブアプリケーションとして導入」を選択
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/3c51c913-be26-9e85-b9e1-578a6e8260ec.png)

--- 


こんな感じで入力。「導入」を選択。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/06d62e1d-d1f1-8bd3-61b4-b39402a3b497.png)

--- 


「許可を確認」を選択
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/050a72c4-71c1-e817-7a4f-ea68f2e6eb52.png)

--- 


Googleのアカウント承認画面が出るので、ログイン中のアカウントを選択します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/463c3ea3-80f7-53ca-4a91-364062752908.png)

--- 


なんかドキドキしつつ進めます。「詳細」から
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/4240aa79-3e12-188b-253e-08ae2efbcec0.png)

--- 


安全ではないページに移動します（ドキドキした）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/d60f02bf-4e49-e228-0181-21d30100fd43.png)

--- 


「許可」を選択
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/a0b22011-2197-148f-f09f-6edf435f3139.png)

--- 

画面に戻ってきてウェブアプリケーションのURLが発行されるので、こいつをコピペします
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/64497fe6-72da-f887-6444-b755524e5e16.png)

--- 


##作業4. LINEアカウントでWebhookの設定
今しがた発行された「ウェブアプリケーションのURL」を、LINE developersの「WEBhook URL」に設定します。
（一応接続確認もしましょう）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/21d8627e-522d-bc10-901b-26f2d2cb3ede.png)

--- 


##作業5. できた（はず）
諸々設定がうまくいっていれば、これで発行されたLINE公式アカウント宛てにLINEのメッセージを送ると、質問に対して回答が返ってくるはずです。

--- 


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/21c9f830-b292-5a15-e425-3eede51b0270.png" width=50%>

--- 


#おしまい
どっちかというと非エンジニア向けにいちいちキャプチャをとりながらやってみました。
ソースいじりたい方はぜひいじってみて下さい。
（というか、エンジニアの方からはソースの至らぬところに対してのアドバイスをいただきたいです。。。）

--- 


あと、「やってるけど動かねえ！」みたいなあるあるのトラブルシューティングもあるんですが、それはちょっとボリューム増えちゃうので、次回とかにしたいと思います。

おわり。