firebaseの無料枠を使ったLineBotを作る

#やりたいこと
FirebaseでLinebotを作る場合、[従量課金プランでなければ外部（Lineサーバ）への通信が行えない](https://qiita.com/ranran/items/5a9427636b35801bfba7)ため、LineBotへのreplyMessageやpushMessageができなかった。

そこで、外部への通信のみ切り出して無料でLineBotを作ってみた。
いったんローカルのDockerコンテナで実装するが、外部への通信ができるならどこでも動作するはず。

LineだけでなくAWSやGCP等のPublicCloudからは通信できないサーバ（イントラネット等）の情報を活用するLinebotを簡単に作ることが目標

#Firebase側の実装
FirebaseFunctionを使ってLineからのメッセージをFireStoreに保存
→外部からFirebaseへの通信はHttpsで24時間受信可能

Lineからのイベント（messageとかpostback）が発生したらFirebStoreのコレクションに保存する。
→どんなLinebotを作る場合でもここは共通で使いまわせるはず

```functions/index.js
const functions = require('firebase-functions')
const admin = require('firebase-admin')
const express = require('express')
const line = require('@line/bot-sdk')


if (!admin.apps.length) {
    admin.initializeApp()
}
const db = admin.firestore()
const config = {
    channelSecret: functions.config().channel.secret,
    channelAccessToken: functions.config().channel.accesstoken
}
const type2Collection = {
    message: 'lineMessage',
    postback: 'lineMessage',
    follow: 'lineUser',
    unfollow: 'lineUserUnfollow',
    join: 'lineGroup',
    leave: 'lineGroupLeave',
    default: 'lineEvent'
}

const app = express()
const middle = line.middleware(config)
app.get('/webhook', async (req, res) => {
    res.end('webhook get')
})
app.post('/webhook', middle, (req, res) => {
    (async () => {
        try {
            for (let i in req.body.events) {
                const event = req.body.events[i]
                const collection = type2Collection[event.type] || type2Collection.default
                //firebaseのチェック用キー
                event._meta = {
                    state: "pending"
                }
                await db.collection(collection).add(event)
            }
        } catch (e) {
            console.error(e)
        }
        res.sendStatus(200)
    })()
})

exports.webhook = functions
    .runWith({
        timeoutSeconds: 300,
        memory: '512MB'
    }).https.onRequest(app)

```

LineBotを友達登録してメッセージを送ると以下のような感じに保存される。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81968/d4d0732b-9de2-f85d-e170-27742707b39a.png)


#ローカル側の実装
FireStoreの更新をフックしてLineサーバへreplyMessageを送る。
何かをトリガーにpushMessageも可能
LineメニューからPostbackを受け取って回答するサンプル。

