BASEプロダクトチームブログ

ネットショップ作成サービス「BASE ( https://thebase.in )」、ショッピングアプリ「BASE ( https://thebase.in/sp )」のプロダクトチームによるブログです。

GitHub Codespaces環境でDenoを使ってSlack Botを作ってみよう!(Slack Bot開発編)

f:id:gatchan0807:20211210003932p:plain

この記事はBASE Advent Calendar 2021の12日目の記事です

devblog.thebase.in

ごあいさつ

はじめましての人ははじめまして、こんにちは!フロントエンドエンジニアのがっちゃん( @gatchan0807 )です。

この記事はDenoの公式Docsを読んでみた前編の続きで、前回得た知識を使って実際にDeno + GitHub Codespaces環境でSlack Botを開発してみよう!の回になります。

もしDenoについて詳しく知りたい方は前回の記事からご覧いただけますと幸いです!🙏

どんなSlack Botを作るのか

まず完成品のイメージを共有してから実際に実装する内容に触れていきます。

少し話は脱線するのですが、BASEではコミュニケーションツールにSlackを使っており、その中で独自のSlack Appを作成してChatOpsを実現しています。

主だったところで言うと、STGとは別に複数用意されている検証環境のデプロイや起動、特定の開発中ブランチの適用などをエンジニアはもちろん、PMの方々も日常的に行っています。

下記はあくまでイメージですが、下記のようなコマンド的なテキストをBotにメンションして、デプロイを行ったりしています。

@dev-bot deploy repository inspection1 feat/something-branch-name

詳しくは、以前投稿されたこちらの記事をご覧ください。

BASEにおけるSlack活用術を大公開!〜Slackで始める業務改善〜 - BASEプロダクトチームブログ

そういった環境があるため、Slack Botを使ったコマンドの実行に一定のリテラシがある前提で下記のようなサブコマンド付きのSlack Botを作成していきます。

作る予定のBotとそのサブコマンドたち

今回作成するのはご覧の通り、登録されたメンバーの一覧を色んなパターンでランダムにごにょごにょするBotです!

@random-bot [command] [group] [member-name]

~~~~~~~ 便利コマンド系 ~~~~~~~
@random-bot (help|h) 👉 ヘルプの表示
@random-bot ping 👉 起動確認
~~~~~~~ データ登録・削除系 ~~~~~~~
@random-bot create [group-name] 👉 グループ(メンバーを追加する枠)の作成
@random-bot (disband|delete-group|remove-group) [group-name] 👉 グループ(メンバーを追加する枠)の解散
@random-bot list [group-name] 👉 グループ内のメンバー一覧の表示
@random-bot add [group-name] [member-name] 👉 グループにメンバーの追加(メンバー名はテキストもメンションもどちらも登録可能です)
@random-bot (delete|remove) [group-name] [member-name] 👉 グループからメンバーの削除
~~~~~~~ シャッフル実行系 ~~~~~~~
@random-bot random-sort [group-name] 👉 グループ内のメンバー一覧をシャッフルして並び替え
@random-bot pick [group-name] [number] 👉 グループ内から指定の人数をランダムでピックアップする
@random-bot separate [group-name] [divide-count] [merge-option] 👉 グループ内のメンバーを指定の人数ごとにグループ分けする

BASEのSlackで下記のようなユースケースを観測していたので、それらのニーズにマッチしたシャッフルのパターンをいくつか用意しています。

デイリーの今日の一言でのランダム化のニーズ

自分が今所属しているプロジェクトチームでは、デイリーで今日の一言とその日やっていることを共有する時間を取ってコミュニケーションと進捗共有を行っています。

その発表順が固定化されないように、スッキリすの運勢ランキングが一番良い人からやってみたり、順番ぎめ.comを使ってランダムに並び替えたりしてみていました。

@random-bot random-sort [group-name] はこのユースケースを満たすために実装していきます。

peer1on1でのランダム化のニーズ

続いて、こちらも自分が所属しているフロントエンドのチームではpeer 1on1という制度を運用しており、ランダムに選ばれた2〜3人で雑談をする時間を週に1回取っています。

peer 1on1の詳しい解説はこちらも過去投稿されたこちらの記事に譲りますが、現在もGASで作られたスプレッドシート上で管理されています。

そのシャッフル、本当にシャッフルですか?何気ない落とし穴にハマった話 - BASEプロダクトチームブログ

@random-bot separate [group-name] [per-number] はこのユースケースを満たすために実装していきます。

読書会の司会選出でのランダム化のニーズ

最後に、別のチームで定期的に行われている読書会の司会選出のニーズです。 @random-bot pick [group-name] [number] はこのニーズを満たすために実装していきます。

f:id:gatchan0807:20211210003929p:plain

こちらは標準搭載されているSlackのカスタムレスポンス機能を利用して、(Slackbotのレスポンスを複数パターン登録して)ランダム機能が実現されています。(これを初めて見た時、正直めっちゃ賢いな…!って思いました) ただ、この方法だと想定外のチャンネルで反応してしまうことがあるので、指定するキーワードには注意ですね⚠

以上のようなニーズがあったので、今回のSlack Bot作る題材にちょうど良いや!と思ったのが作ろうと思った理由です。

さらに、Slackのリマインダー機能と組み合わせて、決まった時間に自動的にシャッフルした結果を出してくれるようになれば手間も減るので良さそうです🙆

実際にDeno環境でSlack Botを作っていく

前回の記事の最後で、Slack社が発表したDenoで提供されるSlack Appの次世代開発プラットフォームのベータプラットフォームに参加できればそちらで開発する!と言っていたのですが、残念ながらベータプラットフォームへの参加は叶わなかったので、有志がNode.js用SlackライブラリをDeno向けに書き換えて提供してくれているライブラリの方を利用していきます。

前回の記事がほとんどの項目をStep by Stepで書いたためにめちゃ長記事になってしまったのを反省したので、詳細は公式ドキュメントに譲り、ハマりどころとポイントをピックアップして書いていくことにします!

また、今回のコードの全容はこちらのGitHubに公開しているので、もしご興味あればご覧ください👀 コミットメッセージ込みで試行錯誤の履歴が残っていますし、記事公開後もちょこちょこリファクタしたりする予定です(PRやIssueも歓迎です🙆)

実際に作ってみた所感

実際に作ってみてどうだったのよ?という感想を見に来られている方もいらっしゃるかと思うので、まず最初にそちらを書いておきます。

  • DenoでのSlack Botの開発はロジック部分は基本的にはNode.js上の開発体験とほぼ変わらない。でも、周辺知識の入れ直しで大変。
  • まだまだエコシステムもライブラリも発展途上のため、詰まった時に情報がなくライブラリの中を読みに行って自力でデバッグになるのは大変。
    • 逆にライブラリたちがURL管理なのでライブラリを読みに行く労力は少し減っている部分はある。
  • Deno Deployはまだまだベータ版。すぐに対応できなさそうな問題にぶち当たったので代替としてCloud Runを使った。
    • 今回根幹として使っていたライブラリがDeno Deploy実行環境から提供されていないAPIを使っていて死ぬという事があった😢
  • GitHub Codespaces上でSlack Botを開発するのは、体験が良い面もあれば微妙な面もあった。
    • 良い面👍: ポートフォワーディング機能で簡単にHTTPSのURLを公開できるので、SlackのWebHookやEvents APIの指定がngrokとか用意しなくていいのめちゃ楽。
    • 良い面👍: 手元のPCのVSCodeからも、iPadのブラウザ上からも同じ環境を見れるので出先でちょこっと直しやすかった。
    • 微妙な面🤔: ポートフォワーディングのタイムラグが体感1分ぐらいあるので、再起動時とかはSlack Bot側の反応が全くなくなってどこまでデータが届いているのか分からずにちょくちょく集中力が切れる。
    • 微妙な面🤔: Denoのライブラリキャッシュ取得後の反映に微妙にラグがあって何度かファイルの読み込み直しやVSCode/ブラウザの再起動が必要だった。

今回公式ドキュメントから仕入れた知識のみで開発することで、開発自体は問題なく行えるものの所々辛い部分を見てきたので、次回以降は aleph.jspackupVelociraptor 等のDeno周辺ツール群も組み合わせて実装を進めてみたいなと思いました🙆

当初の予定と実際のギャップ

合わせて、実際に作ってみてわかった、初期の自分の中での期待とギャップがあった部分(主に詰まった部分)もまとめていきます。

  • Deno Deployはimport-map機能にまだ対応していなかった。
  • Deno Deployで提供されていないAPIを使っているライブラリがあるとデプロイ時に???となる。
  • 利用したライブラリの型情報が古く、 --no-check と一部 any を使ってしまった。

Deno Deployはimport-map機能にまだ対応していなかった

これは先に調べておけば…。という話ではあるのですが、現時点ではDeno Deployは import-map には対応しておらず、基本的に deps.ts のパターンで実装する必要があります。 https://deno.com/deploy/docs/runtime-api#future-support

ある程度、実装を進めたタイミングでデプロイしようとしてこれに気づいたため、完全に「にゃーん」となりました🐱

とはいえ、そこまで労力はかからないのでササッと deps.ts パターンに書き換えたのですが、再度デプロイしたところ次の問題が襲ってきました。

Deno Deployで提供されていないAPIを使っているライブラリがあるとデプロイ時に???となる

※あくまで状況証拠を並べてこれが原因っぽい。という推理のもと書いている点だけご容赦ください🙇

f:id:gatchan0807:20211210003926p:plain

先程の import-map 問題を対応して再度アプリケーションをデプロイしてアクセスしてみたところ、相変わらず502を吐いてページが見れませんでした。

ログを見ると上記のようなエラーを吐いてアプリケーションの起動に失敗していました。

該当ファイルの該当行を見にいってみたものの、エラーメッセージの reading 'deno' とは異なるデータを参照しているので違いそう…。 該当の 'deno' プロパティはファイルの中だと ${Deno.version.deno} しか使われていないけど、発生行数違うな…?

と思いながら、色々調べて改めて https://deno.com/deploy/docs/runtime-api を見たところ、

It's different from Deno but aims to have similar APIs where applicable.

の一文を見つけました👀

提供されている同様のAPIリストを見た感じ Deno.version の名前はない(あくまでDenoを模したランタイムなので提供されていない?)のと、上記の発生行が違う疑問は、実行時にコメントを削除されたファイルだとしたら計算が合う点から、slack_bolt ライブラリの依存先の slack_web_api ライブラリ内の参照が問題で落ちているのだ。と自分の中で結論づけました。

今回利用している slack_bolt ライブラリは今回のアプリケーションの根幹に置いていたものだったこともあって、ある程度実装した上でこれを外して再度実装し直すのは時間がかかりすぎるし、別のライブラリでうまくいく保証は無い。と考え、 結果としてはDeno Deployを利用するのを諦める形で意思決定しました

利用したライブラリの型情報が古く、 --no-check と一部 any を使ってしまった

今回利用した slack_bolt には一部依存ライブラリ側での型情報の融通が出来ていない(多分一部のライブラリのバージョンアップ忘れが原因)バグがあったため、 --no-check のフラグを付けて実行し、型情報を無視するようにました(ここ 見る限りv1.17.0からは --no-check=remote が使えて外部ライブラリだけ型チェック無視になるみたいなので、Dockerfileの指定ではすでにこっそりこれを指定して使ってみてる👀)

また、最終更新が10ヶ月前のライブラリだったため、現在のレスポンスには実際に返却されているデータが型情報に載っていなかったため、一度 any 化し、 as で型をつけ直すという対応を(型拡張するのをめんどくさがって)行いました。 https://github.com/gatchan0807/deno-slack-random-bot/blob/e6a655c74e25f8e765988e0f9c1f2ac1231737ad/src/app.ts#L21-L24

この辺りは次世代化が発表されたこのタイミングというのもあって、「既存の非公式ライブラリをメンテナンスするよりもSlack公式の次世代開発プラットフォームが全体に公開されるのを待つほうがコスパが良い」という思いになりPR作成は見送りましたが、本来であれば積極的にPR出して行きたいものですね👀

ざっくりとした実装の流れ

最後に、Slack Botが実際に動くようになるまでに行った対応の流れを紹介しておきます。

f:id:gatchan0807:20211210003924p:plain

実際に動いた!の図

Slack Boltの公式チュートリアルを参考に、Slack Appを作成開始する

slack.dev

基本的には各ステップのインポート部分の書き方を読み替えてステップを進めていく形で問題ないです🙆

Slackのワークスペースに関しては、権限があれば本番のワークスペースでやっても問題ないです。 ただ、自分はいきなり本番のワークスペースで試すのが怖いのと、管理者画面にどんな設定項目があるのかわからないと困ることがたまにあるので個人のワークスペースを遊び場として作成して、そこで実行できるかチェックしたりしています。

Codespaces環境内に環境変数を指定する

上記の公式チュートリアルの中で、環境変数にSlackのトークンを設定する作業がありますが、exportするだけではせっかく設定した環境変数がCodespacesが再起動されるたびに吹っ飛んでしまうので、提供されているCodespaces secretsの機能を使って環境変数を設定しましょう。

Codespaces の暗号化されたシークレットを管理する - GitHub Docs

下記のように設定できていればOKです🙆

f:id:gatchan0807:20211210003921p:plain

そして、設定した環境変数を適用するにはCodespaceの再起動が必要なので、下記の通りVSCodeコマンドパレットを使って停止→起動を行いましょう。

Using the Visual Studio Code Command Palette in Codespaces - GitHub Docs

再起動後、 $ export | grep SLACK_BOT_TOKEN / $ export | grep SLACK_SIGNING_SECRET を叩いてきちんと環境変数に設定されていることを確認しましょう。

その後、指定のファイルを deno run してみて、問題なく起動できればOKです🙆

Events APIを使ってメンション&サブコマンドの命令を受けられるようにする

上記のチュートリアルを通して、Codespaces上で起動している環境に「リクエストを受け取る→Slackにレスポンスを返す」ことが確認できたので、 app.message メソッドを使って、メンションとサブコマンドの認識ができるようにしていきます。

細かい説明は省いてしまいますが、 app.message は第一引数に正規表現を設定することで正規表現に当てはまるメッセージを検出した場合に、第二引数の関数を呼び出される仕組みになっています。(いわばRouterやEventListnerのようなもの)

そして、その関数の引数には各種リクエスト時に受け取ったtimestampやテキストの内容などと、レスポンス用の say 関数が詰まったオブジェクトが渡ってくるので、それらを利用してメンションから受け取ったデータの保存やメンションへの返事などを実装する形になります。

今回利用しているDeno用Bolt SDKの更新が10ヶ月前で止まっているためか、実際に返却されるオブジェクトと event に設定されている型情報が一致していなかったため、一度雑にany化してしまうというTypeScript的には禁忌なことをやってしまっていますが、本来は型情報を拡張して適用するようにしましょう🙏(時間があれば対応します…)

import { SubCommandPattern } from "./subcommands.ts";

/** ~~ 省略 ~~ **/
// グループの入れ物の作成コマンド
app.message(SubCommandPattern.create, async ({ event, say }) => {
  const _anyEvent = event as any;
  const text = _anyEvent.text as string;
  const user = _anyEvent.user as string;
  const [_botName, _subcommand, groupName] = text.split(" ");

  console.log("[INFO] Create: ", _anyEvent.text);

  await say(`<@${user}> 【 ${groupName} 】グループの作成が完了しました🎉`);
});
/** ~~ 省略 ~~ **/

この対応の全貌は↓のコミットに

https://github.com/gatchan0807/deno-slack-random-bot/commit/7e27039539013946be6663244ea4dd2762fe5b8d

データストアを構築する

ここまででSlack Botがコマンドを受け取って返信できるのを確認できたので、続いて作りたいコマンドを実現するために必要なデータの置き場所をつくります。

今回は本番では Deno Deploy Cloud Runを使う関係上、データストアを外に持つ必要があり、個人開発で使った経験があるFirebase(Firestore)を利用して行こうと思います。

Deno Deployの公式ドキュメントに似たような処理の書き方のチュートリアルがあるのですが、FirebaseのSDKバージョンがv8の頃の記法のまま(namespace形式)なので、基本的にはFirebaseの公式ドキュメントを参考にしながら、データ永続化処理を実装していきます。

firebase.google.com

この辺りを参考にしながら、Firestoreの環境を構築し、Firebase Configを取得して、Slackのトークンと同じように環境変数に設定しちゃいましょう。

f:id:gatchan0807:20211210003917p:plain

f:id:gatchan0807:20211210003913p:plain

FIREBASE_CONFIG は読み込み時に JSON.parse(Deno.env.get("FIREBASE_CONFIG")) のような形でparseされるので、JSONの記法に合わせてプロパティ名をダブルクォーテーションで囲むのと、改行の削除を行ったうえで環境変数に設定しています。

ライブラリを deps.ts に追加する

export { App } from "https://deno.land/x/slack_bolt@1.0.0/mod.ts";
export { initializeApp } from "https://cdn.skypack.dev/@firebase/app@v0.7.10?dts";
export * from "https://cdn.skypack.dev/@firebase/firestore@v3.4.0?dts";

Skypackを利用する場合、 ?dts を設定しないと返ってくるライブラリの型情報がなくなってしまうので、忘れずに設定しましょう。(流石にFirebaseのSDKで型情報がないのは辛い)

データ永続化の処理を実装する

/** ~~ 省略 ~~ **/
  console.log("[INFO] Execute add command:", _anyEvent.text);
  const docRef = doc(db, "groups", groupName);
  const docSnap = await getDoc(docRef);

  if (!docSnap.exists()) {
    console.info(`[INFO] The specified group name does not found.`);
    await say(`<@${user}> 【${groupName}】グループは登録リストに見つかりませんでした!`);
    return;
  }

  const groupRef = doc(collection(db, "groups"), groupName);
  const usersRef = collection(groupRef, "users");
  await addDoc(usersRef, {
    userName: targetUserName,
    timestamp: timestamp,
  });

  await say(
    `<@${user}> "${groupName}"グループに【${targetUserName}】を追加しました! Welcome.👏👏👏`,
  );
/** ~~ 省略 ~~ **/

この対応の全貌は↓のコミットに

https://github.com/gatchan0807/deno-slack-random-bot/commit/8281e1a8b1cda83f3828fdc4347001c8c0d8495d

ランダム処理を実装する

初期時点では、ランダム処理は雑にコピってきた arr.sort(() => Math.random() - 0.5); を使って実装していましたが、下記の記事で触れられている通り、ランダムといいつつある程度の偏りが発生してしまいます。

devblog.thebase.in

そのため、記事に習ってフィッシャーイェーツのアルゴリズムを拝借して実装し直しました🙆

一通り実装できたら、デプロイする

Deno Deployにデプロイするプランは残念ながら頓挫してしまったので、代わりにDockerに詰め込んでしまって、GCP上のCloud Runにデプロイするプランに切り替えました。

実際に処理を詰め込んだDockerfileは↓です

https://github.com/gatchan0807/deno-slack-random-bot/blob/main/Dockerfile

Cloud Runについての詳細な説明はこちらの紹介ページに譲りますが、今回のユースケースで利用量であればデイリーの無料枠で事足りるレベルなので、Firestoreの無料枠とも相まって基本無料でSlack Botを作成できる見込みです。

- 200 万リクエスト(1 か月あたり)
- 360,000 GB 秒のメモリ、180,000 vCPU 秒のコンピューティング時間
- 1 GB の北米からの下り(外向き)ネットワーク(1 か月あたり)

cloud.google.com

GitHubからの自動デプロイ設定はGUIからポチポチと

タイトルのとおりですが、Dockerfileをリポジトリに置いた上で、Cloud Runの設定(もしくはCloud Buildの設定)でGitHubにCloud Build Appのインストールを行えばほぼ自動でGitHubへのPushをトリガーにした自動デプロイ環境を用意できます。

cloud.google.com

Cloud Runの環境変数設定を忘れずに

実行環境の環境変数設定もGUIから実施できるので、設定を忘れずに。

忘れてしまうと 指定のポートでアプリケーション起動してないよ 的なエラーが発生してデプロイ失敗してしまいます🙏

cloud.google.com

デプロイが完了したら、再度Slack Botの設定画面に行ってRequest URLをCloud Runのサービスに紐付いたURLに書き換えてしまいましょう🙆

f:id:gatchan0807:20211210003911p:plain

Verifiedになれば、 @random-bot くんのバックエンドがCloud Runの本番環境に切り替わり、いつでもBotを利用できるようになっているはずです!

これにてSlack Bot完成!

まとめ

もし前回のDeno基礎知識 + 環境構築編からここまでお付き合い頂いた方がいらっしゃれば、長々とお読みいただいて本当にありがとうございました!!

Denoに対しての興味から調査を開始し、Slack Botの作成まで行なって記事にしてきましたが、久しぶりに知らないエコシステムをガッツリ触るということをやったので、とても楽しかったです🙆

全体的な感想としては、2年たったとはいえまだまだDenoも発展途上なんだな!という印象を受け、これから色々な進化を見せてくれるのを追うのが楽しみになりました👀 先にも上げたaleph.jsやpackupなどもこれから触りつつ、Node.jsエコシステムに比べてまだまだ参入余地が沢山あるDenoのコミュニティに機会があれば貢献していきたいなと思いました!

皆さまもぜひ、言語はJavaScript/TypeScriptで馴染みがあるけど、エコシステムは新しいが故にどことなく不思議な感覚になるDenoの世界に飛び込んでいただければと思います!

明日のアドベントカレンダーはBASE BANKのアプリケーションエンジニアの@glassmonekeyさんの記事です!お楽しみに!