太陽がまぶしかったから

C'etait a cause du soleil.

Slackで絵文字リアクションしたURLを特定チャンネルにまとめるSlackBotをGoogle Cloud Functionsに構築

絵文字でリアクションしたURLを特定チャンネルに集めたい

RSS登録するフィードの更新に合わせてどんどん投稿されていくため、気になった記事は専用のチャンネルに移したい。それに限らず、X(元Twitter)やブラウザやメルマガなどの他アプリからもリンクURLを共有してカードを投稿したいのだけど、Slack標準の共有機能ではいちいち投稿先のアカウントやチャンネルを指定する必要があって煩雑だし、誤爆の可能性がある。なので iOS のショーカット機能を使って Slack の特定チャンネル仕組みを作っておくと便利になる。

 上記の記事において、さまざまなアプリから特定のチャンネルにあとで読みたい記事をまとめるショートカットを作成したが、SlackのRSSリーダーから特定チャンネルにまとめさせたい場合にはワンタップでできるとさらに嬉しい。

現在でも、Slack ではメッセージメニューからメッセージを共有し、必要ならそれに説明を加えることができるという素晴らしい機能があります。しかし、多くのチャンネルからメッセージを 1 か所に集めたい場合に、それをワンクリックで行える方法はこれまでありませんでした。

その解決策がこちら、リアク字チャンネラーです。有用なツールですが、ふざけた名前だと思われるかもしれませんね。これは、あるチャンネル(または複数のチャンネル)から別のチャンネルにメッセージをすばやくコピーするためのものです。当社のチーム用に作ったのですが、とても便利でしたので、ほかの人にも使ってもらえるようにしました。

 Slack社内でも絵文字リアクションを使った転送処理が使われており、アプリも提供されているのだけど、Slack公式の転送機能が使われており、そうするとBOTのチャンネル参照権限の関係から処理対象にするのがややこしくなってしまったり、他のアプリからショートカットで利用したときと挙動が変わってしまう問題が出てくる。このため、オリジナルのBOTを作成してショートカットでURLを転送したときと同様の挙動をBOTに実装することで体験を損ねないようにしたい。

 今回の Slack BOT 作成では Google Cloud Funcitons のサーバレス環境に Slack Bolt フレームワークで構築する。絵文字リアクションという明確なトリガーによって起動し、URLを書き出したら終了する処理において常時起動のサーバーを用意するのは不合理であり、起動時間で課金されるサーバーレス環境に構築することに経済合理性があると判断した。

Google Cloud Functionsの作成方法

 Google Cloud Computing や Cloud Functions そのものの説明については、野良のものよりも以下の公式チュートリアルを読むのが正確かつ早いという実感があるため、本稿の対象外とする。

 個人的には現在利用されている第二世代の Google Cloud Functions が Cloud Run と Eventarc 上に構築されてたラッパーのような存在になっていたのが発見だった。このためサービスアカウントの権限としては、「 Cloud Functions 起動元」ではなく、 「Cloud Run 起動元」にしてしておく必要がある。そのあたりも公式チュートリアルを読むのが正確かつ早いという実感がある(2回目)。時間返して!

gcloud functions deploy XXX \
    --gen2 \
    --region=asia-northeast1 \
    --runtime=python311 \
    --trigger-http \
    --allow-unauthenticated \
    --timeout=3s \
    --min-instances=0 \
    --max-instances=10 \
    --memory=256Mi \
    --source=src/ \
    --entry-point=main \
    --service-account XXX \

 それはさておき、上記のようなデプロイコマンドで認証なし http トリガーで起動する関数をデプロイできるようになればサーバー側としては Slack Bolt と独自処理を肉付けしていくことで完成できる。

Slack Bot の権限拡張とイベントリスナーの登録

 Slack側でSlack Botの設定を行う。今回は iOSショートカットから実行する Incoming Webhook用に作成したBOTを拡張する。

{
    "display_information": {
        "name": "share"
    },
    "features": {
        "bot_user": {
            "display_name": "share",
            "always_online": true
        }
    },
    "oauth_config": {
        "scopes": {
            "bot": [
                "chat:write",
                "incoming-webhook",
                "links.embed:write",
                "links:write",
                "channels:history",
                "reactions:read"
            ]
        }
    },
    "settings": {
        "event_subscriptions": {
            "request_url": "xxx",
            "bot_events": [
                "reaction_added"
            ]
        },
        "org_deploy_enabled": false,
        "socket_mode_enabled": false,
        "token_rotation_enabled": false
    }
}

 event_subscriptions 設定をすることで、設定したイベントに合わせて request_url を起動することがきるため、先ほど作ったサーバレスAPIのURLを request_url に指定する。ここでは url_verification によるチェックに失敗してしまうため、後述の通りにAPIを実装してチェックを通せるようにする必要がある。またAPIから認証に利用する以下のトークンについて取得しておく。

  • Settings -> Basic Information -> Signing Secret
  • Settings -> Install App -> Bot User OAuth Token

