Blog

Slackでスタンプラリーを行えるbotを作ってみた

こんにちは、新卒2年目のRyommです!

この記事は、ニフティグループ Advent Calendar 2022 24日目の記事です。
クリスマスイブです!今日はSexyZoneがデビューから11年目にして初めて京セラドームで単独ライブを行った記念すべき日ですね!私も今日は京セラドームに来ています!

今回はSexyZoneから受け取ったエネルギーで作ったスタンプラリーbotを紹介します!

はじめに

ニフティではクラウド・ゴールデン・ジム(以下CGG)というクラウド人材を育てるための社内勉強会を開催しています。
私はこのCGGの運営として、より多くの人に参加してもらうことで社内全体の技術力の底上げにつなげるべく、いくつか参加のモチベーションとなるような仕掛けを準備しました。
その一環として、ジムから連想してスタンプラリーを作ることにしました。

スタンプラリーの要件

スタンプラリーの要件は以下の通りです。
  • 勉強会への出欠と、勉強会で行うクイズの結果をもとにスタンプのランクが変わるようにしたい
  • 誰でも参照できて、誰がどんなスキルを持っているかの指標になるようにしたい
  • 簡単に参照できるようにするため、Slackでbotとして呼び出したい
  • @hogebot command [target] のような形式で問い合わせると、対象の画像が返却されてslack上のプレビューで見られるようにしたい
  • 管理画面を作る余力はないので、データの管理はGoogle SpreadSheetで行いたい

できたもの

helpコマンド
検索結果が1つだけのとき
検索結果が複数あるとき
検索結果に合致するデータがないとき
有効なコマンドがないとき

作ってみた

概要は以下の通りです。
  1. bot応答部分の骨組みを Lambda + API gateway + Slack App で作成
  2. 画像合成部分を Google SpreadSheet + GAS ( + S3 )で作成
  3. 合成した画像を S3 にアップロードしてURLを SpreadSheet に保持する部分を作成
  4. 作成したURLと名前などをセットにして DynamoDB にアップロードする部分を作成
  5. botが受け取った値を用いて DynamoDB を検索して該当のスタンプラリーカードURLを返却する部分を作成
構成図

1. bot応答部分の骨組みを Lambda + API gateway + Slack Appで作成

まずは基礎となるbot部分を作ります。
ここではSlack botにメンションをつけてメッセージを送ると、Lambda側でメッセージを受け取ることができ、メッセージに応じて何かしらの返信をするようにします。

Slack App作成

  • Permissionを設定する
    • app_mentions:read
      • メンションされたメッセージを読み込むために必要
    • channels:history
      • 公開チャンネルでメンションされたメッセージを読み込む
    • chat:write
      • チャットに書き込むために必要
    • im:history
      • DMのメッセージを読み込むために必要(現状実装していないので今は使っていない)
    • users:read
      • データ投入時に名前からslack名を取得するために必要
    • users:read:email
      • データ投入時にemailを使って照合するために必要
Scopesの設定
発行されたトークンを後述のlambdaの環境変数に設定するため、コピーしておきます。

API Gateway作成

slackAPIからContent-Typeapplication/x-www-form-urlencoded でイベントが送られてくるので、マッピングテンプレートを仕込んでおきます。
  1. 右上の統合リクエストを開く
    API gatewayの設定
  2. マッピングテンプレートのリクエスト本文のパススルーは「 リクエストの Content-Type ヘッダーに一致するテンプレートがない場合 」を選択し、 application/x-www-form-urlencoded マッピングテンプレートを作成し、こちらの記事にあるコードを貼り付けます。
    マッピングテンプレート設定

Lambda作成

ひとまず @cgg-stamp-rally hello とメッセージを送ると「何?」と返すようにします。
tokenなど機密情報はLambdaの環境変数に設定しています。
challenge
Slack Appで連携する際に、challengeパラメータを受け取って疎通確認を行います。
We’ll send HTTP POST requests to this URL when events occur. As soon as you enter a URL, we’ll send a request with a challenge  parameter, and your endpoint must respond with the challenge value. Learn more.

slack api

Slack App Event Subscriptions設定

  • Slack Appを開き、 Add features and functionality の Event Subscriptions の Request URL に先ほど作成したAPIのエンドポイントを貼り付けて疎通確認します。
  • Subscribe to bot eventsに app_mentionmessage.channels を設定します。
    • message.im は不要・・・
      Subcribe to bot events permission

疎通確認

ここまででSlackとの疎通ができるようになりました!
疎通確認

Lambdaを作り込む

最終的にコマンドで検索できるようにしたいので、検索ワードの抽出ができるようにしています。
また、helpコマンドと、指定のコマンドがない時はhelpコマンドを呼ぶように誘導するメッセージを出すようにしました。
botがメッセージを送るチャンネルも固定にしていたところを、メンションが呼び出されたチャンネルに投稿するように変更しました。

