🙌

slack bolt + cloud functions v2 + puppeteer

2023/07/05に公開

TERASS でエンジニアをしているruiewoと言います。Cloud Functionsとslack boltでアプリを開発していて、試行錯誤した経験を共有したいと思います。

slack bolt

slack boltの使い方に関しては公式が一番わかりやすいのでこちらを一通り実行します。
https://slack.dev/bolt-js/ja-jp/tutorial/getting-started

1. create Slack App

以下のページからSlackアプリを作成します。From Scratchを選択。
https://api.slack.com/apps/new

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との違いは以下を参照ください。
https://cloud.google.com/functions/docs/concepts/version-comparison?hl=ja

大きな違いは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],
  }, 
  ...})

あとは淡々とコードを書けばいいのですが、課題がいくつかあります。

  1. v2からfunctions.configを利用した環境変数の扱いが非推奨になりました。SecretManagerの利用とパラメータ化が推奨されます。
  2. Slack APIの3秒ルール問題です。APIへのレスポンスを3秒以内に返す必要があるのですが、Cloud functionsの場合、レスポンスを返すとCPU割り当てを剥がされるため後続の処理が行えなくなります。

Secretの取り扱い

SecretManagerAPIを有効化します。当然ですがSecretManagerですが有料です。無料枠はSecret6個&10000呼び出し/月までです。
https://cloud.google.com/secret-manager/pricing?hl=ja

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()した変数を直接渡すことが可能なようです。
https://github.com/firebase/firebase-functions/issues/1084#issuecomment-1437147250

https://zenn.dev/kunimasu/articles/d98b9748b2d411

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なので設定次第で通りそうな気はします。
https://cloud.google.com/blog/ja/products/serverless/cloud-run-gets-always-on-cpu-allocation

今回は迂回路を通る方法(Pub/Subを利用)で実装します。

PubSub

PubSubとはPublisherがmessageをtopicに対して発行し、subscriberがtopicからmessageを受け取る仕組みです。詳しくは公式を参照してください。
https://cloud.google.com/pubsub/docs/overview?hl=ja

Google Cloudからpubsubのtopicを作成します。
https://console.cloud.google.com/cloudpubsub/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
})

https://dev.classmethod.jp/articles/integration-cloud-pub-sub-and-cloud-functions/
https://cloud.google.com/functions/docs/tutorials/pubsub?hl=ja
https://firebase.google.com/docs/functions/pubsub-events?hl=ja&gen=2nd

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点を対応しました。

.puppeteerrc.cjs
const { join } = require('path');

module.exports = {
  cacheDirectory: join(__dirname, '.cache', 'puppeteer'),
};
firebase.json
{
  "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"
      ]
    }
  ]
}

このあたりの記事が参考になりました。
https://ths-net.co.jp/shopify_blog/puppeteer/
https://www.chikach.net/category/useful/puppeteer-v19-cloud-functions-workaround/
https://github.com/puppeteer/puppeteer/issues/9128
https://qiita.com/7mpy/items/3ffc7974a46a82ec1f8e

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',
});
Terass Tech Blog

Discussion