Slack Bolt で Slack Bot をサーバレスに実行する

 Slack Bolt は Slack 公式が開発している Bot 開発用のフレームワークで認証処理やイベント処理などをラップして開発しやすくしてくれるライブラリ。 app に共通処理やコンテキスト情報を詰め込んでデコレータでイベント駆動する関数を特定するなど Flask で馴染みのある手法に従っており、個人的には違和感なく実装できた。こちらもまずは公式ドキュメントを読むのが早い。

 最終的には上記のように実装している。まずは Cloud Functions で起動するために @functions_framework.http でデコレーションした main 関数内に Slack Bot の基本処理を実装する。

  • requirements.txt
Flask==2.3.2
functions-framework==3.4.0
google-cloud-logging==3.5.0
slack_bolt==1.18.0
  • main.py
import json
import os
import flask
import functions_framework
import slack_bolt
from slack_bolt.adapter.google_cloud_functions import SlackRequestHandler

SECRETS: dict = json.loads(os.getenv("SECRETS"))
app: slack_bolt.App = slack_bolt.App(
    token=SECRETS.get("SLACK_BOT_TOKEN"),
    signing_secret=SECRETS.get("SLACK_SIGNING_SECRET"),
    request_verification_enabled=True,
)


@functions_framework.http
def main(request: flask.Request):
    # POST しか受け付けない
    if request.method != "POST":
        return "Only POST requests are accepted", 405

    # リトライ対応はしない
    if request.headers.get("x-slack-retry-num"):
        return "No need to resend", 200

    # 通常イベント、コマンド、チャレンジ認証で処理を分岐
    content_type: str = request.headers.get("Content-Type")
    if content_type == "application/json":
        body: dict = request.get_json()
        if body.get("type") == "url_verification":
            headers: dict = {"Content-Type": "application/json"}
            res: str = json.dumps({"challenge": body.get("challenge")})
            return (res, 200, headers)
        else:
            return SlackRequestHandler(app).handle(request)
    elif content_type == "application/x-www-form-urlencoded":
        return SlackRequestHandler(app).handle(request)
    else:
        return ("Bad Request", 400)

 SECRETS には環境変数から以下のJSON形式で先ほど取得した、 Signing Secret や Bot User OAuth Token などの値を Google Secrets Manager から取得して設定している。

{
    "SLACK_SIGNING_SECRET": "xxxx",
    "SLACK_BOT_TOKEN": "xoxb-xxxx",
    "SHARE_CHANNEL_ID": "xxx",
    "REACTION_EMOJI": "mega"
}

 SHARE_CHANNEL_ID は転送先のチャンネルURLの archives/ 以下のIDを設定。 REACTION_EMOJI はアクションを起こす条件となる絵文字を指定している。絵文字の指定名称については 🎁 Emoji cheat sheet for GitHub, Basecamp, Slack & more などを使うと便利。

 ワンクリックで起動できるように、Slackの 環境設定 -> メッセージ & メディア -> 絵文字 -> メッセージでワンクリック絵文字リアクションに設定しておくと良いだろう。目やチェックなどは他の用途にも使われがちなため、いかにもシェア用にだけ使いそうなメガフォンにしている。

 Secrets Manager に登録する方法は上記を参照。その上で、Google Secrets Manager に登録したJSONから環境変数に流し込むオプション設定を gcloud のデプロイ時に設定している。

 --set-secrets SECRETS=projects/XXX/secrets/SHARE_SLACK_SECRETS:latest

 main 内でも色々と分岐しているが、最終的にはSlackのトークンが認証された状態で Google Cloud Functions 用に実装された SlackRequestHandler(app).handle(request) が実行されればよしなに必要なイベントに応じた関数が起動されるようになる。また、今回は JSON 形式のリアクションイベントのみが実装範囲であるが、例えば /コマンド 形式で命令をした場合などには x-www-form-urlencoded でリクエストがくるため、後のことを考えてハンドリングできるようにしておく。この辺りの実装の混乱は長い期間をかけて開発されてきた Slack の歴史であり、負の側面であろう。

 url_verification があるのは、前述の通りSlack側からそのサーバーの正当性を担保するためであり、イベントリスナーにURLを紐付ける際などに challenge の結果を戻せないとサーバーが許可されないため実装しておく必要がある。ここまで実装した上で、改めて App Manifest を保存することで認証を通せる。失敗する場合には実装を見直す必要がある。

