【Firebase】Cloud Functionsで作るSlack App【TypeScript】

## :star: 概要
Cloud Functions for Firebase で Slack App を作ってみたのでそのノウハウの紹介です

## :star: 今回作ったもの

### :tada: Lively

LivelyはSlack上のコミュニケーションをより便利に楽しくするための機能を複数備えたアプリです！

- 様々なチャンネルから人気の投稿を探して通知します
- 週間・月間で人気のあった投稿を振り返ります
- アプリのホームタブから簡単に設定ができます
- 新しく作成された絵文字やチャンネルをお知らせします
- privateチャンネルには関与しないようになっているため安心です


### :octocat: GitHub
ソースコードはこちら

https://github.com/mikan3rd/lively

### :file_folder: Slack App Directory
ここからインストールできます

https://slack.com/apps/A0239EZ6A5S

## :pencil: 技術スタック
- TypeScript （Node.js）
- GCP
    - Firebase
        - Cloud Functions
        - Firestore
  - Cloud Pub Sub
  - Cloud Scheduler
  - Cloud Tasks
- Slack App
  - OAuth Permission
  - App Home
  - Interactive Components
  - Event Subscription


## :fire: Firebaseについて

### Firebaseとは
- mBaaS（mobile Backend as a Servie）
- 簡単に言うとGCPのサービスのうち、モバイルアプリやwebアプリ開発によく使うバックエンド機能をまとめて使いやすくしたやつ
  - データベースFirestore
  - ストレージは Cloud Storage for Firebase
  - ユーザー認証はAuthentication
  - サーバーレスコンピューティングは Cloud Functions
  - などなど（モバイルアプリ向けのみ、ML Kitといった文字認識や顔検出など機械学習機能もある）
- インフラや一部バックエンドはGCPに任せてサーバーレスで開発できる！
  - 上記の機能をフロントからも使える
  - 今回はCloud Functionsを中心にサーバーレスアプリとして活用しました



### Cloud Functions for Firebaseのセットアップ
1. Firebaseのコンソールページ（ https://console.firebase.google.com ）を開き、プロジェクトを作成
1. firebaseを使うのが初めての場合はPCのterminalからCLIをインストールしてログイン認証を行う（ https://firebase.google.com/docs/cli ）
1. `firebase init` コマンドでJavaScript / TypeScriptのファイル一式を自動生成

これだけです

次のように`index.ts`で`export`された`functions`が関数としてデプロイできます

```index.ts
import * as functions from "firebase-functions";

export const helloWorld = functions.https.onRequest((request, response) => {
  response.send("Hello World!");
});
```

こちらの記事でスクリーンショット付きで説明しています

