こんにちは、新卒2年目のRyommです!
この記事は、ニフティグループ Advent Calendar 2022 24日目の記事です。
クリスマスイブです!今日はSexyZoneがデビューから11年目にして初めて京セラドームで単独ライブを行った記念すべき日ですね!私も今日は京セラドームに来ています!
今回はSexyZoneから受け取ったエネルギーで作ったスタンプラリーbotを紹介します!
はじめに
ニフティではクラウド・ゴールデン・ジム(以下CGG)というクラウド人材を育てるための社内勉強会を開催しています。
私はこのCGGの運営として、より多くの人に参加してもらうことで社内全体の技術力の底上げにつなげるべく、いくつか参加のモチベーションとなるような仕掛けを準備しました。
その一環として、ジムから連想してスタンプラリーを作ることにしました。
スタンプラリーの要件
スタンプラリーの要件は以下の通りです。
- 勉強会への出欠と、勉強会で行うクイズの結果をもとにスタンプのランクが変わるようにしたい
- 誰でも参照できて、誰がどんなスキルを持っているかの指標になるようにしたい
- 簡単に参照できるようにするため、Slackでbotとして呼び出したい
@hogebot command [target]
のような形式で問い合わせると、対象の画像が返却されてslack上のプレビューで見られるようにしたい- 管理画面を作る余力はないので、データの管理はGoogle SpreadSheetで行いたい
できたもの
作ってみた
概要は以下の通りです。
- bot応答部分の骨組みを Lambda + API gateway + Slack App で作成
- 画像合成部分を Google SpreadSheet + GAS ( + S3 )で作成
- 合成した画像を S3 にアップロードしてURLを SpreadSheet に保持する部分を作成
- 作成したURLと名前などをセットにして DynamoDB にアップロードする部分を作成
- 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を使って照合するために必要
- app_mentions:read
発行されたトークンを後述のlambdaの環境変数に設定するため、コピーしておきます。
API Gateway作成
slackAPIからContent-Type
が application/x-www-form-urlencoded
でイベントが送られてくるので、マッピングテンプレートを仕込んでおきます。
- 右上の統合リクエストを開く
- マッピングテンプレートのリクエスト本文のパススルーは「
リクエストの Content-Type ヘッダーに一致するテンプレートがない場合
」を選択し、application/x-www-form-urlencoded
マッピングテンプレートを作成し、こちらの記事にあるコードを貼り付けます。
Lambda作成
ひとまず @cgg-stamp-rally hello
とメッセージを送ると「何?」と返すようにします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
import json import os import logging import urllib.request logger = logging.getLogger() logger.setLevel(logging.INFO) def lambda_handler(data, context): # challenge # slack api との連携に必要 if ('challenge' in data): return { "statusCode": 200, "body": json.dumps({'challenge': data['challenge']}) } # slack event event = data["event"] # メンション時 if (event["type"] == "app_mention"): # helloコマンド if ("hello" in event["text"]): send_slack("何?") return { "statusCode": 200 } def send_slack(message): url = 'https://slack.com/api/chat.postMessage' token = os.environ['SLACK_TOKEN'] channel = "#specific_channnel" headers = { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json; charset=utf-8' } method = 'POST' data = { "channel": channel, "text": message } json_data = json.dumps(data).encode("utf-8") req = urllib.request.Request(url=url, data=json_data, headers=headers, method=method) res = urllib.request.urlopen(req, timeout=5) |
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_mention
、message.channels
を設定します。message.im
は不要・・・
疎通確認
ここまででSlackとの疎通ができるようになりました!
Lambdaを作り込む
最終的にコマンドで検索できるようにしたいので、検索ワードの抽出ができるようにしています。
また、helpコマンドと、指定のコマンドがない時はhelpコマンドを呼ぶように誘導するメッセージを出すようにしました。
botがメッセージを送るチャンネルも固定にしていたところを、メンションが呼び出されたチャンネルに投稿するように変更しました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 |
import json import os import logging import urllib.request logger = logging.getLogger() logger.setLevel(logging.INFO) def lambda_handler(data, context): # challenge # slack api との連携に必要 if ('challenge' in data): return { "statusCode": 200, "body": json.dumps({'challenge': data['challenge']}) } # slack event event = data["event"] # メンション時 if (event["type"] == "app_mention"): # helpコマンド if ("help" in event["text"]): msg = '''`@CGGスタンプラリー help`:このメッセージを表示する `@CGGスタンプラリー hello`:何?と返す `@CGGスタンプラリー data`: 渡ってくるデータを返す(開発用) `@CGGスタンプラリー ref [検索文字]`:氏名・slack名で検索(複数指定でAND検索) ''' send_slack(event["channel"], msg) # helloコマンド elif ("hello" in event["text"]): send_slack(event["channel"], "何?") # どんなデータが渡ってくるか確認するため elif("data" in event["text"]): send_slack(event["channel"], data) # refコマンド elif ("ref" in event["text"]): # 検索文字を抽出 search_words = event["text"].split() # ワードからbotメンションを削除 del search_words[0] # ワードからrefコマンド文字列を削除 search_words.remove("ref") send_slack(event["channel"], search_words) # その他のコマンド else: send_slack(event["channel"], "意味がわかりません!helpでコマンドを確認してください!") return { "statusCode": 200 } def send_slack(channel, message): url = 'https://slack.com/api/chat.postMessage' token = os.environ['SLACK_TOKEN'] headers = { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json; charset=utf-8' } method = 'POST' data = { "channel": channel, "text": message } json_data = json.dumps(data).encode("utf-8") req = urllib.request.Request(url=url, data=json_data, headers=headers, method=method) res = urllib.request.urlopen(req, timeout=5) |
2. 画像合成部分をGoogle SpreadSheet+GAS(+S3)で作成
スタンプラリーは出欠と理解度確認クイズのスコアを基にスタンプの色を出し分けるようにしたいです。
そのため、それぞれの入力に対応してバッジをセットするGASを仕掛けてありますが、ここでは詳細説明を省略します。
画像合成は、大まかに以下の手順で進めます。
- ベースになるhtmlを作成
- 管理シートのデータを基に名前やスタンプ画像を埋め込む
- html2canvas を用いてcanvasに変換
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
を設定します。
curlで叩いてheaderにキチンと含まれているか確認します。
※ Access-Control-Allow-Origin: *
にしてるので呼び出す側のoriginはなんでもいいです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
curl -i {S3のURL}/{画像名}.png -H "Origin:{GASの呼び出す側のURL}" --head HTTP/1.0 200 Connection established HTTP/1.1 200 OK . . . Access-Control-Allow-Origin: * Access-Control-Allow-Methods: GET, HEAD Access-Control-Expose-Headers: Access-Control-Allow-Origin . . . |
バッジ画像リスト
スプレッドシートに新しいシートを作成し、バッジのURLを参照できるようにしておきます。
メニューを作る
毎度GASを開いて実行するのは面倒なので、以下のようにワンクリックで呼び出せるように準備しておきます。
スクリプトファイルを作成し、メニューを追加します。関数を作成したら順次こちらに追加していきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// メニューに追加 function onOpen() { const sp = SpreadsheetApp.getActiveSpreadsheet() const myMenu = [ { name: 'メニューが動くか確認', functionName: 'testMenu_' } ] sp.addMenu('自動化ツール', myMenu) } function testMenu_() { Browser.msgBox('メニュー動く') } |
土台となるhtmlを作成
ローカルでサクッと組みます。
<?=変数名 ?>
とすることでHTML生成時に動的に値を埋め込むことができます。大体できたら、GASにindex.htmlで作成します。
GASでwebページをホストする
毎度デプロイで表示させてもいいですが、ちょっと面倒なのでスプレッドシートの右側に表示されるようにします。
先ほど作ったindex.htmlを基に HtmlService.createTemplateFromFile("index")
でHtmlテンプレートオブジェクトを作成し、テンプレートの変数部分に値を挿入します。その後 evaluateメソッドを実行してHtmlOutputオブジェクトを生成します。
SpreadsheetApp.getUi().showSidebar(htmlOutput)
でサイドバーに生成したHtmlOutputオブジェクトを表示させています。
HTMLサービスやClassUIについて詳しくは公式ドキュメントをお読みください。
- https://developers.google.com/apps-script/reference/base/ui
- https://developers.google.com/apps-script/reference/html/html-service.html
引数の c
とcolumns
は、1行分のデータが入った配列とカラム名です。
この関数を各行に対してfor文で回すことで一気に画像生成ができる算段です。
繰り返す時は処理中の行番号とslack名をPropertiesServiceに入れています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
function createHtml_(c, columns) { const html = HtmlService.createTemplateFromFile("index") // 各行の内容を取得してhtmlを生成 for (const [i, v] of columns.entries()){ Logger.log(v) switch (v) { case 'name': html.NAME = c[i] break case '#1badge': html.BADGE_1 = c[i] break case '#2badge': html.BADGE_2 = c[i] break case '#3badge': html.BADGE_3 = c[i] break . . . default: break } } const htmlOutput = html.evaluate() SpreadsheetApp.getUi().showSidebar(htmlOutput) } |
画像に変換する
htmlは作成できたので、作成後 html2canvas を用いてcanvasに変換し、toDataURL()
でpng形式に変換します。テンプレートのinde.htmlを編集し、以下のようなコードになっています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 |
<!DOCTYPE html> <html> <head> <script src="https://cdn.jsdelivr.net/npm/html2canvas@1.0.0-rc.5/dist/html2canvas.min.js"></script> <style> * { margin: 0; padding: 0; line-height: 1; font-size: inherit; font-weight: normal; } .wrapper { width: 400px; padding: 1em; background-color: #e2e5ea; } header { text-align: center; margin-bottom: 0.5em; } header h1 { font-weight: bold; line-height: 2em; } .slack-profile { margin: 0 auto; } .slack-profile h2 { font-size: 1.5em; line-height: 3em; text-align: center; word-break: keep-all; } .stamp-rally table { margin: 0 auto; border-spacing: 1em 2em; } .stamp-rally table td { position: relative; width: 80px; height: 80px; border-radius: 0.3em; background-color: #fff; } .stamp-rally table td span { position: absolute; top: 105%; left: 0; width: 100%; height: 1em; text-align: center; word-break: keep-all; font-size: 0.6em; color: #5e6062; } .stamp-rally table td img.badge { width: 80%; margin: 10%; } </style> </head> <body> <div class="wrapper" id="stampCard"> <header> <h1>CGG 2022 スタンプラリー</h1> </header> <main> <div class="slack-profile"> <h2><?=NAME ?></h2> </div> <div class="stamp-rally"> <table> <tr> <td> <img class="badge" src="<?=BADGE_1 ?>" crossorigin="anonymous" /> <span class="description">THE AWS</span> </td> <td> <img class="badge" src="<?=BADGE_2 ?>" crossorigin="anonymous" /> <span class="description">設計GD</span> </td> <td> <span class="description">フロントエンド</span> <img class="badge" src="<?=BADGE_3 ?>" crossorigin="anonymous" /> </td> <td> <img class="badge" src="<?=BADGE_4 ?>" crossorigin="anonymous" /> <span class="description">暴れん坊コンテナ</span></td> </tr> <tr> <td><img class="badge" src="<?=BADGE_5 ?>" crossorigin="anonymous" /><span class="description">サーバーレス</span></td> <td><img class="badge" src="<?=BADGE_6 ?>" crossorigin="anonymous" /><span class="description">CI/CD</span></td> <td> <img class="badge" src="<?=BADGE_7 ?>" crossorigin="anonymous" /> <span class="description">オブザーバビリティ</span> </td> </tr> <tr> <td> <img class="badge" src="<?=BADGE_gd ?>" crossorigin="anonymous" /> <span class="description">障害対応WS</span></td> <td><img class="badge" src="<?=BADGE_quiz ?>" crossorigin="anonymous" /><span class="description">トリビアクイズ</span></td> <td> <img class="badge" src="<?=BADGE_lt ?>" crossorigin="anonymous" /> <span class="description">LT</span></td> </tr> </table> </div> </main> </div> </body> <script> window.onload = function() { const stampCard = document.querySelector("#stampCard") html2canvas(stampCard, {scale:2, allowTaint: true, useCORS: true, taintTest: true, logging: true}).then((ele) => { google.script.run.saveImage(ele.toDataURL("image/png")) }) } </script> </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に保存する場面で作成する関数
1 2 3 4 5 6 7 8 9 |
<script> window.onload = function() { const stampCard = document.querySelector("#stampCard") html2canvas(stampCard, {scale:2, allowTaint: true, useCORS: true, taintTest: true, logging: true}).then((ele) => { google.script.run.saveImage(ele.toDataURL("image/png")) }) } </script> |
CORSについて
GASのhtmlに埋め込んだ画像はCORSに引っかかり、png変換時に出力されません。
その場合、まず画像URLのヘッダーにAccess-Control-Allow-Origin
を設定します。
- 自分でホストしている画像であれば、
Access-Control-Allow-Origin: *
をヘッダーに含める
- Slackのプロフィール画像など、ヘッダーを弄れない場合は出力できないので諦める
また、html2canvas実行時の useCORS
を有効にします。
1 2 3 |
html2canvas(stampCard, {scale:2, allowTaint: true, useCORS: true, taintTest: true, logging: true}).then((ele) => { google.script.run.saveImage(ele.toDataURL("image/png")) }) |
さらに、HTML内の全てのimgタグに crossorigin="anonymous"
を含めます。
1 2 3 4 5 |
<img class="badge" src="<?=BADGE_1 ?>" 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プロジェクト内にコピペします。
- https://github.com/eschultink/S3-for-Google-Apps-Script/blob/master/S3.gs
- https://github.com/eschultink/S3-for-Google-Apps-Script/blob/master/S3Request.gs
コピペしたファイルを、以下の記事を参考に修正します。
https://note.com/marina1017/n/n431f0bb4e342
S3にアップロードし、アップロードした画像のURLをスプレッドシートに記録する関数作成
rowNum
はfor文で createHtml_()
を呼び出す際にPropertyServiceに保存した処理中の行番号です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
function saveImage(img) { // 情報を格納する行 const row = PropertiesService.getDocumentProperties().getProperty("rowNum") // 作成した画像のアップロード先S3の設定 const S3_ACCESS_KEY = PropertiesService.getScriptProperties().getProperty("S3_ACCESS_KEY") const S3_SECRET_ACCESS_KEY = PropertiesService.getScriptProperties().getProperty("S3_SECRET_ACCESS_KEY") const s3 = getInstance(S3_ACCESS_KEY, S3_SECRET_ACCESS_KEY) const S3_BUCKET_NAME = "cgg-stamp-rally" // ファイル名を組み立てる const today = new Date() const name = PropertiesService.getDocumentProperties().getProperty("slackName").trim() const filename = `${("0" + today.getFullYear()).slice(-2)}${("0" + (today.getMonth() + 1)).slice(-2)}${("0" + today.getDate()).slice(-2)}${("0" + today.getHours()).slice(-2)}${("0" + today.getMinutes()).slice(-2)}-${name}` const a = img.replace('data:image/png;base64,', '') const decodedImg = Utilities.base64Decode(a) const imgblob = Utilities.newBlob(decodedImg, "image/png", `${filename}.png`) s3.putObject(S3_BUCKET_NAME, `${filename}`, imgblob, {logRequests: true}) const url = `{S3のURL}/${filename}` const SPREADSHEET_ID = PropertiesService.getScriptProperties().getProperty('SPREADSHEET_ID') const s = SpreadsheetApp.openById(SPREADSHEET_ID).getSheetByName('2022'); s.getRange(Number(row), 4).setValue(url) } |
画像をBlobオブジェクトにする必要があるため、data URI schemeのメディアタイプとエンコード方式を削除してからデコードし、メディアタイプとファイル名を指定してBlobを作成しています。
1 2 3 |
const a = img.replace('data:image/png;base64,', '') const decodedImg = Utilities.base64Decode(a) const imgblob = Utilities.newBlob(decodedImg, "image/png", `${filename}.png`) |
これを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文で回しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
function updateDynamodb_() { const DYNAMODB_ACCESS_KEY = PropertiesService.getScriptProperties().getProperty('DYNAMODB_ACCESS_KEY') const DYNAMODB_SECRET_ACCESS_KEY = PropertiesService.getScriptProperties().getProperty('DYNAMODB_SECRET_ACCESS_KEY') AWS.init(DYNAMODB_ACCESS_KEY, DYNAMODB_SECRET_ACCESS_KEY) const SPREADSHEET_ID = PropertiesService.getScriptProperties().getProperty('SPREADSHEET_ID') const s = SpreadsheetApp.openById(SPREADSHEET_ID).getSheetByName('2022'); const data = s.getRange(2, 1, s.getLastRow(), s.getLastColumn()).getValues() const table = "cgg-stamp-rally" for (const [i, c] of data.entries()) { if (c[1]) { const item = { slackName: {S: c[1]}, name:{S: c[0]}, stampCardUrl: {S: c[3]} } const res = AWS.request( 'dynamodb', 'ap-northeast-1', 'DynamoDB_20120810.UpdateItem', {}, 'POST', { TableName: table, Item: item, Key: { 'slackName': item.slackName }, ExpressionAttributeNames: { '#n': 'name', '#url': 'stampCardUrl' }, ExpressionAttributeValues: { ':newName': item.name, ':newUrl': item.stampCardUrl }, UpdateExpression: 'SET #n = :newName, #url = :newUrl' }, { 'Content-Type': 'application/x-amz-json-1.0' }, ) const code = res.getResponseCode() const text = res.getContentText() if (code < 200 || code >= 300) throw Error(`AWS.request failed: ${code} - ${text}`) Logger.log(`OK: ${table} - ${JSON.stringify(item)}`) } } } |
5. botが受け取った値を用いてDynamoDBを検索して該当のスタンプラリーカードURLを返却する部分を作成
lambdaのbot部分に戻り、検索条件に合ったスタンプラリーのURLを返却するようにします。
検索条件に合致するスタンプラリー画像URLを探す関数作成
複数の検索ワードが渡された場合、AND検索するようにします。
検索結果が複数ある場合や、データが見つからなかったときは文言を変えています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
def get_card(targets): dynamodb = boto3.resource('dynamodb') table = dynamodb.Table('cgg-stamp-rally') # 検索文字列をFilterExpressionに指定できる形に整形 fe = None for target in targets: if fe is None: fe = Attr('slackName').contains(target) | \ Attr('name').contains(target) else: fe = fe & Attr('slackName').contains(target) | \ Attr('name').contains(target) res = table.scan( FilterExpression = fe ) items = res['Items'] # 最後まで読み込む while 'LastEvaluatedKey' in res: res = table.scan( FilterExpression = fe, ExclusiveStartKey=resp['LastEvaluatedKey'] ) items.extend(res['Items']) if len(items) == 0: return '条件に合致するデータがありません' # 同姓同名の場合を考慮 if len(items) > 1: prospective_targets = [] for item in items: prospective_targets.append(item['slackName']+':'+item['stampCardUrl']) return "対象ユーザーの候補が複数あります\n" + "\n".join(prospective_targets) target_card = [] for item in items: target_card.append(item['stampCardUrl']) return "\n".join(target_card) |
FilterExpression
で条件を指定してscan- 結果がページネーションされている場合を考慮し、
LastEvaluatedKey
でページを最後まで読み込む
最後にSlackから @cgg-stamp-rally ref hoge
とコマンドを送り、対象のスタンプラリーが返ってくることを確認したら完成です!
おわりに
スタンプラリー制作はGAS上で行う画像合成部分が一番大変でした。
かなり荒削りな実装ですが、CGG開催期間中のみ利用する想定なので今の状態で運用しています。
スタンプラリーはかなり反響が高く、さらに副次的な効果としてスタンプラリーきっかけで始めたクイズも理解度が上がると好評となっています。
このクイズは社内研修で行われたジョブローテーション先のサービスインフラチームの「伝授」の仕組みから着想を得て、勉強会に応用してみました。
CGGはグループを横断して行われる大規模な勉強会のため、テーマであるクラウドサービス以外にも様々な刺激を受けてもらえたらと考え試行錯誤しています。
今後の展望としては、まだまだ改善の余地はたくさんあるシステムなため開発環境の整備が完了次第、社内オープンソース化をして扱いやすくし、社内勉強会においてスタンプラリーが定着してくれたらいいなと考えています。
というわけで、以上がRyommサンタからのクリスマスプレゼントでした!
明日は、14kwさんのNotionのなんか書くです。お楽しみに!