Google Apps Script + LINE Messaging API + JavaScriptで誕生日リマインダーのLINE BOTを作成してみた。

こんにちは、エンジニアのうるです。
今回はタイトルにあるようにGoogle Apps Script + LINE Messaging API + JavaScriptで誕生日をリマインドしてくれるLINE BOTを作ってみました。

[リポジトリ](https://github.com/YutoUrushima/Birthday-Reminder-Bot)はこちら

![qiita8.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/599964/7105853e-7f94-e006-9fd9-dc366a6e3023.jpeg)



# 作成の背景
ある日Qiitaで見つけた以下の記事を見てフロントエンドエンジニアながら、LINEのBOTを作ってみたいと思い挑戦してみることにしました。
[プログラマーとして初めて親の役に立つものを作った話(LINEbot)](https://qiita.com/enomotoakira/items/64bf4efeccc0248958f1)

自分は全くGASやLINE Messaging APIに知識が無かったので、Udemyで良さげな講座を探していると、Taka ponさんの講座を発見し受講することでかなり参考にさせていただきました。

### 参考講座一覧
- [LINE BOTの作り方。オウム返しボットを作ろう](https://www.udemy.com/course/line-bot-t/)
- [はじめてのLINEBOTの作り方　秘書ボットを作ろう](https://www.udemy.com/course/line-bot-x/)
- [LINEで順番待ち。順番待ちLINEボットの作り方講座](https://www.udemy.com/course/lineline/)


# 使用技術
- Google Chrome
- Google Apps Script（以下、GAS）
- Google Apps Script GitHub アシスタント（Google Chrome拡張機能）
- Google スプレッドシート（データベースとして使用）
- LINE Messaging API
- JavaScript（主にJavaScriptで記述するのですが、一部使えない操作やGASで使える有益な操作も含みます。）
- GitHub, SourceTree

# 作成手順

## ①LINE Messaging APIの準備
LINE Developersのページに移動し、自身のLINEアカウントでログインしてください。
（LINEのアカウントを持っていなければ、多分作る必要があるかと思います。）
![qiita1.jfif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/599964/5528d946-7dee-21f7-2bc9-8dfd140e99b1.jpeg)

色々な認証を終えると、以下のコンソール画面にたどり付きます。
![スクリーンショット 2021-03-01 14.51.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/599964/1962e05d-1871-f9c0-90d1-714e712a4c46.png)

ここから
1. プロバイダの作成
2. チャンネルの作成
3. チャンネルアクセストークンの発行（実装時に使用）
4. webhookの有効化
などの作業が発生します。

ここら辺は[参考講座一覧](#参考講座一覧)の講座を見てもらえると分かりやすいかも。


## ②Google Apps Script(GAS)の準備
今回はスプレッドシートをデータベースとして使用するので、スプレッドシートと紐づくようにGASを起動します。
Google Driveを開いて、左上の「新規作成」→「Google スプレッドシート」から新しいスプレッドシートを開きます。また、このスプレッドシートの[シートID](https://blog-and-destroy.com/33158)というIDを実装時に使用します。

そして、以下の画像のように「ツール」→「スクリプトエディタ」からGASを開きます。
![qiita2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/599964/539b4d6c-8ec1-6d3e-53c6-3491492ddf4d.jpeg)


## ③GitHubと連携
__GitHubのアカウントを持っていない＆知らないなど、必要を感じなければここの操作は必要ないかと思います。__

### (1)Chromeの拡張機能「Google Apps Script GitHub アシスタント」をダウンロード
Google Apps Script GitHub アシスタントを追加することで連携が可能になりますので、ダウンロードします。
[Google Apps Script GitHub アシスタントはこちらから](https://chrome.google.com/webstore/detail/google-apps-script-github/lfjcgcmkmjjlieihflfhjopckgpelofo)


### (2)ダウンロードすると、ツールバーに「Login SCM」という項目が出てくるのでここから自身のGitHubと連携
（項目が出てこない場合はChromeを一度終了して再起動させるなどを試してみてください）
詳しくは[こちらの記事](https://qiita.com/20731057hh/items/7f76f9e53e9da5c85ae9)を参考に。

連携が成功すると、以下の画像のようにツールバーの「Repository」や「Branch」から好きなリポジトリやブランチを選択できるようになります。

簡単な操作方法としては「Branch」の項目の横の__「↑」でpush、「↓」でpull__の操作ができます。

自分は連携させたリポジトリをSourceTreeでクローンして、差分の管理をしていました。
![qiita3.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/599964/416083fa-5fdd-8f54-4bc7-f7d4a0a2c66e.jpeg)

## ④誕生日リマインダーのコードを記述
以下は、実際のコードです。
（至らない記述があるかと思いますが、その場合はご指摘などいただければと思います。）

### (1)誕生日の追加・削除・一覧の表示機能(main.gs)

1. doPost関数でユーザーの入力をJSONで受け取り`parse`して、必要なデータを取り出す。
2. 現在の処理を`const cache = CacheService.getScriptCache();`でキャッシュしておく。
3. 誕生日の追加・削除・一覧の表示を行う。

また、コード最下部の`reply関数`でユーザーに返信しています。

```javascript:main.gs
// 初期設定
const CHANNEL_ACCESS_TOKEN = 'チャンネルアクセストークンを入力';
const URL = 'https://api.line.me/v2/bot/message/reply';
const SHEET_ID = 'スプレッドシートのIDを入力';
const SHEET_NAME = 'birthdays';
const SPREAD = SpreadsheetApp.getActiveSpreadsheet();
const SHEET = SPREAD.getSheets()[0];
// 誕生年があるものとないもの
const dateExp = /1?\d\/[123]?\d/;
const dateExpYear = /[19|20]\d{2}\/1?\d\/[123]?\d/;

//doPost関数（Lineからメッセージを受け取る）
function doPost(e) {
  //メッセージ受信
  const data = JSON.parse(e.postData.contents).events[0];
  //ユーザーID取得
  const lineUserId = data.source.userId;
  //リプレイトークン取得
  const replyToken= data.replyToken;
  //送信されたメッセージ取得
  const postMsg = data.message.text;

  // リプライトークンが無かったら処理を止める
  if(typeof replyToken === 'undefined') {
    return;
  }

  // キャッシュを設定
  const cache = CacheService.getScriptCache();
  let type = cache.get("type");

  // 処理を分ける
  if(type === null) {
    if(postMsg === '誕生日の追加') {
      cache.put('type', 1);
      reply(replyToken, '追加する人の名前を入力してください');
    } else if(postMsg === '誕生日の削除'){
      cache.put('type', 3);
      reply(replyToken, '削除する人の名前を入力してください')
    } else if(postMsg === '誕生日の一覧'){
      reply(replyToken, showBirthdaysList());
    } else {
      reply(replyToken, '「誕生日の追加」、「誕生日の削除」、「誕生日の一覧」のいずれかを入力してください');
    }
  } else {
    // 処理途中で追加のキャンセル
    if(postMsg === 'キャンセル') {
      cache.remove('type');
      reply(replyToken, '誕生日の追加をキャンセルしました');
      return;
    }

    switch(type) {
      // 誕生日の追加処理
      case '1':
        cache.put('type', 2);
        cache.put('name', postMsg);
        reply(replyToken, '追加する誕生日を「1996/12/20」の形式で入力してください \n 誕生年は無くても構いません');
        break;
      case '2':
        if(postMsg.match(dateExp || dateExpYear)) {
          cache.put('date', postMsg);
          addBirthday(cache.get('name'), cache.get('date'), lineUserId);
          reply(replyToken, `${cache.get('name')}さんの誕生日を${cache.get('date')}で登録しました`);
          cache.remove('type');
          cache.remove('name');
          cache.remove('date');
          break;
        } else {
          reply(replyToken, '正しく入力してください。「キャンセル」で処理を中止します');
          break;
        }

      // 誕生日の削除処理
      case '3':
        if(checkName(postMsg) !== -1) {
          deleteBirthday(checkName(postMsg));
          reply(replyToken, `${postMsg}さんの誕生日を削除しました`);
          cache.remove('type');
          break;
        } else {
          reply(replyToken, `${postMsg}さんの誕生はありませんでした`);
          cache.remove('type');
          break;
        }
    }
  }
}

// 誕生日の追加
function addBirthday(name, date, lineUserId) {
  const sheet = SpreadsheetApp.openById(SHEET_ID).getSheetByName(SHEET_NAME);
  const splitedDate = date.split('/');
  let year, month, day;

  if(splitedDate.length === 3) {
    year = splitedDate[0];
    month = splitedDate[1];
    day = splitedDate[2];

    sheet.appendRow([
      name,
      year,
      month,
      day,
      lineUserId
    ]);
  } else {
    year = '';
    month = splitedDate[0];
    day = splitedDate[1];

    sheet.appendRow([
      name, 
      year,
      month,
      day,
      lineUserId
    ]);
  }
};

// 誕生日の検索
function checkName(name) {
  const sheet = SpreadsheetApp.openById(SHEET_ID).getSheetByName(SHEET_NAME);
  const values = sheet.getDataRange().getValues();
  const nameList = [];
  // 初期値に-1を入れておく、-1がそのまま返ってきたら該当するユーザーはいなかったということ
  let nameIndex = -1;

  // nameListに全ての名前を入れていく
  for(let i = 0; i < values.length; i++) {
    nameList.push(values[i][0]);
  }
  
  // 一つずつnameとマッチするか確かめていく
  nameList.forEach((e, i )=> {
    if(e == name) {
      nameIndex = i;
      return;
    }
    return;
  });

  return nameIndex;
}

// 誕生日の削除
function deleteBirthday(rowNumber) {
  const sheet = SpreadsheetApp.openById(SHEET_ID).getSheetByName(SHEET_NAME);
  sheet.deleteRows(rowNumber + 1);
}

// 誕生日の一覧
function showBirthdaysList() {
  const sheet = SpreadsheetApp.openById(SHEET_ID).getSheetByName(SHEET_NAME);
  const values = sheet.getDataRange().getValues();
  const ranges = sheet.getRange(2, 1, values.length - 1, 4).getValues();

  // 返信用のフォーマットに変換する
  const dataList = ranges.reduce((list, item) => {
    if(item[1] === '') {
      return `${list}\n${item[0]} : ${item[2]}月${item[3]}日`;
    } else {
      return `${list}\n${item[0]} : ${item[1]}年${item[2]}月${item[3]}日`;
    }
  }, '名前 : 誕生日');

  return dataList;
}

// 返信機能
function reply(replyToken, message) {
  UrlFetchApp.fetch(URL, {
    'headers': {
      'Content-Type': 'application/json; charset=UTF-8',
      'Authorization': 'Bearer ' + CHANNEL_ACCESS_TOKEN,
    },
    'method': 'post',
    'payload': JSON.stringify({
      'replyToken': replyToken,
      'messages': [{
        'type': 'text',
        'text': message,
      }],
    }),
  });
  return ContentService.createTextOutput(JSON.stringify({'content': 'post ok'})).setMimeType(ContentService.MimeType.JSON);
}

```

### (2)誕生日のpush通知機能(push.gs)

続いては、誕生日をpush通知する機能です。

1. [トリガー](https://tonari-it.com/gas-timed-driven-trigger/)を設定することで、毎日0時~1時の間にスプレッドシートを探して誕生日の人を探してくる。
2. 誕生日の人がいれば、入力を行ったユーザーに対してpush通知を行う。

```javascript:push.gs
//アクセストークン
var ACCESS_TOKEN = "チャンネルアクセストークンを入力";

//送信先の処理
function pushMessage() {
  const sheet = SpreadsheetApp.openById(SHEET_ID).getSheetByName(SHEET_NAME);
  const index = findUser();

  // 誕生日が無い場合は早期リターン
  if(index === -1) {
    return;
  }

  const person = sheet.getRange(index + 1, 1).getValue();
  const to = sheet.getRange(index + 1, 5).getValue();
  const year = new Date().getFullYear();
  let theYear = sheet.getRange(index + 1, 2).getValue();
  let message = "今日は";
  let age;

  if(theYear) {
    age = year - theYear;
    message += `${person}さんの${age}歳の誕生日です！`;
  } else {
    message += `${person}さんの誕生日です！`;
  }

  //メッセージ送信処理
  return push(message, to);
}

function findUser() {
  const sheet = SpreadsheetApp.openById(SHEET_ID).getSheetByName(SHEET_NAME);
  const values = sheet.getDataRange().getValues();

  // 比較するときの様式を合わせる
  const birthdaysList = values.map( row => {
    return `${row[2]}/${row[3]}`;
  });

  // 比較するときの様式を合わせる
  const today = new Date(); 
  const hour = today.getHours();
  const month = today.getMonth();
  const date = today.getDate();
  // timeZoneをAsia/Tokyoに変更
  const theDay = `${month + 1}/${date}`;

  // 記述方法がこれでないと動かないっぽい
  const BirthdayIndex = birthdaysList.findIndex((el) => el == theDay　);

  // デバッグ用
  // const BirthdayIndex = birthdaysList.reduce((accu, curr) => {
  //   return `${accu}\n${curr}`;
  // }, theDay);

  return BirthdayIndex;
}

//PushMessageの作成
function push(message, to) {

    var url = "https://api.line.me/v2/bot/message/push";
    var headers = {
    "Content-Type" : "application/json; charset=UTF-8",
    'Authorization': 'Bearer ' + ACCESS_TOKEN,
    };
    var postData = {
    "to" : to,
    "messages" : [{
      'type' : 'text',
      'text' : "Today is your friend's Birthday!!",
    },{
      'type' : 'text',
      'text' : message,
    }],
    };
    var options = {
    "method" : "post",
    "headers" : headers,
    "payload" : JSON.stringify(postData)
    };
    return UrlFetchApp.fetch(url, options);
}
```
以上です！
丸々、コピーで動くかとは思います。（チャンネルアクセストークンやシートIDなどは設定する必要がありますが）

ちなみにデータベースとしてのスプレッドシートはこんな感じ
![qiita4.jfif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/599964/e444bb4f-2b4a-d51b-5809-1f4876afad2a.jpeg)

# ⑤GASでデプロイ

コードを記述したら、デプロイしていきます。
以下の画像のように「デプロイ」→「新しいデプロイ」を選び、「ウェブアプリ」として「デプロイ」をクリックします。
![qiita8.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/599964/1a06a6c4-f3c5-8c83-4da1-455f5ec3d90e.jpeg)

クリックすると、ここで「ウェブアプリ」のURLが出てくるので、これをLINE Developersの今回作成したチャンネルの「Messaging API設定」→　「Webhook設定」内の「Webhook URL」に入力します。
画像内の「Verify」を押すと正しく動作するか検証できます。
（ただ、正しく動作する場合でも検証に失敗することがあるようです。）
そうすることで、GASとLINE Messaging APIが接続されてBOTを使用することが可能になります。
![qiita9.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/599964/82fcb481-02f7-18d9-3155-69e06f28ffc5.jpeg)


# 個人的にハマった点

## ①Google Apps Script GitHub アシスタントをGoogleアカウントがブロックしてしまう

なぜか、Google Apps Script GitHubアシスタントを使用しようとすると「この拡張機能は危険なのでブロックしました」的なメッセージとともに使用できない期間がありました。
色々、ググって解決を試みるも上手くいかず。。
ここで1~2週間ほど作業が止まってしまい、__諦めかけたある日突然使えるようになりました。__

あれはなんだったんでしょうか、、？？
解決方法を提供できないハマりポイントでした。。

## ②アメリカ時間を取得してくるGAS
`push.gs`内のJavaScriptで今日の日付を取得して、スプレッドシート内に該当の誕生日があればpush通知を送信する機能ですが、初期のころはなぜかアメリカ（東部？）時間を取得してくるので、日付が1日ズレる事象が起こりました。

### 解決方法
いつのタイミングかはハッキリとは分からないですが、`appsscript.json`というJSONファイルが生成されており、ここにtimeZoneが記述されていました。
timeZoneが`America/New_York`になっていたので、`Asia/Tokyo`に変更で無事解決。
設定しないとファイル自体が表示されないので詳しくはググってみてください。

```javascript:appsscript.json
{
  "timeZone": "Asia/Tokyo",
}
```

#最後に
今回の誕生日リマインダーの主な欠点としては、
__データベースが1つのスプレッドシートを用いている点__だと思います。
なので、一般公開をすることができません。
色んな人の誕生日がゴッチャに入ってしまいますからね、プライバシー的に。
そもそも、GASは小規模なサービス向けなので根本的に一般公開は難しそうです。

でも個人的には満足しているので問題なしです！！

そして、Udemyで受講させていただいたTaka ponさんに感謝です。