```server/index.js
require('dotenv').config();
const line = require('@line/bot-sdk')
const admin = require('firebase-admin')

const serviceAccount = JSON.parse(process.env.firebase_token_json)
const client = new line.Client({
  channelAccessToken: process.env.channel_accesstoken
});
admin.initializeApp({
  credential: admin.credential.cert(serviceAccount)
});
const db = admin.firestore();

const postback = async (data, s_doc) => {
  const querys = {};
  //適当なクエリパーサ
  data.postback.data.split("&").map(q => q.split("=")).forEach(q => {
    querys[q[0]] = q[1]
  });
  const actions = {
    wiki: async () => {
      return {
        "type": "template",
        "altText": "WIKIへのリンク",
        "template": {
          "type": "buttons",
          "text": "Wikiへのリンク",
          "actions": [
            {
              "type": "uri",
              "label": "WIKIへ",
              "uri": "https://ja.wikipedia.org/wiki/"
            }
          ]
        }
      }
    },
    tikaku: async () => {
      return {
        "type": "text",
        "text": "近くのお店を検索します。",
        "quickReply": {
          "items": [
            {
              "type": "action",
              "action": {
                "type": "location",
                "label": "現在の位置情報を送信"
              }
            }
          ]
        }
      }
    }
  }
  const updateData = {
    _meta: {
      state: "reject"
    }
  }
  if (querys.action && actions[querys.action]) {
    try {
      const body = await actions[querys.action](querys)
      if (body) {
        // use reply API
        await client.replyMessage(data.replyToken, body);
      }
      updateData._meta.state = "resolve"
    } catch (e) {
      //console.error(e)
      updateData._meta.msg = e.toString()
    }
  } else {
    updateData._meta.msg = "no action. astion = " + querys.action
  }
  updateData._meta.time = admin.firestore.FieldValue.serverTimestamp()
  await s_doc.ref.update(updateData)
}
const message = async (data, s_doc) => {
  const updateData = {
    _meta: {
      state: "resolve"
    }
  }

  await client.replyMessage(data.replyToken, {
    "type": "text",
    "text": `{メッセージに対する個別に返信}`,
  });
  updateData._meta.time = admin.firestore.FieldValue.serverTimestamp()
  await s_doc.ref.update(updateData)
}
const location = async (data, s_doc) => {
  const updateData = {
    _meta: {
      state: "resolve"
    }
  }
  //GPS情報がとれるのでそれに合わせた返答
  await client.replyMessage(data.replyToken, {
    "type": "template",
    "altText": "近くのお店",
    "template": {
      "type": "buttons",
      "text": "近くのお店が見つかりました。",
      "actions": [
        {
          "type": "uri",
          "label": "経路情報はこちら",
          "uri": "https://www.google.com/maps/dir/?api=1&destination={ロケーション的なもの}"
        }
      ]
    }
  });
  updateData._meta.time = admin.firestore.FieldValue.serverTimestamp()
  await s_doc.ref.update(updateData)
}


const main = () => {
    //lineMessageコレクションに更新があったらフックする
  return db.collection("lineMessage")
    .where("_meta.state", "==", "pending")
    .where("timestamp", ">", (new Date()).getTime())
    .onSnapshot(async snapshot => {
      const docs = snapshot.docChanges()
      //追加と更新のみフック（pending→resolve時は削除としてフックされるため）
        .filter(e => e.type === "added" || e.type === "modified")
        .map(e => e.doc)
      for (let s_doc of docs) {
        const data = await s_doc.data();
        if (data.type === "postback") {
          await postback(data, s_doc)
        } else if (data.type === "message" && data.message.type === "location") {
          await location(data, s_doc)
        } else {
          await message(data, s_doc)
        }
      }
    }, (error) => {
      console.log((new Date()).toLocaleString(), error)
      //Docker restart で処理するため異常終了する。
      process.exit(-1)
    })
}

(async () => {
  const interval = 1000 * 60 * 10;
  console.log("start interval " + interval)
  while (true) {
    const ret = main();
    await new Promise((r) => setTimeout(r, interval))
    //なぜかメモリエラーで落ちるので10分ごとにリッスン解除
    ret()
  }
})();
```

Dockerfileはこちら

```Dockerfile
FROM node:13.1.0-slim
WORKDIR /src

ADD . /src
RUN npm install
ENV TZ Asia/Tokyo
CMD ["/usr/local/bin/node", "index.js"]

```

#総評
Firebaseの無料枠とローカルサーバの組み合わせで無料（？）でLinebotを作成できた。
Line→FirebaseFunction→Firestoreのイベントフックと若干遠回りだが、個人で利用しているLineBotとしてはレスポンスの遅延は感じなかった。
LineのイベントがすべてFirestoreに残るのが開発時や、運用時に便利であった。

#課題
サンプルのコード中にも記載したが、Firestoreの更新をフックする処理を長時間放置すると、「なぜかメモリエラーで落ちるので10分ごとにリッスン解除」する必要があった。
Dockerかつ返信してないイベントはFirestoreに保存されている落ちれば再起動することで復旧できるがなぞが残った・・・