slack bolt + cloud functions v2 + puppeteer
TERASS でエンジニアをしているruiewoと言います。Cloud Functionsとslack boltでアプリを開発していて、試行錯誤した経験を共有したいと思います。
slack bolt
slack boltの使い方に関しては公式が一番わかりやすいのでこちらを一通り実行します。
1. create Slack App
以下のページからSlackアプリを作成します。From Scratchを選択。
2. tokenとscopeの設定
最終的に以下のscopeを利用します。
scope | description |
---|---|
chat:write | チャットの書き込み |
commands | スラッシュコマンド |
im:history | アプリとのダイレクトメッセージチャンネルのイベント購読 |
users:read | ユーザ情報の取得 |
users:read.email | ユーザのメールアドレスの取得 |
3. socket mode
slackのget startedではsocket modeを有効にしますが、今回はCloud Functionsにデプロイするため最終的にはoffにします。開発時はsocket modeの方が利便性が高いため、初めてbotを作成する場合やCloud Functionsのデプロイが終わるまではsocket modeで動作確認をするのが楽です。
4. subscribe bot event
好きなイベントで構いませんが、botアプリと直でやり取りしたいため今回はmessage:imをsubscribeします。
※アプリをslackのworkspaceへinstall後、slack上でアプリを開いた際に「このアプリへのメッセージ送信はオフにされています。」が出る場合、Features/App homeのページ下部にshow tabsというエリアがあるので、massage tabを有効にしてください。
5. create slash command
左メニューのslash commandsから新しいslash commandを追加します。
socket modeの場合はURLの入力欄が表示されませんが、後程cloud functionsのデプロイ先のURL + /eventsのようにendpointまで含んだURLを指定します。
6. install app
何かを弄るたびに画面上にinstallかreinstallを促されると思いますので、適宜installして動作を確認してください。
参考までにここまでのサンプルを置いておきます。slaskのget started通りに行った場合はdotenvはinstallしてないと思いますが、envファイルを扱いたいのでinstallしてます。
npm init
npm install @slack/bolt
npm install dotenv
const { App } = require('@slack/bolt');
require('dotenv').config();
const app = new App({
token: process.env.SLACK_BOT_TOKEN,
signingSecret: process.env.SLACK_SIGNING_SECRET,
socketMode: true,
appToken: process.env.SLACK_APP_TOKEN,
port: process.env.PORT || 3000,
});
app.message('hello', async ({ message, say }) => {
const result = await app.client.users.info({ user: message.user });
console.dir(result);
// user.name
// user.real_name
// user.profile.display_name
// user.profile.email
// etc.
await say(`${message.username}`);
});
app.command('/my-slash-command', async ({ command, ack, respond }) => {
await ack();
await respond(`${command.text}`);
});
(async () => {
await app.start();
console.log('⚡️ Bolt app is running!');
})();
cloud functions
今回はcloud functions v2で構成します。
v1との違いは以下を参照ください。
大きな違いはv2はCloud Run上に構築されることで、これに伴い1リクエストのタイムアウト時間が伸びたり、1インスタンスで複数リクエストが捌けたりします。(Cloud Runとの違いは?)
他にもfunctionのURLがCloud Functions用のものとCloud Run用の物の2種類が発行されます。
https://{region}-{project name}.cloudfunctions.net/{function name}
https://{function name}-{random}-an.a.run.app
どちらのURLを利用しても構わないようです。
まずはinitします。
firebase init functions
v1との目立つ違いはregion等の指定がonRequestの第1引数のオプションに指定することになったことですね。
const myFunc = onRequest(option, handler);
具体的には以下のような指定になります。
import { onRequest } from 'firebase-functions/v2/https';
const slack = onRequest(
{
region: 'asia-northeast1',
memory: '512MiB',
secrets: [botToken, signingSecret, appToken],
},
...})
あとは淡々とコードを書けばいいのですが、課題がいくつかあります。
- v2からfunctions.configを利用した環境変数の扱いが非推奨になりました。SecretManagerの利用とパラメータ化が推奨されます。
- Slack APIの3秒ルール問題です。APIへのレスポンスを3秒以内に返す必要があるのですが、Cloud functionsの場合、レスポンスを返すとCPU割り当てを剥がされるため後続の処理が行えなくなります。
Secretの取り扱い
SecretManagerAPIを有効化します。当然ですがSecretManagerですが有料です。無料枠はSecret6個&10000呼び出し/月までです。
v1の頃のサンプルによくある以下のようなfunctions.configを使った例をSecretManagerを利用した形に改変するとdeploy時やserve時に動きません。
import * as functions from "firebase-functions"
import { App, ExpressReceiver } from "@slack/bolt"
const { signing_secret, bot_token } = functions.config().slack
const receiver = new ExpressReceiver({
signingSecret: signing_secret,
endpoints: "/events",
processBeforeResponse: true,
})
const app = new App({
receiver,
token: bot_token,
processBeforeResponse: true,
})
export const slack = functions.https.onRequest(receiver.app)
secretの利用が許されるのはruntimeのみであるため、appの初期化処理はonRequestのHandlerの中からでないと呼べません。
import { App, ExpressReceiver } from '@slack/bolt';
import { defineSecret } from 'firebase-functions/params';
import { onRequest } from 'firebase-functions/v2/https';
const botToken = defineSecret('SLACK_BOT_TOKEN');
const signingSecret = defineSecret('SLACK_SIGNING_SECRET');
const appToken = defineSecret('SLACK_APP_TOKEN');
export const slack = onRequest(
{
region: 'asia-northeast1',
secrets: [botToken, signingSecret, appToken],
},
(req, res) => {
const expressReceiver = new ExpressReceiver({
signingSecret: signingSecret.value(),
endpoints: '/events',
processBeforeResponse: true,
});
const app = new App({
receiver: expressReceiver,
token: botToken.value(),
appToken: appToken.value(),
});
expressReceiver.app(req, res);
},
);
公式のシークレットパラメータの項目に以下のようにも書いてあります。
シークレットの値は関数の実行時まで非表示になるため、関数の構成中は使用できません。
https://firebase.google.com/docs/functions/config-env?hl=ja&gen=2nd#secret_parameters
同様の理由でdeploy時にもsecretの値を使えませんが、optionに渡す場合にはdefineSecret()した変数を直接渡すことが可能なようです。
Slack APIの3秒ルール
3秒ルールとはapiのレスポンスを3秒以内に返さないとタイムアウトで失敗するというものです。
We wait longer than 3 seconds to receive a valid response from your server.
https://api.slack.com/apis/connections/events-api#error-handling
幸いにもackというレスポンスを返すだけのメソッドが定義されているので、以下のような処理を書きたくなるのですが、これは動きません。
app.command('/my-command', async ({ command, ack, say }) => {
await ack();
await doHeavyTask();
await say(`Done!`);
});
Cloud functions(Cloud Runも)の仕組みとして、呼び出し後にHTTPレスポンスを返すと関数の実行が終わったもの判断され、CPUの割り当てが剥がされます。上記のようなack()後に重たい処理を行った場合、CPU割り当てがほぼない状態で処理が行われるため、正常に終了できないか異常に時間が掛かって完了する、といった挙動になるようです。
これを解決するには
・CPU割り当てを剥がされないようにする、
・Slack APIからの呼び出し時は軽い処理のみに留め、重たい処理は迂回路を用意
になると思います。
Cloud Runであればalways on CPUという設定が可能なため、これを使うのも選択肢です。functionsの場合は相当するものがあるのかないのか分かりませんが、v2はCloud Runなので設定次第で通りそうな気はします。
今回は迂回路を通る方法(Pub/Subを利用)で実装します。
PubSub
PubSubとはPublisherがmessageをtopicに対して発行し、subscriberがtopicからmessageを受け取る仕組みです。詳しくは公式を参照してください。
Google Cloudからpubsubのtopicを作成します。
PubSub利用には@google-cloud/pubsubを使います。(firebase SDKの方にもあるんですかね?)
npm install @google-cloud/pubsub
new PubSub().topic('topic_name')でtopicを特定し、publishMessageでメッセージを送ります。これだけです。今回は3秒ルールのために送信側での処理は極力行いたくないため、受け取ったコマンドとユーザIDだけを渡して、レスポンスを返します。
import { PubSub } from '@google-cloud/pubsub';
import { App, ExpressReceiver } from '@slack/bolt';
app.command('/my-command', async ({ command, ack, say }) => {
const pubsub = new PubSub();
const topic = pubsub.topic('my_topic_name');
const messageId = await topic.publishMessage({
json: {
userId: command.user_id,
command: command.text,
},
});
await say(`command accepted.`);
await ack();
});
subscriber側のコードはこんな感じです。
topicをoptionで指定するだけで良しなに実行してくれます。
import { onMessagePublished } from 'firebase-functions/v2/pubsub';
export const test = onMessagePublished({
topic: 'my_topic_name'
}, async (event)=>{
// do heavy task
})
puppeteer
今回は処理内容にpuppeteerを使ったのですが、いくつかエラーに当たったので備忘録として記録しておきます。
Could not find Chrome
Error: Could not find Chrome (ver. 114.0.5735.133). This can occur if either 1. you did not perform an installation before running the script (e.g.
npm install
) or 2. your cache path is incorrectly configured (which is: /workspace/.cache/puppeteer).
Chromeが無いよ、と言われるのですが心当たりがありません。
色々調べた結果、以下の2点を対応しました。
const { join } = require('path');
module.exports = {
cacheDirectory: join(__dirname, '.cache', 'puppeteer'),
};
{
"functions": [
{
"source": "functions",
"codebase": "default",
"ignore": [
"node_modules",
".git",
"firebase-debug.log",
"firebase-debug.*.log",
".cache" // <= add this!!
],
"predeploy": [
"npm --prefix \"$RESOURCE_DIR\" run lint",
"npm --prefix \"$RESOURCE_DIR\" run build"
]
}
]
}
このあたりの記事が参考になりました。
Memory limit 256 MiB
'Memory limit of 256 MiB exceeded with 327 MiB used. Consider increasing the memory limit, see https://cloud.google.com/functions/docs/configuring/memory'
cloud functionsでデフォルトのメモリ割り当てが256MiBであり、puppeteerを動かすには足りません。optionにmemory指定を足して対応します。512で動作するのは確認できました。複数リクエスト時などでメモリ足らなくなるかもと思わなくもないですが、とりあえず512は最低でも必要です。
export const hoge = onMessagePublished(
{
topic: 'my_topic_name',
memory: '512MiB',
},
...)
Puppeteer old Headless deprecation
Puppeteer old Headless deprecation warning:
これはエラーではなくログに含まれていただけなのですが、puppeteerのheadless指定が変わったようです。
headless: trueで指定していましたが、"new"の指定が追加されました。
const browser = await puppeteer.launch({
// headless: true,
headless: 'new',
});
Discussion