linebot + cloud functions + firestore で家計簿みたいなbotを作る

# できたもの
<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/371763/3502b5fd-7a2a-2396-88bc-368a08a883f8.gif">

firestoreに保存されている
![linebot_test.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/371763/280a2810-5943-f124-ce24-46075af5fc5a.png)

# Botの内容について  
- 一緒に住んでるような人と、費用を割り勘するためのbot
- 食費、電気代など、二人で割り勘する費用を保存していき、どっちが多く負担しているかを常に知ることができる
- 今回できたのは、botと1対1で、金額を入力すると、データベースに保存してくれるとこまで

# ソースコード
https://github.com/inoue723/warikan_functions/tree/linebot

# 環境・前提知識
- nodejs v12.18.1
- firestoreを触ったことがある

# 仕組み
1. botにメッセージを送ると、linebotの[webhookイベント](https://developers.line.biz/ja/reference/messaging-api/#webhooks)が呼ばれる
2. cloud functionsでそのイベントを受けとる(linebotの設定画面からwebhookのurlを指定できる)
3. cloud functionsでfirestoreのapiを呼び出し、保存する
4. cloud functionsで[linebot message api](https://developers.line.biz/ja/reference/messaging-api/#send-reply-message)を呼び出し、「記録しました！」と返信

※**firebaseは完全無料のsparkプランではなく、従量課金制の[blazeプラン](https://firebase.google.com/pricing?authuser=0)じゃないとmessage apiの呼び出しができない**
といっても百万リクエストで$0.4とかだし、無料枠もあるのでほとんどかからないと思われるが、自己責任で。

# 手順
### Line Bot の作成
- Line Developerに登録、ログインする

- チャネルを作る  
[ここ](https://developers.line.biz/console/channel/new?type=messaging-api)から作れる。先にプロバイダーというものを作る必要がある。チャネル作るとこまででok  


### cloud functionsの設定
- firebase-toolsをグローバルインストール  
`npm install -g firebase-tools`

- firebaseにログイン  
`firebase login`

- ディレクトリを作成してfirebaseプロジェクトとの紐づけ  
プロジェクトを作ってない人は以下で作る(Blazeプランに変更が必要)  
https://console.firebase.google.com/u/0/  
`mkdir warikan_functions && cd warikan_functions`  
`firebase init functions`  
最初の質問で既存のプロジェクトを指定する  
あとは適当に選ぶ  
※今回はtypescriptを選択してみました  

- functionsフォルダに移動して必要なライブラリのインストール  
`cd functions && npm i express @line/bot-sdk`
基本は`functions`配下で作業することになる。

- (必要であれば)デフォルトだとtslintを使用していたので、下記を参考にeslintを使うよう変更する  
https://qiita.com/madono/items/a134e904e891c5cb1d20  
そのほかに`package.json`のコマンドも変更する必要あり。[ソースコード](#-ソースコード)参照。


- cloud functions で linebot message api を使うための準備
以下のコマンドで、message api のtokenをcloud functionsの環境変数に設定する  
`firebase functions:config:set linebot.token="アクセストークン" linebot.secret="シークレット"`
  - アクセストークン: LineBot設定画面のMessaging API設定タブの最下部の「チャネルアクセストークン」（発行していない場合は発行する）
    - シークレット: LineBot設定画面のチャンネル基本設定タブの「チャンネルシークレット」

  以下のように環境変数を呼び出せる

  ```ts
  const config = {
    channelAccessToken: functions.config().linebot.token,
    channelSecret: functions.config().linebot.secret,
  };
  ```

### functionの作成
- [linebotのnodejs reference](https://line.github.io/line-bot-sdk-nodejs/getting-started/basic-usage.html#synopsis)を参考にとりあえず返事するbotを作る  
既に自動で作られている`functions/index.ts`に追加していく

```ts:index.ts
import * as functions from "firebase-functions";
import * as line from "@line/bot-sdk";
import * as express from "express";

const config = {
  channelAccessToken: functions.config().linebot.token,
  channelSecret: functions.config().linebot.secret,
};

const app = express();
app.post("/costs", line.middleware(config), (req, res) => {
  return Promise.all(req.body.events.map(handleEvent)).then((result) =>
    res.json(result)
  );
});

const client = new line.Client(config);
async function handleEvent(event: line.WebhookEvent) {
  if (event.type !== "message" || event.message.type !== "text") {
    return Promise.resolve(null);
  }

  return replyWithText("記録しました！", event);
}

async function replyWithText(message: string, event: line.MessageEvent) {
  return client.replyMessage(event.replyToken, {
    type: "text",
    text: message,
  });
}

export const lineBot = functions.https.onRequest(app);
```

- functionをdeployする  
`warikan_functions/functions/`で  
`npm run deploy`を実行  
成功するとエンドポイントのurlが表示されるので、コピーしておく


- linebot設定画面でwebhook urlを設定する
  - message api設定タブのwebhook urlに上でコピーしたurlを設定する  
  今回の場合、`{エンドポイント}/costs`までしないとダメ（ここで結構時間使った・・・）  
そもそも`app.post("/", ....`で設定しておけばよかった。どうせ１つしか使わなそうだし。
  - すぐ下のwebhookの利用をONにする

      ※検証押すとエラーがでるが、[リファレンス](https://developers.line.biz/ja/reference/messaging-api/#response)見る限り200ok返していないのが原因かも。未検証。

- linebot設定画面のQRコードからBotを追加してメッセージ送る  
テキストを送信して、「記録しました！」が返ってくればOK

### firestoreに費用を保存するようfunctionを修正する
- firebase admin sdkの設定を追加する  
以下を参考に環境変数を設定してfirebase adminの設定を初期化できるようにする  
https://firebase.google.com/docs/admin/setup?hl=ja#initialize_the_sdk  

  以下のコードでfirestoreのapiが呼び出せるようになる

```ts
import * as admin from "firebase-admin";

admin.initializeApp({
  credential: admin.credential.applicationDefault(),
});
```

- `handleEvent`にfirestoreに保存する処理を追加する  
その前にいつもお世話になっているmomentjsを追加しておく
`npm i moment`

```ts:index.ts
import * as functions from "firebase-functions";
import * as line from "@line/bot-sdk";
import * as express from "express";
import * as admin from "firebase-admin";
import * as moment from "moment";

const config = {
  channelAccessToken: functions.config().linebot.token,
  channelSecret: functions.config().linebot.secret,
};

const app = express();

app.post("/costs", line.middleware(config), (req, res) => {
  return Promise.all(req.body.events.map(handleEvent)).then((result) =>
    res.json(result)
  );
});

admin.initializeApp({
  credential: admin.credential.applicationDefault(),
});

async function handleEvent(event: line.WebhookEvent) {
  if (event.type !== "message" || event.message.type !== "text") {
    return Promise.resolve(null);
  }

  // ここから
  const userId = event.source.userId;
  if (userId == null) return Promise.resolve(null);

  const userRef = admin.firestore().collection("users").doc(userId);

  const user = await userRef.get();
  if (!user.exists) {
    await admin.firestore().collection("users").doc(userId).set({});
  }

  const amount = Number(event.message.text);
  if (!amount) return replyWithText("半角数字で入力してください", event);

  await userRef
    .collection("costs")
    .doc(moment().utcOffset(0).format("YYYY-MM-DDTHH:mm:ss.SSSSSS"))
    .set({
      amount: amount,
      burdenRate: 0.5,
      paymentDate: admin.firestore.Timestamp.fromDate(moment().toDate()),
    });

  // ここまで

  return replyWithText("記録しました！", event);
}
```

- functionをdeployする  
`warikan_functions/functions/`で  
`npm run deploy`を実行

- linebotにメッセージを送ってみる  
半角数字で送れば「記録しました！」という返事が返ってきて、[firebaseのコンソール](https://console.firebase.google.com/?hl=ja)にあるfirestoreのデータを見て、`{userId}/costs/{日付}`という構造でデータが入っていれば成功

# 次にやることリスト
今回で下地が揃ったので、どんどん改良していきたい。
- 一覧取得、差額取得などのアクションの追加
- groupに招待して、二人で使えるようにする
- ラインボットのリッチメニューの作成
- functionのテストを導入する


# 本文のリンク以外で参考にさせてもらったリンク
Line Message Api Reference
https://developers.line.biz/ja/reference/messaging-api/

firebase functions で環境変数を使う
https://firebase.google.com/docs/functions/config-env?hl=ja

firebase functions のローカルで環境変数を使う
https://qiita.com/kenny_J_7/items/f75cc26f6265a019673d#%E3%83%AD%E3%83%BC%E3%82%AB%E3%83%AB%E3%82%A8%E3%83%9F%E3%83%A5%E3%83%AC%E3%83%BC%E3%83%88%E6%99%82%E3%81%AB%E3%81%AF%E7%92%B0%E5%A2%83%E5%A4%89%E6%95%B0%E3%82%92%E5%88%A5%E9%80%94%E5%90%90%E3%81%8D%E5%87%BA%E3%81%99%E5%BF%85%E8%A6%81%E3%81%8C%E3%81%82%E3%82%8B

`getaddrinfo EAI_AGAIN api.line.me:443`というエラーがcloud functionsのログで出たら
https://qiita.com/vedokoy/items/e996d7e2d5e8baa93dac#-%E3%83%97%E3%83%A9%E3%83%B3%E3%82%92%E5%A4%89%E6%9B%B4