概要
Cloud Functions for Firebase で Slack App を作ってみたのでそのノウハウの紹介です
今回作ったもの
Lively
LivelyはSlack上のコミュニケーションをより便利に楽しくするための機能を複数備えたアプリです!
様々なチャンネルから人気の投稿を探して通知します
週間・月間で人気のあった投稿を振り返ります
アプリのホームタブから簡単に設定ができます
新しく作成された絵文字やチャンネルをお知らせします
privateチャンネルには関与しないようになっているため安心です
GitHub
ソースコードはこちら
Slack App Directory
ここからインストールできます
技術スタック
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
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のセットアップ
Firebaseのコンソールページ( https://console.firebase.google.com )を開き、プロジェクトを作成
firebaseを使うのが初めての場合はPCのterminalからCLIをインストールしてログイン認証を行う( https://firebase.google.com/docs/cli )
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】
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をご確認ください
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に保存されるようになりました
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})
});
例えばこんな表示が作れる
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を受け取って次のようなメッセージを送ることができる
実際に触ってみて分かった注意事項
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が表示されてしまう
苦労した点
スタンプの多いメッセージを取得・通知するには?
一定数以上のスタンプがついたメッセージを取得したいが、そのようなAPIはない
メッセージの一覧を取得するAPIで全件取得してスタンプ数でフィルタリングする処理を行っている
ただしメッセージの一覧はチャンネルごとにしか取得できないので、チャンネル数だけメッセージの全件取得を行わなかればいけない
チャンネル数が多い場合はレート制限に引っかかってしまうため調整している(Cloud Taskで雑に調整)
上記の処理を毎時実行する定期処理にしている
また、一度通知したメッセージが重複して通知されないように、通知済みのメッセージのチャンネルIDとタイムスタンプはFirestoreに保存してチェックしている
週間、月間で集計するには?
毎時の処理で取得したスタンプの多いメッセージを保存して流用したい
しかし、メッセージを指定して取得できるAPIがないため、上記の処理の取得期間を週間と月間に伸ばして実行するチカラ技の処理となってしまっている・・・・
スタンプ数の多いスレッドも通知したい!
メッセージ一覧取得時にスレッドのデータは付与されない
スレッドの一覧はメッセージ指定でしか取得できない
つまり全てのメッセージに対してスレッド一覧取得を行うしかない
流石にそれは辛いので断念
まとめ
ちょっとした用途のバックエンドにはCloud Functions for Firebaseが便利!
ちょっとした用途のシンプルなデータベースにはFirestoreが便利!
Slackアプリは簡単に作れる!(けど制約があるので思い通りにならないことも)
↧