2. 画像合成部分をGoogle SpreadSheet+GAS(+S3)で作成

スタンプラリーは出欠と理解度確認クイズのスコアを基にスタンプの色を出し分けるようにしたいです。
そのため、それぞれの入力に対応してバッジをセットするGASを仕掛けてありますが、ここでは詳細説明を省略します。
画像合成は、大まかに以下の手順で進めます。
  1. ベースになるhtmlを作成
  2. 管理シートのデータを基に名前やスタンプ画像を埋め込む
  3. html2canvas を用いてcanvasに変換
  4. toDataURL("image/png") を用いてPNG画像に変換

管理シートを作成する

管理シートのカラムは以下の通りです。
  • name:実名
  • slack_name:slackの名前
  • image-url:合成した画像のURL
  • #0attend:#0(講義のナンバリング)の出欠
    • meetの出席レポートから入力する
  • #0score:#0のクイズのスコア
  • #0badge:#0attendと#0scoreの値を加味して振り分けられたバッジ

S3を作成し、CORS設定を行う

スタンプの画像はS3でホストしておきます。
社内だけでなくグループ会社の方も使う予定のため、画像が閲覧できるようにパブリックに公開できる設定にしています。また、独自ドメインをあえて設定するような用途ではないため、CloudFrontは挟んでいません。 次に、CORSに Access-Control-Allow-Origin を設定します。
CORS設定
curlで叩いてheaderにキチンと含まれているか確認します。
Access-Control-Allow-Origin: * にしてるので呼び出す側のoriginはなんでもいいです。

バッジ画像リスト

スプレッドシートに新しいシートを作成し、バッジのURLを参照できるようにしておきます。

メニューを作る

毎度GASを開いて実行するのは面倒なので、以下のようにワンクリックで呼び出せるように準備しておきます。
スプレッドシートのメニュー
スクリプトファイルを作成し、メニューを追加します。関数を作成したら順次こちらに追加していきます。

土台となるhtmlを作成

スタンプラリーの土台のhtml
ローカルでサクッと組みます。
<?=変数名 ?> とすることでHTML生成時に動的に値を埋め込むことができます。大体できたら、GASにindex.htmlで作成します。

GASでwebページをホストする

毎度デプロイで表示させてもいいですが、ちょっと面倒なのでスプレッドシートの右側に表示されるようにします。
先ほど作ったindex.htmlを基に HtmlService.createTemplateFromFile("index") でHtmlテンプレートオブジェクトを作成し、テンプレートの変数部分に値を挿入します。その後 evaluateメソッドを実行してHtmlOutputオブジェクトを生成します。
SpreadsheetApp.getUi().showSidebar(htmlOutput) でサイドバーに生成したHtmlOutputオブジェクトを表示させています。 HTMLサービスやClassUIについて詳しくは公式ドキュメントをお読みください。 引数の ccolumns は、1行分のデータが入った配列とカラム名です。
この関数を各行に対してfor文で回すことで一気に画像生成ができる算段です。
繰り返す時は処理中の行番号とslack名をPropertiesServiceに入れています。

画像に変換する

htmlは作成できたので、作成後 html2canvas を用いてcanvasに変換し、toDataURL() でpng形式に変換します。テンプレートのinde.htmlを編集し、以下のようなコードになっています。
  • html2canvas を使用して指定したオブジェクトをcanvasに変換
    • <script src="<https://cdn.jsdelivr.net/npm/html2canvas@1.0.0-rc.5/dist/html2canvas.min.js>"></script>
  • toDataURL を使ってcanvasをpng形式に変換
    • google.script.run.saveImage(png画像) は後述のS3に保存する場面で作成する関数
CORSについて
GASのhtmlに埋め込んだ画像はCORSに引っかかり、png変換時に出力されません。 その場合、まず画像URLのヘッダーにAccess-Control-Allow-Origin を設定します。
  • 自分でホストしている画像であれば、Access-Control-Allow-Origin: * をヘッダーに含める
  • Slackのプロフィール画像など、ヘッダーを弄れない場合は出力できないので諦める
また、html2canvas実行時の useCORS を有効にします。 さらに、HTML内の全てのimgタグcrossorigin="anonymous" を含めます。 ヘッダーに設定したAccess-Control-Allow-Origin が消滅するときは以下の現象が起きていると考えられます。私の場合、 crossorigin="anonymous" を全てのimgタグに設定するとエラーが消えました。
S3の画像URLを使用している場合、かつ、Chromeでブラウザのcacheが有効になっている場合、2度目のアクセス時にAccess-Control-Allow-Origin Headerの付いていないキャッシュしたレスポンスをChromeが返すためCORSエラーが発生してしまう模様

