DynamoDB
lambda
Slack
slackbot

Slackで動作するピアボーナス機能を3日で実装、導入した話

この投稿はLancers(ランサーズ) Advent Calendar 2018の6日目の記事です。

昨日はyKanazawaによるランサーズ版SQLチューニングポリシーという記事でした。

今日は、ランサーズ内にNas!というピアボーナスサービスを導入した話を書いていきます。

ピアボーナスとは?

ピアボーナスとは「peer(仲間)」と「bonus(報酬)」が合わさった言葉で、従業員同士がお互いに報酬や感謝を贈り合うことができる仕組みのことをいいます。

導入することで従業員同士のコミュニケーションが活発化したり、社内の良い出来事が見えやすくなるなどのメリットがあります。

導入して数日ですが、以前に比べて積極的に褒め合う空気が生まれてきたかなと感じています。

実装内容

ランサーズではSlackをメインのチャットツールとして使用しているので、そこで動作するピアボーナス機能を実装しました。

このサービスはNAS!という名前で、開発部のメンバーがつけてくれました。おそらくピアボーナスから連想してつけた名前ですが、しょうもなさが良くて、気に入っています。tipの単位もnasにしています。

アイコンも社内の方に書いていただきました。かわいい。
image.png

導入した機能

nasを送りたい時

メッセージをつけて送りたい時
/コマンド @nasを送りたい相手 相手へのメッセージ
Slack ApiのSlash commandを用いています。相手へのメッセージは専用の部屋を作り、そこでメンションつきで返答されるような設計になっています。
image.png
image.png

スタンプから送りたい時
相手のメッセージに対して:eggplant:スタンプを送ると、nasが送られます。 botや自分に対しては送っても反映されません。自分に押したときは自戒の念も込めて以下の内容を返しています。
image.png

自分の残りnasを確認したいとき

無制限にボーナスを送れるような設計では、ありがたみが薄れてしまうので制限を設けています。nasは一週間ごとにリセットされるようにしています。

残りnasは自分にしか見えないので、どこで確認しても大丈夫です。
現在はテスト期間なので、上限を1000に設定しています。
image.png

その週のランキングを確認したいとき

その週のnasを送られた数のランキングを確認することもできます。

例によって、このコマンドも自分にしか見えないので、どの部屋に送っても大丈夫な設計にしています。またコマンドでの実行とは別にCloudWatch Eventを用いたcronを設定し、毎週金曜日に自動でその週のランキングを返してくれるようにしています。

構成概要

今回の実装はAWS Lambdaを用いて行いました。構成図は以下のようになります。
nas_architecture (1).png

Lambda+API Gateway+DynamoDBというよくある構成です。

またCloudWatch Eventを用いたCron式を導入して、毎週金曜日にその週のランキングをSlackに返してくれるような機能も実装しています。

管理画面のかわりとしてre:dashを採用しています。DynamoDBとre:dashの親和性が高く、DQLというDynamoDB用のSQL文で簡単にデータを抽出できるので、下手に管理画面を作るよりよっぽどいいものになります。

ぶっちゃけ言えば、管理画面作るのがめんどくさかっただけですが、外部ツールを使用した方が保守性が高く、拡張性も広いのでアリだと思っています。

開発よもやま

一番苦労したのはDBの設計でした。DynamoDBは、AWSでサーバレスなシステムを構築する際にデータベース層に採用されることが多いですが、DynamoDBはNoSQL型のDBなので、RDBMSのように必要に応じてデータモデルを拡張し、対応することが難しいです。設計段階で適切なPrimaryKeyやSorted Keyを設定することが重要になると感じました。

最終的な設計としては、PrimaryKeyがnasを送ったユーザのID, SortedKeyをnasが送られた時間のタイムスタンプにすることで要件を満たせるような設計にできました。この設計だとデータ量が増えても問題なくワークすると思います。

またデータ抽出時の力技として、scanで全データを持ってきてから、条件の指定して目的のデータ群を抽出することもできます。

今後、DynamoDBを設計するときは、最初に要件を洗い出して、丁寧に構成を検討してから実装した方が少ない工数で実装できると思います。(見えない要求、要件に対しても柔軟に対応できるような設計だとなお良しですが、かなりの修練が必要になると思います、、、)

あとSlack側から送られてくる情報が思ったよりも少ないので、SlackのApiを使いながら足りない情報を補填する部分の実装にも四苦八苦しました。

tips

Slackでその人にしか見えない投稿を送る方法

nasスタンプを送った時のリプライや残りのnasを確認するためのメッセージなどがパプリックなチャンネル内に表示されるのはかなり迷惑なので、その人にしか見えないメッセージで返しています。

