ToDoアプリがいろいろありすぎるので、LINEにToDo機能を追加してみた。

## ToDoアプリは多すぎるし、スマホのアプリを増やしたくない
私はToDo系のアプリを使用していないのですが、使用していない理由としては以下が挙げられます。
 1. **ToDoアプリが多すぎるのでどれを使えば良いかわからない**
 1. **いちいちToDoアプリを開くのが面倒**
 1. **使いなれるまでに時間が掛かる**
 
面倒臭がり屋の私でも、いろいろと試してみたときはありました。
だけど、どれもすぐにそのアプリの存在を忘れてしまったり、使い勝手が面倒で諦めてしまいました:dizzy_face:

でもそんな私も毎日のように使用しているアプリがあります。

そう、それは<font color="Red">LINE!!</font>

今まではこのLINEにToDo機能があればなー、って思っていたわけですが、あるじゃないですか！LINE Botなんて便利機能が！
LINE Botならプッシュ通知も可能なので、ToDoを知らせてくれるにはもってこいやないか:thumbsup:

ということで、LINE Botを用いて、ToDo機能をLINEに実装してみました！

## 最低限欲しい機能


 - **手軽にToDoを入力できる**
 - **午前、午後、夜のToDoを入力できる**
 - **朝にその日のToDoをLINEに送ってくれる**
 - **午前、午後、夜のToDoを知りたいときに知れる**

まずは、この機能を実装することにしました。
そのために、自分の力量を考えて技術選定しました！

アプリ名は**TODO:Bot**にしました！

## 使用する技術

### LINE Bot(LINE Messaging API)
 
