Slack(Slash Commands) + Lambdaで工数管理

こんにちは。katoです。

今回はSlackのSlash Commandsとlambdaを連携して、工数管理の仕組みを作り上げていきたいと思います。

 

概要

今回行う工数管理の流れは下記の通りとなっています。

Slash Commands → (API Gateway) → Lambda → DynamoDB

SlackからLambdaに向けてタスクをpushし、DynamoDBにタスクを登録していくという形です。
構成としてはシンプルで、Slack以外はAWSのサービスのみで実現できるので簡単に導入できます。

業務を始める際にタスク登録(コマンド一つ)をSlackで打ち込むだけなので、操作はとても簡単です。

 

Slack設定

それでは実際に設定を行っていきます。

まずはじめに、SlackでSlash Commandsの設定を行います。
「Channel Settings」から「Add an app」を選択します。

 

 

アプリケーションの選択画面が表示されるので、検索ボックスに「Slash」と入力し、表示された「Slash Commands」を選択します。

 

 

「Add Configuration」をクリックし、「Choose a Command」の箇所にコマンドを入力します。
ここで指定したコマンドがタスクを登録する際に使用するコマンドとなります。
コマンド予測に出力されないのでなるべく短いコマンドにすることをお勧めします。
なお、コマンドはスラッシュから始まるよう入力してください(例 /put_task)。

コマンドを入力したら「Add  Slash Command Integration」をクリックします。
コマンドの設定画面が表示されるので、「Token」に表示されている文字列を記録しておいてください。
最初の設定はここまでで、残りはAPI Gatewayの設定後に行います。

 

DynamoDB設定

次にDynamoDBの設定を行っていきます。
今回のDynamoDBはタスクを登録していくだけなので難しい設定はありません。

AWSのマネジメントコンソールを開き、DynamoDBのサービスページからテーブルの作成を行います。

 

 

設定に関しては、「パーティションキー」と「ソートキー」は上図の通り、「user」、「time」として下さい。
テーブル名は自由に設定して問題ありません。

上記設定後、「作成」をクリックすればDynamoDBの設定は完了となります。

 

Lambda設定

今回作成するLambda関数は2つになります。

1つ目の関数は、Slackから情報を受け取り、DynamoDBに登録するという処理を行います。

 

import boto3
import json
import logging
import os
import datetime

from base64 import b64decode
from urlparse import parse_qs


ENCRYPTED_EXPECTED_TOKEN = os.environ['kmsEncryptedToken']

##TokenをKMSによって暗号化する場合はコメントアウトを外してください。
#kms = boto3.client('kms')
#ENCRYPTED_EXPECTED_TOKEN = kms.decrypt(CiphertextBlob=b64decode(ENCRYPTED_EXPECTED_TOKEN))['Plaintext']

logger = logging.getLogger()
logger.setLevel(logging.INFO)


def respond(params, err, res=None):
    task = params['text'][0]
    user = params['user_name'][0]
    command = params['command'][0]
    dynamo = boto3.resource('dynamodb')
##先程作成したDynamoDBのテーブル名を指定します。
    table = dynamo.Table('slack_job')
    start = datetime.datetime.now() + datetime.timedelta(hours=9)
    start = start.strftime('%Y-%m-%d %H:%M:%S')
    table.put_item(
        Item={
                'user': user,
                'time': start,
                'task': task
        }
    )
    return {
        'text': "Input: " + command + " " + task
    }

def lambda_handler(event, context):
    params = parse_qs(event['body'])
    token = params['token'][0]
    if token != ENCRYPTED_EXPECTED_TOKEN:
        logger.error("Request token (%s) does not match expected", token)
        return respond(Exception('Invalid request token'))
    return respond(params, None)

 

この関数はSlackから呼び出す必要があるので、「slack-echo-command-python」の設計図から作成します。

 

「slack-echo-command-python」を選択すると、api-gatewayの設定項目があるので、セキュリティを「オープン」に設定します。

 

 

あとは先程のコードを書き込むだけですが、環境変数を利用しているので、こちらの設定を忘れずに行います。
ここで指定しているのは、Slash Commandsの設定時に表示された「Token」の文字列になります。
今回はKMSでの暗号化を省略していますので、表示された文字列を環境変数の箇所に直接入力しています。

 

 

これでタスク登録用の関数は完成となりますが、トリガーとなるAPI Gatewayの設定が残っていますので、後程設定していきます。

2つ目の関数は、DynamoDBから登録情報を読み込み、日次の集計をSlackに通知するという処理を行います。

 

#!/usr/bin/python
# coding: UTF-8

import boto3
import json
import datetime
import requests
import os

def lambda_handler(event, context):
    now = datetime.datetime.now() + datetime.timedelta(hours=9)
    nowdate = now.strftime('%Y-%m-%d')
    now = now.strftime('%Y-%m-%d %H:%M:%S')
    dynamo = boto3.resource('dynamodb')