image.png

これはSlack Apiを用いて実装しています。該当のドキュメントはここです

以下が実際のコードです。送りたいメッセージとチャンネル、対象のユーザを選択してpostします。

import json
import os
import requests

# その人だけにしか見えないメンションを返す
def post_personal_message_to_slack(message: str, channel: str, user: str):
    # Slackのchat.postMessage APIを利用して投稿する
    # ヘッダーにはコンテンツタイプとボット認証トークンを付与する
    url = "https://slack.com/api/chat.postEphemeral"
    headers = {
        "Content-Type": "application/json; charset=UTF-8",
        "Authorization": "Bearer {0}".format(os.environ["SLACK_BOT_USER_ACCESS_TOKEN"])
    }
    data = {
        "token": os.environ["SLACK_OAUTH_ACCESS_TOKEN"],
        "channel": channel,
        "text": message,
        "user": user
    }

    res = requests.post(url, data=json.dumps(data).encode("utf-8"), headers=headers)

    return

DynamoDBから任意のデータを持ってくる。

DynamoDBから条件指定して任意のデータ群を取得する方法を載せときます。
GSIやLSIを用いる方法もありますが、今回はscanしてから条件を指定してやる手法を用いています。

GSIは5件までしか登録できませんが、この方法だとその制約がありません。データ量によっては遅くなることもあるかもしれませんが、大抵の場合これでも十分ワークすると思います。

この以下のコードでは、基準となるref_time_stampより大きな値を持つデータを取得しています。

import boto3
from boto3.dynamodb.conditions import Key

dynamoDB = boto3.resource("dynamodb")
table = dynamoDB.Table("DynamoDBのテーブル名") 

nas_data = table.scan(
    FilterExpression=Key('time_stamp').gt(ref_time_stamp),
)

この他にも色々な検索条件があります。詳細はこのドキュメントに書いてあります。

Slackで自作コマンドを導入する

Lamdba+Api Gatewayを用いてSlackに自作のコマンドを導入する方法です。

Lambda関数はあらかじめ作成されているものとします。

Api Gatewayにpostメソッドを登録してやり、作成したLambda関数を割り当ててやります。
image.png

Slack Apiから新規アプリを作成し、Slash commandを登録します。
image.png

Slackから送られてくるデータ型はapplication/x-www-form-urlencodedであるので、Api Gatewayの統合リクエストでデータ型を設定してやります。

統合リクエストのマッピングテンプレートでapplication/x-www-form-urlencodedを登録してやります。中身は既存であるやつを選ぶか、自分で定義してやればOKだとおもいます。
image.png

設定ができたらデプロイしてやればOKです。

またコマンドが実行された際にSlackから送られてくる内容は以下です。

{'body': 'token=XXXXXXXXXXXX&team_id=XXXXXXXXXXXX&team_domain=XXXXXXXXXXXX&channel_id=XXXXXXXXXXXX&channel_name=XXXXXXXXXXXX&user_id=コマンドを実行したユーザのid&user_name=コマンドを実行したユーザの名前&command=/実行されたコマンド&text=コマンドと一緒に入力されたテキスト&response_url=XXXXXXXXXXXXXXX&trigger_id=XXXXXXXXXXXXXXX'}

正直もうちょっと使いやすい形のデータで送ってほしかったです。
これを使いやすい形に補正するためのコードが以下です。

        body = event['body'].split('&')
        for param in body:
            sp_place = param.find('=')
            key = param[:sp_place]
            value = param[sp_place+1:]
            request[key] = value

あとは個々人の環境で欲しいデータを抜き出してやればいいと思います。

導入効果

個々人の細かい動きが今までよりも可視化されたように感じます。

メッセージ付きのnasを流している部屋のスクショ
image.png

あとnasをもらえるとかなり嬉しいので、業務のモチベーションもアップするなぁと感じました。

今後の展望

社内の定例会でnasランキングに応じた褒賞を送る制度を本格的に導入していって、積極的に褒め合う文化が生まれていったらいいなと思います。

また、今回実装したピアボーナス機能はオープンソース化させるかもしれないので、この記事を見ているみなさんにも使っていただけたら嬉しいとおもっています。

まとめ

今回の実装はテスト段階の実装までをほぼ3日で完了させることができました。
かなりのスピード感で実装できたと感じており、今後も継続させたいと思っています。

以上、Lancers(ランサーズ) Advent Calendar 2018 6日目の記事でした。
明日は yKanazawa さんの記事です。お楽しみに!