絵文字リアクションがあったらURLを転送する処理

 Slack BOTの基盤部分が出来上がったため、イベントドリブンで実行する関数を作成する。リアクションが追加された時に起動するデコレータは以下の通り。evenvt 変数内に evnet の情報が含まれており、どの絵文字リアクションだったかも取得できるため、それが先ほど使った対象のリアクションだったときのみ処理を実行することとする。

@app.event("reaction_added")
def handle_reaction_added(event: dict):
    if event["reaction"] == SECRETS.get("REACTION_EMOJI"):

 なお、event を検知できるのは当該BOTがインテグレーションされているチャンネルのみであるため、チャンネル内のアプリ設定やメンションを飛ばすなどしてBOTをチャンネルに追加しておく必要がある。

 またリアクションのイベントにはメッセージの内容が含まれておらず、チャンネルIDとタイムスタンプのみが含まれているため、改めてメッセージの内容を取得する必要がある。タイムスタンプをIDに使うなんて某マイナンバー住民票と思ったりもするが、チャンネルごと発番なので重複したりはしない。とでもいうのだろうか。

        channel_id: str = event["item"]["channel"]
        ts: str = event["item"]["ts"]
        result: dict = app.client.conversations_history(
            channel=channel_id, inclusive=True, latest=ts, limit=1
        )

 取得したメッセージからURLを抽出して転送処理やトラッキングパラメータの除去などを実施。

        text: str = result["messages"][0]["text"]
        link: str = url_utils.extract_url(text)
        link = url_utils.remove_tracking_query(link)

 処理内容の意図としては pytest を見てもらった方が良いだろう。

 抽出できたURLがある場合のみ投稿する。ショートカットと同様に unfurl_links=True とすることでカードを展開可能としている

        if link is not None:
            app.client.chat_postMessage(
                channel=SECRETS.get("SHARE_CHANNEL_ID"), text=link, unfurl_links=True
            )

 BOTのいるチャンネルの投稿に対して指定した絵文字リアクションをすることで動作確認できるのだが、Cloud Functionsのログを睨めっこしながらトラブルシュートが必要になることもあるだろう。デバッグログについては、Google の Cloud Logging に連携することでクラウド上で確認が可能である。

 詳細は上記の記事などに詳しい。正直なとことを言えば、イベントオブジェクトの中身などを確認する際に実環境で printf デバッグに近しいログ出力をせざるを得ない場面も多々あった。

自作のSlack Bot は「名前のない料理」

 以上までで、Slackで絵文字リアクションしたURLを特定チャンネルにまとめる SlackBot を Google Cloud Functions に構築できた。実装された処理そのものは単純なものであるが、Google Cloud Functions や Slack Bolt の利用方法についてまで説明して長大なものになってしまった感はある。実際問題として自分自身の学習コストとしてもそれなりに高く、試行錯誤が必要であった。

 それでも、今回の基盤を前提にすることでコマンドの実装や各種APIとの連携なども安価かつ柔軟に行えるようになることが期待できる。何より、自分による自分のための自分の Slack Bot には勝手なこだわりを詰め込みつつも使いながら不便に感じた部分を即座に直していけるため、愛着が湧きやすい。

ドッグフーディング (英: dogfooding) または「自社のドッグフードを食べる」「ドッグフードする」(Eating your own dog food、Drinking your own champagneとも言う)は、コンピュータ業界において、自社製品を開発して利用する組織の習慣で、組織が実際の使用法で日々自分たちで製品を利用しながら製品テストを行うことである。

 自分で作ったものを自分で使うのは「ドッグフィーディング」なんて言われるけれど、「名前のない料理」のが近い感覚。自分だけが食べる前提であればピーキーな味付けや大雑把さを発揮しながら時に大胆にトッピングをしたり、味変をしたりができる。

 そんなどうでも良い妄想をしつつも、各種アプリのショートカットやSlackのリアクションから「あとで読む」を集約できるようになったので、いよいよ Slack およびサーバレス環境に居候させている AI Bot との競争や共創や狂騒を試みることとしたい。