Bolt for PythonでSlackアプリの3秒ルールを破る

サービシンクでバックエンドエンジニアをしている藤原です。

数ヶ月前に総務から「Slackに投稿した請求書などの経費書類を、Googleドライブにアップロードする仕組みを作ってほしい」という依頼を受けました。

こういう時に簡単にSlackと連携するアプリを作れるのがBoltです。BoltとはSlack APIを使ったアプリを作るために、Slack公式が提供しているフレームワークです。

その依頼に対しては、おおまかに以下のような流れで利用するアプリを作りました。

  1. Slackの#経費書類チャンネルに社員が書類(PDF)をアップロードする
  2. メッセージのショートカットを実行するとモーダルが表示される
  3. モーダルに金額や案件名、書類の種類を入力する
  4. 入力された情報を元にアプリ側でGoogle Driveの適切なフォルダに書類がアップロードされる
  5. 確認が必要な種類の書類をアップロードすると、総務にメンションが送られる

できたアプリをLambdaにアプリの関数をデプロイ。動作することを確認して任務完了と普段の業務に戻りました。

しかしある日、総務からまた声をかけられます。

「一部アップロードできていないファイルがあるようなんですが......」

LambdaとBoltの3秒ルール

なぜアップロードに失敗しているのかログを確認しても、特にエラーが起きているのは見つけられませんでした。

そこで色々調べているうちに、Slackアプリの3秒ルールに気がつきます。

Slackのアプリはリクエストを受けてから3秒以内に結果を返すことができないとタイムアウト扱いでエラーを返すようになっています。

この3秒ルールへの対応として、Boltではack()という関数を呼び出すと一旦OKレスポンスを返して処理を継続できるようになっています。 ところがLambdaでBoltの関数を動かす場合、ack()などで何らかのレスポンスを返すとその時点で実行環境の破棄が始まってしまうようなのです。

最初に作った関数ではack()を使っていたので、Google Driveへのアップロードが間に合う場合とそうでない場合があったのでしょうか。 だからといってack()をやめると、レスポンス返却を含めたすべての処理を3秒以内に終える必要があります。

すべてを解決するBolt for Pythonのlazyリスナー

そんな中たどり着いたのがlazyリスナーです。

以下のように書くことで、ack()を返す処理と時間のかかる処理をを別のLambda関数で実行してくれるらしいです。

app.view('pdf_upload_submit')(
        ack=lambda ack: ack(),
        lazy=[process_file_upload_and_notify]
)

これによって、ack()でOKレスポンスを返しても、もう1つのLambda関数で処理を継続できます。

Pythonさえ書ければ、あとはもう何の問題もありません。

Pythonでの実装

Pythonで書いたコードはこんな感じになりました。一部を抜き出してお見せします。

Boltアプリの初期化とハンドラの追加

process_before_response=Trueを忘れないでください。

app = App(
        token=os.environ.get('SLACK_BOT_TOKEN'),
        signing_secret=os.environ.get('SLACK_SIGNING_SECRET'),
        process_before_response=True
)

slack_handler = SlackRequestHandler(app)

# Lambda関数のハンドラ
def handler(event, context):
        return slack_handler.handle(event, context)

ショートカット実行時にモーダルを表示するリスナー

@app.shortcut('pdf_upload')
def open_modal(ack, shortcut, client, say):
        ack()

        message = shortcut.get('message', {})
        files = message.get('files', [])

        # メッセージにファイルが添付されているかチェックする
        if not files:
                say(
                        thread_ts=message['ts'],
                        text=f'<@{shortcut["user"]["id"]}>\nファイルが見つかりません。'
                )

                return

        # ファイルがPDFかどうかチェックする
        if not files[0]['mimetype'] == 'application/pdf':
                say(
                        thread_ts=message['ts'],
                        text=f'<@{shortcut["user"]["id"]}>\n申し訳ございません。私が処理できるのはPDFファイルのみです。'
                )
                return

        meta = {
                'thread_ts': message['ts'],
                'channel_id': os.environ.get('SLACK_CHANNEL_ID'),
                'file': files[0]['url_private'],
        }

        slack_bot_token = os.environ['SLACK_BOT_TOKEN']

        try:
                # モーダルビューを表示する
                client.views_open(
                        token=slack_bot_token,
                        trigger_id=shortcut['trigger_id'],
                        view={
                                'type': 'modal',
                                'callback_id': 'pdf_upload_submit',
                                'private_metadata': json.dumps(meta),
                                **modal_data
                        }
                )

        except Exception:
                logger.exception('モーダルの表示に失敗しました。')
                say(
                        thread_ts=message['ts'],
                        text=f'<@{shortcut["user"]["id"]}>\nエラーが発生しました。'
                )

ファイルのアップロードとSlackへの結果通知を行う関数

def process_file_upload_and_notify(say, body):
        try:
                file_name = make_file_name(body)
                meta = json.loads(body['view']['private_metadata'])
                file = meta['file']
                type = body['view']['state']['values']['type']['type_select']['selected_option']['value']

                # 共有ドライブにPDFファイルをアップロードする
                drive = GoogleDrive() #自作のクラス
                drive.create_file(file, file_name, type)

        except Exception:
                logger.exception('ファイルのアップロードに失敗しました。')
                message = 'ファイルのアップロードに失敗しました。'

        # Slackに完了を通知する
        user_id = body['user']['id']
        needs_to_mention = type in NEEDS_TO_MENTION_TYPES

        message = f'<@{user_id}>'
        if needs_to_mention:
                message += f' Cc <{os.environ.get("SOUMU_GROUP_ID")}>'
        message += f'\n{type}のアップロードが完了しました。\n「{file_name}」'

        say(
                channel=meta['channel_id'],
                thread_ts=meta['thread_ts'],
                text=message
        )

モーダル送信時に反応するlazyリスナー

app.view('pdf_upload_submit')(
        ack=lambda ack: ack(),
        lazy=[process_file_upload_and_notify]
)

まとめ

JavaScriptのBoltでも3秒以上かかる処理を行う方法はあるようです。

ただ面倒な実装や他サービスとの連携が必要だったり、ファイルを受け取ってアップロードするのが難しかったりで、だったらPythonで作り直すのが楽だという判断をしました。

Pythonは学生の頃に少し勉強したきりで、関数を実行する行より前に定義しないといけない点にはちょっとやられましたが、優秀な助手(ChatGPT)がいるのでそれほど困ることはなかったです。

参考リンク

Webサイト・システムの
お悩みがある方は
お気軽にご相談ください

お問い合わせ 03-6380-6022(平日09:30~18:30)

出張またはWeb会議にて、貴社Webサイトの改善すべき点や
ご相談事項に無料で回答いたします。

無料相談・サイト診断 を詳しく見る

多くのお客様が気になる情報をまとめました、
こちらもご覧ください。