- [【GCP】Cloud Functionでリングフィットアドベンチャーの入荷をクロール&結果を通知するLINEbotを作ってみた【TypeScript】](https://qiita.com/mikan3rd/items/781dd4eed6b3bbcb514b)


### Cloud Functions for Firebaseの良いところ

- 関数単位のちょっとした処理をサーバーレスで用意できる
  - フレームワーク不要、関数だけでバックエンドサーバーができる
- firebase-toolsを使用したデプロイが楽
  - `firebase deploy` だけ！
  - Firestoreのセキュリティルールとかもデプロイ可能
- 様々なトリガーで関数を起動できる
  - HTTPリクエスト
  - PubSub
  - Cloud Schedulerの自動PubSub連携（定期処理）
  - フロント側からSDK経由で直接呼び出し
  - Firestore / Storage にデータの変更があった場合
  - などなど
- エミュレーターを使えばローカルでも動作確認可能


### Firestoreのいいところ
- 何も準備がいらないので楽
  - テーブルを追加する必要なし（ドキュメント追加と共に自動で作成される）
- ちょっとしたクエリなら使える
  - RedisなどのKey-Value型のDBではクエリが書けないが、Firestoreはドキュメント型なので簡単なクエリなら書ける
- 少量の使用なら無料枠におさまる
  - Cloud SQLなどマネージドDBは最低スペックでも1ヶ月数千円かかる
  - Google Compute EngineにMySQLサーバーを自前で立てることもできるが面倒



### ちょっとした工夫・注意点
- `firebase-admin`の`admin.initializeApp()`は全体で一度だけ行う必要があるため、`index.ts`の序盤で呼んでおきましょう
  - この記述の手前で`firebase-admin`を使った処理を含む関数やファイルをimportしてるとエラーになるので注意
- Firestoreを使用する場合
  - Cloud FunctionsでFirestoreのタイムスタンプ取得した時に日本時間で扱うためには`process.env.TZ = "Asia/Tokyo"`のように環境変数のタイムゾーンを書き換える必要があります
  - Firestoreのデータ保存時にundefinedな値があるとエラーになってしまうため、undefinedを自動で取り除くように`ignoreUndefinedProperties: true`を設定しておくと便利です
- `index.ts`に直接関数を記述していくと関数が増えるにつれて行数が多くなってしまうので、他のファイルに分けてから`export * from "XXX"`のようにするとスッキリすると思います


まとめると次のようになります

```src/index.ts
import admin from "firebase-admin";

// Cloud FunctionsでFirestoreのタイムスタンプ取得した時に日本時間で扱うために必要
process.env.TZ = "Asia/Tokyo";

// firebase-adminの初期化
admin.initializeApp();

// Firestoreへのデータ保存でundefinedな値を自動で取り除くように設定
const db = admin.firestore();
db.settings({ ignoreUndefinedProperties: true });

// それぞれのファイルでfunctionsをexportしている
export * from "./schedule";
export * from "./pubSub";
export * from "./https";
```

- 関数には基本的にregionを設定すると思いますが、毎回記述するのが面倒になってくるので次のようにregion付きの関数を定義しておくと便利です
- 同様にCloud Schedulerトリガーの関数を使用する場合もregionを指定したものを定義しておくと便利です
- `console.log()`ではなく、公式のloggerを使うとFirebaseのコンソール上でのログが見やすくなります

```src/firebase/functions.ts
import * as cloudFunctions from "firebase-functions";

const REGION = "asia-northeast1" as const;
const TIMEZONE = "Asia/Tokyo" as const;

export const functions = cloudFunctions.region(REGION);
export const scheduleFunctions =
  (runtimeOptions: cloudFunctions.RuntimeOptions = {}) =>
  (schedule: string) =>
    functions.runWith(runtimeOptions).pubsub.schedule(schedule).timeZone(TIMEZONE).retryConfig({ retryCount: 1 });

export { logger } from "firebase-functions/lib";


// 使い方の例
export const helloWorld = functions.https.onRequest((request, response) => {
  response.send("Hello World!");
});

export const helloWorld2 = scheduleFunctions({ memory: "1GB" })("0 * * * *").onRun(async (context) => {
  response.send("Hello World2!");
});
```
詳しくはGitHubをご確認ください


## :speech_left: Slack App について

### Slack App とは？
- Slack Appを作成すると、Slackにメッセージを投げたり、Slackのデータを取得したり、SlackのUIを使って操作したりできる
- 無料！！


### OAuth認証

- アプリを作成したworkspace以外でも使えるようにするためには、OAuth認証で他のworkspaceの認証トークンを取得する必要がある
- Slack Appを作成したworkspaceのみで使用する場合は不要
- 使用したいAPIに応じてscopeを付与する必要がある
- 今回のアプリではprivateチャンネルに対するアクセス権はない
- 認証用URLにリダイレクトするendpointと認証処理用のendpointを用意する必要がある

```src/oauth.ts
import { InstallProvider, Installation, InstallationQuery } from "@slack/oauth";

import { CONFIG } from "./firebase/config";
import { FieldValue, FirestoreParams, SlackOAuth, SlackOAuthDB } from "./firebase/firestore";
import { functions } from "./firebase/functions";

const getInstaller = () => {
  const installer = new InstallProvider({
    clientId: CONFIG.slack.client_id,
    clientSecret: CONFIG.slack.client_secret,
    stateSecret: CONFIG.slack.state_secret,
    authVersion: "v2",
    installationStore: {
      storeInstallation: async (_installation) => {
        const installation = _installation as Installation<"v2", false>;
        const teamId = installation.team.id;
        const SlackOAuthDoc = await SlackOAuthDB.doc().get();
        let data: FirestoreParams<SlackOAuth> = {};
        if (SlackOAuthDoc.exists) {
          data = {
            installation,
            updatedAt: FieldValue.serverTimestamp(),
          };
        } else {
          data = {
            installation,
            createdAt: FieldValue.serverTimestamp(),
            updatedAt: FieldValue.serverTimestamp(),
          };
        }
        await SlackOAuthDB.doc(teamId).set(data, { merge: true });
      },
      fetchInstallation: async (_installQuery) => {
        const installQuery = _installQuery as InstallationQuery<false>;
        const SlackOAuthDoc = await SlackOAuthDB.doc(installQuery.teamId).get();
        const data = SlackOAuthDoc.data() as SlackOAuth;
        return data.installation;
      },
    },
  });
  return installer;
};

export const slackOAuthUrl = functions.https.onRequest(async (request, response) => {
  const installer = getInstaller();
  const url = await installer.generateInstallUrl({
    scopes: [
      "channels:history",
      "channels:join",
      "channels:manage",
      "channels:read",
      "chat:write",
      "chat:write.public",
      "emoji:read",
      "reactions:read",
      "im:write",
      "users:read",
    ],
    redirectUri: CONFIG.slack.redirect_uri,
  });
  response.redirect(url);
});

export const slackOAuthRedirect = functions.https.onRequest(async (request, response) => {
  const installer = getInstaller();
  await installer.handleCallback(request, response);
});
```

これをdeployして`slackOAuthUrl`のendpointにアクセスすると次のような認証画面が表示される様になり、許可した時に認証情報がFirestorenに保存されるようになりました

![mikan3rd.slack.com_oauth_client_id=1007800288400.1564228189232&scope=channels%3Ahistory%2Cchannels%3Ajoin%2Cchannels%3Amanage%2Cchannels%3Aread%2Cchat%3Awrite%2Cchat%3Awrite.public%2Cemoji%3Aread%2Creactions%3Aread%2Cim%3Awrite%2Cusers%3Are.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178077/af472e9f-a313-c389-78a8-477c7fddf8d7.png)


### Interactive Component & App Home

- Interactive ComponentとはSlackのメッセージにテキストだけでなくボタンやセレクトボックスなどを表示できる機能
  - 各パーツはJSON形式で決まった構造で組み立てる必要がある
  - https://app.slack.com/block-kit-builder でプレビューできる
- App HomeをONにするとSlack上でアプリを選択した時にHomeタブが表示できるようになる
  - ここにもInteractive Componentを表示させることができる
  - 今回の場合はここでアプリの設定ができるようにしている
- Interactive Componentで選択された結果を受け取るendpointが必要
- actionが発生した場合に3秒以内に応答しないとtimeout扱いになってしまうため、PubSubをpublishしてレスポンスを素早く返し、実際の処理はPubSubを受け取った側で行うようにしている

```src/interactive.ts

import { PubSub } from "@google-cloud/pubsub";
import { createMessageAdapter } from "@slack/interactive-messages";

import { toBufferJson } from "./common/utils";
import { CONFIG } from "./firebase/config";
import { functions, logger } from "./firebase/functions";
import { Action } from "./slack/actionIds";

const slackInteractions = createMessageAdapter(CONFIG.slack.signing_secret);

slackInteractions.action({ actionId: Action.SelectTargetChannel }, async (payload, respond) => {
  const pubSub = new PubSub();
  await pubSub.topic(Action.SelectTargetChannel).publish(toBufferJson(payload));
});

export const slackInteractive = functions.https.onRequest(slackInteractions.requestListener());
```


```src/interactivePubSub.ts
import { SlackOAuth } from "./firebase/firestore";
import { functions, logger } from "./firebase/functions";
import { createHomeView } from "./services/createHomeView";
import { getConversationsList } from "./services/getConversationsList";
import { updateJoinedChannelIds } from "./services/updateJoinedChannelIds";
import { Action } from "./slack/actionIds";
import { SlackClient } from "./slack/client";

type CommonBasePayload = {
  team: {
    id: string;
  };
  user: {
    id: string;
    name: string;
    team_id: string;
    username: string;
  };
};

type CommonBaseAction = {
  block_id: string;
  action_ts: string;
  action_id: string;
};

type CommonPayload<T> = {
  actions: (T & CommonBaseAction)[];
} & CommonBasePayload;

type ChannelsSelectPayload = CommonPayload<{
  type: "channels_select";
  selected_channel: string;
}>;

export const selectTargetChannelPubSub = functions
  .runWith({ maxInstances: 1 })
  .pubsub.topic(Action.SelectTargetChannel)
  .onPublish(async (message) => {
    const { team, actions }: ChannelsSelectPayload = message.json;
    const selectedChannelId = actions.find((action) => action.action_id === Action.SelectTargetChannel)?.selected_channel;
    logger.log({team, actions, selectedChannelId})
  });
```


例えばこんな表示が作れる

![スクリーンショット 2021-06-04 14.14.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178077/7641e335-5a1b-2585-3db5-5aaa3abb9206.png)


### Event Subscription
- 特定のイベントが発生した時にリクエストを送ってくれるwebhook的なやつ
  - 先ほどのApp Homeが開かれた時には `app_home_opened` というイベントが送られてくるので最新のデータを反映したInteractive Componentを送っている
  - `channel_created` `emoji_changed` のイベントが送られた時にチャンネル作成の通知やスタンプ追加の通知をリアルタイムで送るようにしている（簡単）
- イベントを受け取るためのendpointが必要

```src/event.ts
import { verifyRequestSignature } from "@slack/events-api";

import { CONFIG } from "./firebase/config";
import { functions, logger } from "./firebase/functions";

export const slackEvent = functions.https.onRequest(async (request, response) => {
  verifyRequestSignature({
    signingSecret: CONFIG.slack.signing_secret,
    requestSignature: request.headers["x-slack-signature"] as string,
    requestTimestamp: parseInt(request.headers["x-slack-request-timestamp"] as string, 10),
    body: request.rawBody.toString(),
  });

  // Cloud Functionsのコールドスタートなどで3秒以内にレスポンスが返せない場合などに
  // Slackから同じイベントが何度も送られてくるため、初回以外は処理をスキップする
  if (request.headers["x-slack-retry-num"] && request.headers["x-slack-retry-reason"] === "http_timeout") {
    response.send();
    return;
  }

  logger.log(request.body)

  response.send(request.body.challenge);
});
```

例えばemojiが新規追加されたeventを受け取って次のようなメッセージを送ることができる

![スクリーンショット 2021-06-04 14.36.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178077/b511b419-7703-bb82-1f63-b1056fbb34b1.png)


## :warning: 実際に触ってみて分かった注意事項

### Cloud Functions for Firebaseを使う際の注意
- 外部へのアクセスなど一部の機能には従量課金プランに切り替えが必要
  - と言っても従量課金プランにも無料枠はあるのでそんなに気にすることはない
- コールドスタート
  - 実行環境はゼロから初期化されるため、関数の実行までその分の時間がかかる
- タイムアウト
  - 最大9分
  - そもそも呼び出し回数、メモリ、実行時間による従量課金なので長く重い処理には向かない
- デプロイによる課金
  - `firebase deploy` は内部的に Cloud Build を使用しているため Cloud Buildの実行時間が課金対象となる（無料枠あるのでそう超えないはず）
  - 関数は無料枠のない Container Registry に保存されるため、その分のストレージ料金がかかる
  - デプロイする度に古いContainerが削除されずに溜まっていって課金対象となってしまう
- 使えるのは Node.js （JavaScript / TypeScript）だけ
  - 普通のCloud FunctionsはRuby  / Python / Java / PHP / Go でも使えますが、デプロイは `gcloud functions deploy` コマンドとなり、複数の関数をデプロイする場合などには工夫が必要


### Firestoreを使う際の注意
- 読み取り、書き込み、削除のドキュメントの数による従量課金
  -頻繁に上記の処理が発生する場合や大量のデータを扱う場合は不向き
  - ページングなどでドキュメントの合計数が欲しい場合もデータの読み取りとして課金される
  - コンソール上で上記の処理を行った場合も課金対象
- NoSQLなのでRDBでいうリレーションを再現し辛い
  - 一応、サブコレクションや参照型など用意されているがクセがある
- NoSQLなのでカラムごとに型を制限できない
  - KeyとValueのように格納できるが型の制限はできないので想定外の値が入ってもエラーにならない
  - またマイグレーションのような概念もない
- 複数Keyを使ったクエリを書くには別途indexを貼る必要がある



### Slackアプリを使う際の注意
- レート制限
  - 同じAPIを呼べるのは1分間に20回までなどの制限がある
  - OAuth認証であればトークンごとに回数が測定される
- メッセージを指定して取得するAPIが存在しない
  - そもそもメッセージにサロゲートキーがない（ちなみにメッセージへのリンク取得APIではチャンネルIDをタイムスタンプを指定して取得するのでこれが複合キーっぽい）
  - 例えば1年前のとあるメッセージのチャンネルIDとタイムスタンプが特定できていても、API経由で取得するには1年前まで順番に遡るしかない
  - メッセージを順番に取得する際にも件数を指定しないといけないので、ヤマカンででかい件数を指定して繰り返す処理をしないといけない（レート制限にかからないように）
- Event APIには3秒以内にレスポンスを返さないとリトライが走る
  - 何か処理を行う時間がないので非同期にしないといけない
  - リクエストヘッダーの `x-slack-retry-num` `x-slack-retry-reason` を見ればハンドリング可能
- Interactive Componentのアクションにも3秒以内にレスポンスを返さないとtimeout扱いとなる
　　- Slack上で操作したユーザーにwarningが表示されてしまう

## :innocent: 苦労した点
### スタンプの多いメッセージを取得・通知するには？
- 一定数以上のスタンプがついたメッセージを取得したいが、そのようなAPIはない
  - メッセージの一覧を取得するAPIで全件取得してスタンプ数でフィルタリングする処理を行っている
  - ただしメッセージの一覧はチャンネルごとにしか取得できないので、チャンネル数だけメッセージの全件取得を行わなかればいけない
  - チャンネル数が多い場合はレート制限に引っかかってしまうため調整している（Cloud Taskで雑に調整）
  - 上記の処理を毎時実行する定期処理にしている
  - また、一度通知したメッセージが重複して通知されないように、通知済みのメッセージのチャンネルIDとタイムスタンプはFirestoreに保存してチェックしている
- 週間、月間で集計するには？
  - 毎時の処理で取得したスタンプの多いメッセージを保存して流用したい
  - しかし、メッセージを指定して取得できるAPIがないため、上記の処理の取得期間を週間と月間に伸ばして実行するチカラ技の処理となってしまっている・・・・


### スタンプ数の多いスレッドも通知したい！
- メッセージ一覧取得時にスレッドのデータは付与されない
- スレッドの一覧はメッセージ指定でしか取得できない
- つまり全てのメッセージに対してスレッド一覧取得を行うしかない
- 流石にそれは辛いので断念


## :star2: まとめ
- ちょっとした用途のバックエンドにはCloud Functions for Firebaseが便利！
- ちょっとした用途のシンプルなデータベースにはFirestoreが便利！
- Slackアプリは簡単に作れる!（けど制約があるので思い通りにならないことも）