特定の条件でのみS3の画像URLでCORSエラーが発生する問題をなんとかする – Qiita

3. 合成した画像をS3にアップロードしてURLをSpreadSheetに保持する部分を作成

GASで作成した画像をDriveに保存してリンクを共有するとslack上で画像プレビューされないため、S3にアップロードします。

IAM

S3FullAccessを指定し、IAMロールを作成したらアクセスキーとシークレットキーはPropertyServiceに保存しておきます。

S3にアップロードするライブラリ追加

aws-sdk-js を使用するので、GASにライブラリを追加します。
  • スクリプトID
    • 1Qx-smYQLJ2B6ae7Pncbf_8QdFaNm0f-br4pbDg0DXsJ9mZJPdFcIEkw_
      GASのライブラリの追加
ただし、このライブラリは画像をアップロードする際に改造が必要になります。
GASプロジェクト内にファイルを新規作成し、それぞれ以下のファイルをGASプロジェクト内にコピペします。 コピペしたファイルを、以下の記事を参考に修正します。
https://note.com/marina1017/n/n431f0bb4e342

S3にアップロードし、アップロードした画像のURLをスプレッドシートに記録する関数作成

rowNum はfor文で createHtml_() を呼び出す際にPropertyServiceに保存した処理中の行番号です。 画像をBlobオブジェクトにする必要があるため、data URI schemeのメディアタイプとエンコード方式を削除してからデコードし、メディアタイプとファイル名を指定してBlobを作成しています。 これをhtml中から呼び出しています。
処理中の行番号とスプレッドシートで持っているslack名をPropertyServiceに入れたのは、saveImage()のようにhtmlテンプレート内部で呼び出す関数に変数を渡すのが大変なためです。

4. 作成したURLと名前などをセットにしてDynamoDBにアップロードする部分を作成

DynamoDBを使うためにライブラリ追加

GASからDynamoDBに直接データ投入を行いたいですが、認証を突破することが大変なので aws-apps-scripts を使います。メンテ状況を見て不安になりますが、動きました。
GASプロジェクト内にファイルを新規作成し、以下のaws.js内のコードをそのままコピペします。
https://github.com/smithy545/aws-apps-scripts/blob/master/aws.js

IAM

権限は以下の2つ
  • AmazonDynamoDBFullAccess
  • batch-submit-policy
アクセスキーとシークレットキーをGASのPropertyServiceに設定しておきます。

DynamoDBにデータを登録していく関数作成

DynamoDBを検索することを考えると同じ人のデータは更新していく方が望ましいため、updateメソッドを使います。
本来はDynamoDBに一括でアイテム登録した方がいいですが、ここではfor文で回しています。

5. botが受け取った値を用いてDynamoDBを検索して該当のスタンプラリーカードURLを返却する部分を作成

lambdaのbot部分に戻り、検索条件に合ったスタンプラリーのURLを返却するようにします。

検索条件に合致するスタンプラリー画像URLを探す関数作成

複数の検索ワードが渡された場合、AND検索するようにします。
検索結果が複数ある場合や、データが見つからなかったときは文言を変えています。
  • FilterExpression で条件を指定してscan
  • 結果がページネーションされている場合を考慮し、 LastEvaluatedKey でページを最後まで読み込む
最後にSlackから @cgg-stamp-rally ref hoge とコマンドを送り、対象のスタンプラリーが返ってくることを確認したら完成です!
@cgg-stamp-rally ref hoge

おわりに

スタンプラリー制作はGAS上で行う画像合成部分が一番大変でした。
かなり荒削りな実装ですが、CGG開催期間中のみ利用する想定なので今の状態で運用しています。
スタンプラリーはかなり反響が高く、さらに副次的な効果としてスタンプラリーきっかけで始めたクイズも理解度が上がると好評となっています。

このクイズは社内研修で行われたジョブローテーション先のサービスインフラチームの「伝授」の仕組みから着想を得て、勉強会に応用してみました。
CGGはグループを横断して行われる大規模な勉強会のため、テーマであるクラウドサービス以外にも様々な刺激を受けてもらえたらと考え試行錯誤しています。

今後の展望としては、まだまだ改善の余地はたくさんあるシステムなため開発環境の整備が完了次第、社内オープンソース化をして扱いやすくし、社内勉強会においてスタンプラリーが定着してくれたらいいなと考えています。

というわけで、以上がRyommサンタからのクリスマスプレゼントでした!
明日は、14kwさんのNotionのなんか書くです。お楽しみに!

We are hiring!

ニフティでは、さまざまなプロダクトへ挑戦するエンジニアを絶賛募集中です! ご興味のある方は以下の採用サイトよりお気軽にご連絡ください! Tech TalkやMeetUpも開催しております! こちらもお気軽にご応募ください!