##先程作成したDynamoDBのテーブル名を指定します。
    table = dynamo.Table('slack_job')
    items = table.scan()
    datas = items['Items']
    users = []
    for num in range(len(datas)):
        time = datas[num]['time']
        dates = datetime.datetime.strptime(time, '%Y-%m-%d %H:%M:%S')
        date = dates.strftime('%Y-%m-%d')
        if date == nowdate:
            users.append(datas[num]['user'])
            user = datas[num]['user']
            task = datas[num]['task']
            file = "/tmp/" + user + ".txt"
            text = str(time) + ";" + task + "\n"
            f = open(file, 'a')
            f.write(text)
            f.close()
        else:
            pass

    user = list(set(users))
    message = "daily_task_result\n"
    for num2 in range(len(user)):
        message = message + "\n[" + user[num2] + "]\n"
        file = "/tmp/" + user[num2] + ".txt"
        tasklist = []
        with open(file) as f:
            lines = f.readlines()
            for num3 in range(len(lines)):
                datas1 = lines[num3].split(";")
                count = 0
                strsum = "00:00:00"
                sumh = 0
                summ = 0
                sums = 0
                if datas1[1].replace("\n", "") in tasklist:
                    pass
                else:
                    for num4 in range(len(lines)):
                        checkdata1 = lines[num4].split(";")
                        if num3 == num4:
                            count += 1
                        elif num3 != num4 and datas1[1] == checkdata1[1]:
                            count += 1
                            if num4 != len(lines)-1:
                                checkdata2 = lines[num4+1].split(";")
                                checktime = datetime.datetime.strptime(checkdata2[0], '%Y-%m-%d %H:%M:%S') - datetime.datetime.strptime(checkdata1[0], '%Y-%m-%d %H:%M:%S')
                                sumtimes = str(checktime).split(":")
                                sumh = sumh + int(sumtimes[0])
                                summ = summ + int(sumtimes[1])
                                sums = sums + int(sumtimes[2])
                            else:
                                checktime = datetime.datetime.strptime(now, '%Y-%m-%d %H:%M:%S') - datetime.datetime.strptime(checkdata1[0], '%Y-%m-%d %H:%M:%S')
                                sumtimes = str(checktime).split(":")
                                sumh = sumh + int(sumtimes[0])
                                summ = summ + int(sumtimes[1])
                                sums = sums + int(sumtimes[2])
                    if datas1[1].replace("\n", "") == "task_end":
                        endtime = datetime.datetime.strptime(datas1[0], '%Y-%m-%d %H:%M:%S')
                        strend = endtime.strftime('%H:%M:%S')
                        message = message + "============\n終了時刻: " + strend + "\n"
                    elif num3 != len(lines)-1:
                        datas2 = lines[num3+1].split(";")
                        if count != 1:
                            worktime = (datetime.datetime.strptime(datas2[0], '%Y-%m-%d %H:%M:%S') + datetime.timedelta(hours=sumh) + datetime.timedelta(minutes=summ) + datetime.timedelta(seconds=sums)) - datetime.datetime.strptime(datas1[0], '%Y-%m-%d %H:%M:%S')
                            tasklist.append(datas1[1].replace("\n", ""))
                        else:
                            worktime = datetime.datetime.strptime(datas2[0], '%Y-%m-%d %H:%M:%S') - datetime.datetime.strptime(datas1[0], '%Y-%m-%d %H:%M:%S')
                        if num3 == 0:
                            starttime = datetime.datetime.strptime(datas1[0], '%Y-%m-%d %H:%M:%S')
                            strtime = starttime.strftime('%H:%M:%S')
                            message = message + "開始時刻: " + strtime + "\n============\n"
                        else:
                            pass
                        message = message + datas1[1].replace("\n", ": ") + str(worktime) + "\n"
                    else:
                        if count != 1:
                            worktime = (datetime.datetime.strptime(now, '%Y-%m-%d %H:%M:%S') + datetime.timedelta(hours=sumh) + datetime.timedelta(minutes=summ) + datetime.timedelta(seconds=sums)) - datetime.datetime.strptime(datas1[0], '%Y-%m-%d %H:%M:%S')
                            tasklist.append(datas1[1].replace("\n", ""))
                        else:
                            worktime = datetime.datetime.strptime(now, '%Y-%m-%d %H:%M:%S') - datetime.datetime.strptime(datas1[0], '%Y-%m-%d %H:%M:%S')
                        if num3 == 0:
                            starttime = datetime.datetime.strptime(datas1[0], '%Y-%m-%d %H:%M:%S')
                            strtime = starttime.strftime('%H:%M:%S')
                            message = message + "開始時刻: " + strtime + "\n============\n"
                        else:
                            pass    
                        message = message + datas1[1].replace("\n", ": ") + str(worktime) + "\n"
        os.remove(file)
    slack_message(message)