ToDoのプッシュ機能と、読出しを実施するために使用します。
 > 参考URL：[LINE Bot(LINE Messaging APIのドキュメント)](https://developers.line.biz/ja/)

### Google Sheets API

ToDo機能を有するということは、ToDoを保存する場所がいるなーということで、今回はGoogleSpreadSheet(以下：スプレッドシート)を使用することにしました！
スプレッドシートを使用するには、Google Sheets APIというAPIを使用します！
> 参考URL：[Google Sheets APIのドキュメント](https://developers.google.com/sheets/api)

### Google Forms

ToDoを保存するために使用します！
本当はLINE Botから保存できるようにしようと思ったのですが、午前と午後と夜を分けて登録するときに、いちいちそれを入力させるのは嫌だなーと思い、今回はこちらをGoogle Forms(以下：Googleフォーム)を使用しました！
LINEのリッチメニューにこれまたLINEの機能であるLIFFを使用して表示させるようにします！

 > 参考URL：
 > - [Google Formsのドキュメント](https://www.google.com/intl/ja_jp/forms/about/)
 > - [リッチメニューのドキュメント](https://developers.line.biz/ja/docs/messaging-api/using-rich-menus/)
(リッチメニューはLINEの画面下部に表示されるメニュー機能のことです。)
 > - [LIFF (LINE Front-end Framework)のドキュメント]((https://developers.line.biz/ja/docs/liff/overview/))
(LIFFはLINE画面にWebアプリを表示させるために使用する機能です)

### GitHub Actions

こちらは、朝のプッシュ通知の機能を実装するために使用しました。
時刻をcronで設定して定期実行ができます。

 > 参考URL：[GitHub Actionsのドキュメント](https://docs.github.com/ja/actions)


## 完成アプリ
Googleフォームで入力した情報をLINEを通して取得することができました！

https://twitter.com/i/status/1577276749863215104

では以下は実装編です！


## 開発環境

 - Node.js v16.16.0
 - npm v8.11.0
 - line/bot-sdk v7.5.2
 - axios v0.27.2
 - express v4.18.1
 - google-spreadsheet v2.0.3
 - Visual Studio Code v1.71.2



## 下準備
<details><summary><font color="Red">*****各種機能の設定方法を記載しています。ご覧になりたい方はこちらを見てください*****</font ></summary><div>

###  GoogleフォームからスプレッドシートにToDoを保存できるようにする

#### Googleフォームの作成

[こちらの画面](https://www.google.com/intl/ja_jp/forms/about/)から「フォーム」をクリックして＋マークの「空白」をクリックします。
すると、「無題のフォーム」が表示されるので、そちらをカスタマイズしました。
完成画像はこちらです。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2756612/b65ccfa6-48a4-2dfd-4dbf-629919f7460a.png" width="50%">

 - TODOの日付
 - AM
 - PM
 - Sleep
 
の情報を保存するようになっています。

### Googleフォームの保存先をスプレッドシートにする

[こちらの記事](https://form.run/media/contents/form-creation-tools/google-form-spreadsheet-integration/)を元に「回答」にある「回答先を選択」から「新しいスプレッドシート」作成にて、保存先となるスプレッドシートを作成します。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2756612/133d52e9-2bab-2e13-afcc-972f6ed2b4a7.png" width="50%">

これでまずWeb上から回答すると、連携したスプレッドシートに値が保存されます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2756612/172c0a85-fc40-c508-696f-0f6a9db7fa31.png" width="80%">

### 先ほど作成したスプレッドシートにてGoogle Sheets APIを有効にする

先ほどのスプレッドシートのGoogle Sheets APIを有効にして、Node.jsからスプレッドシートを操作できるようにします。
私は以下の記を参考にして実施しました。

> - [参考URL１](https://virment.com/enable-google-spreadsheet-api/)
> - [参考URL２](https://www.twilio.com/blog/load-data-from-google-spreadsheet-jp)(Node.jsでの操作方法も載っています)

:::note warn
アクセスするための情報をダウンロード等する必要があります
:::


### Node.jsを用いてスプレッドシートからデータを取り出す

Node.jsにてスプレッドシートを使用するためのパッケージをインストールします。
```bash: ターミナル
npm i google-spreadsheet
```
　
　下記サンプルコードを用いて、実行結果がスプレッドシートに記載されている内容と同じであれば無事にAPIの接続ができています。

```javascript: spreadsheet.js
(async function() {
    // パッケージを使用する
    const {
        GoogleSpreadsheet
    } = require('google-spreadsheet');
    const creds = require('./client_secret.json'); // ダウンロードしたJSON
    // spreadsheet IDはURLに記載されている一部になります
    const doc = new GoogleSpreadsheet('*spreadsheet ID*');
    // 認証
    doc.useServiceAccountAuth(creds);

    await doc.loadInfo(); 
    doc.sheetsByTitle[doc.title]
    console.log(doc.title);
    // sheetsByIndex[シートNo]となります。シートが一枚しかなければ０でアクセスできます
    const sheet = doc.sheetsByIndex[0];
    // 行データを取得
    const rows = await sheet.getRows();
    console.log(rows[0].AM);
}());
```

```bash: ターミナル
node spreadsheet.js
- (スプレッドシート名)
- 起きる
```

> [参考URL](https://qiita.com/Lotus_ollz/items/fac213f60e2a2db6299c)

### リッチメニューを作成し、Googleフォームを表示する

リッチメニューを作成することで、Botへの入力がやりやすくなります。
例えば、「PM」と入力をしたいとき普通ならいちいち手入力をして送信しないとダメですが、リッチメニューならメニューからPMボタンを押すと、「PM」を送信することができます。
また、今回はGoogleフォーム画面を表示するためにLIFFを表示できるようにもします。


#### リッチメニューを設定する


今回のTODO:Botでは以下のようなリッチメニューを作成しました。

 - TODO追加ボタン：Googleフォームを表示する
 - AMボタン：「午前中」を送信する
 - PMボタン：「午後」を送信する
 - Sleepボタン：「寝る前」を送信する

**（設定画面）**
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2756612/3d75d33b-b722-a8c2-5909-ddab1622faf9.png" width="50%">


> [参考URL](https://www.linebiz.com/jp/manual/OfficialAccountManager/rich-menus/)

#### TODO追加に表示するためのLIFFを作成する

こちらの記事の「[LIFFアプリを自分で作ってみる](https://goworkship.com/magazine/what-is-liff/)」を参考にして作っていきます。
以下を読み替えてください。
 - LFFアプリの元となるWebアプリを作成する ➡　GoogleフォームのURLを使用します
 - 「6.WebアプリにLIFF SDKを組み込む」は無視してください。

あとは登録したときにできる「LIFF URL」を、リッチメニューのTODO追加ボタンの「リンク」に登録すれば、Googleフォームが表示されるようになります。

> LIFFの大きさはTallにしています。

</div></details>

## LINE Bot側のプログラム
### LINE Botの作成
ご自分で作成する場合は、下記を参考に作成してください。
ローカル環境構築ではngrokを使用して開発しました。

> 参考URL１： [1時間でLINE BOTを作るハンズオン](https://qiita.com/n0bisuke/items/ceaa09ef8898bee8369d)
> 参考URL２： [おうむ返しbotを作ろう](https://qiita.com/inoue2002/items/dcf40d5ebfe2454796fa)

#### リプライメッセージ側プログラム

<details><summary><font color="Blue">*****リプライメッセージのコードをご覧になりたい方はこちらを見てください*****</font ></summary><div>

```javascript: todo.js
'use strict';

const express = require('express');
const line = require('@line/bot-sdk');

const PORT = process.env.PORT || 3000;

// Messaging APIを利用するための鍵を設定します。ここは自分のものに書き換えてください。
const config = {
    channelSecret: '**********************************',
    channelAccessToken: '********************************************************'
};
const client = new line.Client(config);
const {
    GoogleSpreadsheet
} = require('google-spreadsheet');
// ダウンロードしたJSON
const creds = require('./client_secret.json');
// スプレッドシートのIDを取得する IDはスプレッドシートのURLから確認できます。
const doc = new GoogleSpreadsheet('***************************************');
// 認証設定
doc.useServiceAccountAuth(creds);
// スプレッドシートから本日のTodoを取得
async function getTodosData() {
    // スプレッドシートと接続
    await doc.loadInfo();
    console.log(doc.title);
    // シートNo0=Todoシートを指定
    const sheet = doc.sheetsByIndex[0];
    doc.sheetsByTitle[doc.title]
    console.log(sheet);
    // 一行ずつ取得
    const rows = await sheet.getRows();
    // 現在時刻を取得 TODO: このコードではUTC時間になっているので、日本時間-9時間となっています。
    const today = new Date();
    // 本日の日時とTodoから取得した日時を比較して、本日の日時のTodoデータだけ取り出す
    const resData = rows.filter((item) => {
        const saveTime = new Date(item.TODOの日付)
        if (saveTime.getFullYear() === today.getFullYear() && saveTime.getMonth() === today.getMonth() && saveTime.getDate() === today.getDate()) {
            console.log(item.TODOの日付)
            console.log(saveTime.getDate())
            console.log(today.getDate())
            return true
        }
        return false;
    });
    console.log(resData);
    // 返答
    return resData;
}

// リプライメッセージ返答処理
// ユーザーからメッセージがあったときに呼び出されます
async function handleEvent(event) {
    if (event.type !== 'message' || event.message.type !== 'text') {
        return Promise.resolve(null);
    }
    // スプレッドシートからTodoデータを取得
    const data = await getTodosData()
    console.log(data)
    // 返答用配列
    let resMessage = []
    let todo = '';

    // 指定された文言に対応したTodoデータをtodo変数に格納する
    if (event.message.text == '午前中') {
        resMessage.push({
            type: 'text',
            text: '午前中の予定は・・・'
        });
        data.forEach(d => {
            if (d.AM != '') {
                todo += d.AM + '\n';
            }
        });
    }
    else if (event.message.text == '午後') {
        resMessage.push({
            type: 'text',
            text: '午後の予定は・・・'
        });
        data.forEach(d => {
            if (d.PM != '') {
                todo += d.PM + '\n';
            }
        });
    }
    else if (event.message.text == '寝る前') {
        resMessage.push({
            type: 'text',
            text: '寝る前の予定は・・・'
        });
        data.forEach(d => {
            if (d.Sleep != '') {
                todo += d.Sleep + '\n';
            }
        });
    }
    else {
        todo = "変なデータ送ってくるな"
    }
    // 最後の改行は取り除いておきます
    todo = todo.trim();
    // 最後にメッセージに入れます。
    resMessage.push({
        type: 'text',
        text: todo
    });

    console.log(resMessage)
    // ユーザーにリプライメッセージを送ります。
    return client.replyMessage(event.replyToken, resMessage);
}

// ここ以降は理解しなくてOKです
const app = express();
app.get('/', (req, res) => res.send('Hello LINE BOT! (HTTP GET)'));
app.post('/webhook', line.middleware(config), (req, res) => {

    if (req.body.events.length === 0) {
        res.send('Hello LINE BOT! (HTTP POST)');
        console.log('検証イベントを受信しました！');
        return;
    } else {
        console.log('受信しました:', req.body.events);
    }

    Promise.all(req.body.events.map(handleEvent)).then((result) => res.json(result));
});

// ローカルで確認するときに必要
app.listen(PORT);
console.log(`ポート${PORT}番でExpressサーバーを実行中です…`);

```

</div></details>

#### プッシュメッセージ側プログラム

<details><summary><font color="Green">*****プッシュメッセージのコードをご覧になりたい方はこちらを見てください*****</font ></summary><div>

```javascript: push.js
'use strict';

const express = require('express');
const line = require('@line/bot-sdk');

const PORT = process.env.PORT || 3000;

// Messaging APIを利用するための鍵を設定します。ここは自分のものに書き換えてください。
const config = {
    channelSecret: '**********************************',
    channelAccessToken: '********************************************************'
};
const client = new line.Client(config);
const {
    GoogleSpreadsheet
} = require('google-spreadsheet');
// ダウンロードしたJSON
const creds = require('./client_secret.json'); 
// スプレッドシートのIDを取得する IDはスプレッドシートのURLから確認できます。
const doc = new GoogleSpreadsheet('***********************************');
// 認証設定
doc.useServiceAccountAuth(creds);
// スプレッドシートから本日のTodoを取得
async function getTodosData() {
    // スプレッドシートと接続
    await doc.loadInfo();
    console.log(doc.title);
    // シートNo0=Todoシートを指定
    const sheet = doc.sheetsByIndex[0];
    doc.sheetsByTitle[doc.title]
    console.log(sheet);
    // 一行ずつ取得
    const rows = await sheet.getRows();
    // 現在時刻を取得 TODO: このコードではUTC時間になっているので、日本時間-9時間となっています。
    const today = new Date();
    // 本日の日時とTodoから取得した日時を比較して、本日の日時のTodoデータだけ取り出す
    const resData = rows.filter((item) => {
        const saveTime = new Date(item.TODOの日付)
        if (saveTime.getFullYear() === today.getFullYear() && saveTime.getMonth() === today.getMonth() && saveTime.getDate() === today.getDate()) {
            console.log(item.TODOの日付)
            console.log(saveTime.getDate())
            console.log(today.getDate())
            return true
        }
        return false;
    });
    console.log(resData);
    // 返答
    return resData;
}
// プッシュメッセージ処理
const main = async () => {
    // スプレッドシートからTodoデータを取得
    const data = await getTodosData()
    console.log(data)
    // 返答用配列
    let resMessage = [{
        type: 'text',
        text: '今日の予定は・・・'
    }]
    let todoAM = '午前の予定は・・・' + '\n';
    let todoPM = '午後の予定は・・・' + '\n';
    let todoSleep = '寝る前の予定は・・・' + '\n';
    // 当日のTodoをすべて格納します
    data.forEach(d => {
        if (d.AM != '') {
            todoAM += d.AM + '\n';
        }
        if (d.PM != '') {
            todoPM += d.PM + '\n';
        }
        if (d.Sleep != '') {
            todoSleep += d.Sleep + '\n';
        }
    });
    todoAM = todoAM.trim();
    todoPM = todoPM.trim();
    todoSleep = todoSleep.trim();
    // 3つのメッセージに分けて送信します
    resMessage.push({
        type: 'text',
        text: todoAM
    },{
        type: 'text',
        text: todoPM
    },{
        type: 'text',
        text: todoSleep
    },);

    if (resMessage.length === 0) {
        resMessage = [{
            type: 'text',
            text: '今日の予定はありません'
        }]
    }
    console.log(resMessage)
    // 送信処理
    try {
        const res = await client.broadcast(resMessage);
        console.log(res);        
    } catch (error) {
        console.log(`エラー: ${error.statusMessage}`);
        // console.log(error.originalError.response.data);
    }
}
main();
```

:::note alert
送信に使用するメッセージオブジェクト配列```resMessage[]```に格納できるオブジェクト数は最大５件のため、それ以上同時に送信しようとするとエラーとなります。
:::

</div></details>



#### GitHub Actions側の実装

毎朝７時にプッシュ通知するように設定しています。

:::note
GitHub Actionsを実装する際、「 set up a workflow yourself 」を使用する記事が多いですが、個人的にはNode.jsの「Configure」から一部変更する方がやりやすかったです。
:::

```yaml

name: Node.js CI
on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]
  schedule:
    # 定期実行する時間・・・ここを変更する UTC時刻のため送信したい時刻の-9を設定する
    - cron: '0 22 * * *'

jobs:
  build:

    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [16.x]
        # See supported Node.js release schedule at https://nodejs.org/en/about/releases/

    steps:
    - uses: actions/checkout@v3
    - name: Use Node.js ${{ matrix.node-version }}
      uses: actions/setup-node@v3
      with:
        node-version: ${{ matrix.node-version }}
        cache: 'npm'
    - run: npm ci
    - run: npm run build --if-present
    - run: node push.js # ここにコードを起動する処理をいれる


```

 > 参考：[こちらのYoutubeのGitHub Actions](https://www.youtube.com/watch?v=T0KAE2kq2Xo)の箇所を参考にしました。

:::note warn
プッシュ通知ですが、7時に通知するよう設定はしていますが、画像の通り30分程度のタイムラグがありました。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2756612/28b264d9-acd8-cf01-0ecc-884adedf22ee.jpeg" width="30%">


:::

## Renderにてデプロイを実施する
最終的にLINE Botのサーバー側を静的ページとしてデプロイし、Webhookとして接続することでTODO:Botが完成となります。
デプロイは以下の記事を参考にして実施しました。記事内のリンクに登録方法なども記載されています。

 > 参考URL：[RenderにLINE Botをデプロイする](https://zenn.dev/protoout/articles/56-line-bot-render-deploy)

:::note
一部、記事と違う所としては、私の環境ではLINE Botのソースコードはprivateで非公開でしたので、RenderとGitHubの連携が必要でした。記事と同じところに対象のソースコードを入れると、接続が必要です。という画面が出てくるので、メール等で認証作業をすれば無事接続ができます。
:::

:::note warn
デプロイには記事に記載されている通り、時間がかかります！
私は途中でコードの間違いに気がついてデプロイが完了する前に、Pushし直したりしたので途中でデプロイが失敗したりしました。ログ等をみながら変更する際はデプロイが完了したことを確認してからの方が良いと思います。
:::

## 最後に

今回はLINEに無事Todo機能を実装することができました:bowtie:

**がしかし！！**

機能としてはまだまだ不十分です。

### 足りない機能
- **Todoデータを取ってくる時刻がUTC時刻のため日本時間-9時間を起点としているので、朝のプッシュ通知は前日のデータが送られてくる**
➡朝9時になるまで前日のTodoが送られてきます
- **Googleフォームの日付入力が面倒**
➡LIFFアプリを独自に作成すれば改善しそう？
- **プッシュ通知と言いながら、実はブロードキャストメッセージを使っているため、ユーザーごとにTodoを管理できていない**
➡これもLIFFアプリを独自に作成すれば、入力したユーザーを特定できそう！
- **データが増えると、おそらくTodoデータの取得に膨大な時間が掛かる**
➡終了したTodoデータは削除する等が必要になってくる

思いつく限りでもこれだけあります、、、:sob:

なんとも歯がゆい、、、
まだまだ精進あるのみだな！と思いました！
最終的にはクラウドのデータベースとか使ったほうがやりやすいのかな？と思ったりしています。

みなさんにもお使いいただけますよう、ブラッシュアップしていきたいと思います！:sparkles:

最後までお読みいただきありがとうございました！