def slack_message(message):
##Slackの出力先URLを指定して下さい。
    requests.post('https://hooks.slack.com/services/xxxxxxxxxxxxxxx/xxxxxxxxxxxxxxxx', data = json.dumps({
        'text': message,
        'username': u'scripttest',
        'icon_emoji': u':abc:',
        'link_names': 1,
    }))

 

この関数に関しては「requests」ライブラリが必要になるので、zipアップロードを行う必要があります。
また、Slackへの出力が必要となるので、Slack側で「Incoming WebHooks」の設定が必要となります。
Slash Commandsと同じように「Add an app」から設定できます。

 

 

集計用関数はCloudWatch Eventsから任意の時刻に実行されるよう設定します。

以上で、Lambdaの設定は完了となります。

 

API Gateway設定

最後にAPI Gatewayの設定を行います。

タスク登録関数のトリガーにAPI Gatewayが設定されているので、「LambdaMicroservice」をクリックしてAPI Gatewayのサービスページを開きます。

API Gatewayのサービスページを開くとすでにリソースとメソッドが登録されているので、これを変更していきます。
変更が必要になるのは「統合リクエスト」の項目です。

「統合リクエスト」を選択し、「Lambdaプロキシ統合の使用」のチェックボックスを外します(いろいろ確認出ますが全部OKで)。
すると、「本文マッピングテンプレート」が表示されるので、これを選択し、「テンプレートが定義されていない場合(推奨)」を選択します。
「Content-Type」に「application/x-www-form-urlencoded」と入力し、チェックマークをクリックします。
テンプレートの入力欄に「{ "body": $util.urlDecode($input.json("$")) }」と入力し「保存」をクリックします。

 

 

保存が完了したら「アクション」をクリックし「APIのデプロイ」を選択します。
「デプロイされるステージ」に「prod」を選択し、「デプロイ」をクリックします。

 

 

デプロイに成功するとURLが表示されるので、これをコピーしてSlash Commands設定画面の「URL」の箇所に入力します。
SlashCommands設定画面で「Sava Integration」をクリックすれば完了です。

 

動作確認

それでは実際にタスクを登録してみましょう。
Slackを開き、メッセージの入力欄に登録したコマンドを入力します。
引数にはタスク名を指定します。
コマンドの実行後Slash Commandsからメッセージが返ってくれば成功です。

 

 

なお、上記の様にメッセージが返ってこなかった場合は、もう一度同じコマンドを実行してみて下さい。

集計はCloudWatchに設定した時刻になったらSlackに出力されます。

 

 

上記の様にユーザごとの業務結果が表示されれば成功です。

 

メリット

今回、Slash CommandsとLambdaで工数管理の仕組みを作りましたが、ほかのSlack工数管理ツールなどと比較した際のメリットを以下に簡単にまとめさせていただきます。

 

AWSサービスのみで実現可能

今回の仕組みは、Lambda、API Gateway、DynamoDBといったAWSサービスのみで構成されているので、AWSの知識がある方なら簡単に導入が可能となっております。
また、基本的にはAWSの無料利用枠の範囲内で導入が可能なるので、コストがほぼかかりません(かかったとしてもDynamoDBのストレージ程度)。

 

②サーバレス

インスタンスなどを立てる必要はありません。

 

③Slack内のすべてのチャンネルからタスク登録が可能

Slash CommandsはSlack内ならどのチャンネルからでも実行可能なので、特定のチャンネルでコマンドを打ち込まなきゃいけないという手間が省けます。

 

④DynamoDBに情報を保持しているので後から参照可能

一か月前の工数を確認したいとなった場合、Slackのメッセージをさかのぼって情報を取ってくるというのはかなり面倒です。
今回の仕組みならDynamoDBがデータを保持しているので、特定の日次のデータを簡単に取り出すことが可能です。

 

⑤コマンドの実行結果は自分以外には出力されない

コマンドの実行後に実行結果がSlackに表示されますが、今回の仕組みではコマンドの実行ユーザ以外には実行結果が見えない形で返ってくるので、Slackのチャンネルをタスクの登録が埋め尽くすといったことがありません。

 

⑥カスタマイズ可能

今回の工数管理の仕組みはLambda関数で作成しているので、用途に合わせて自由にカスタマイズが可能です。
他のAWSサービスとの連携や出力形式の変更など用途にあった機能を実現できます。

 

まとめ

今回は工数管理の仕組みについて紹介させていただきましたが、Slash CommandsとLambdaを連携させると、Slackからインスタンスを起動したり、インスタンスの起動状態を確認したり、さまざまなことが可能になります。

Slash Commandsは便利な機能なので、ぜひ使ってみて下さい。