Googleカレンダーの予定からバイトの給料を計算して教えてくれるSlack botの作成

#はじめに
Googleカレンダーとても便利ですよね．Gmailと連動できるし，PCから開けるし，シンプルだし．ただ，不満に思う点があります．それがバイトの給料を計算してくれないことです．シフト管理アプリと連動してくれれば良いのに．．．
と言うことで，気になってた各種APIとかAWSとかを初心者なりに使いつつ，作ってみました．

#使ったもの
* Slack
* AWS
* Googleカレンダー
* Python

#アーキテクチャ全体像
以下のような構成でbotを作成しました．

<img width="500" alt="仕組み" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364169/e84d4443-a468-c823-87fa-f6f0992ab803.png">

#手順
1. Slack APIのサイト上でbot作成用のアプリを作成する
2. AWS上にSlackイベントを受け取るエンドポイントを作成する
3. GoogleAPIを使って，Googleカレンダーからバイトの給料を計算するプログラムを作成
4. AWS lambdaに導入


#【手順1】【手順2】Slack botを作成し，AWS上にエンドポイントを作成する
この手順は，以下の記事にとてもお世話になりました．記事の通りに進めると，問題なくできると思います．
[AWS初心者でもわかる！ ブラウザ上で完結！ AWS+Slack Event APIを使ったSlackボット超入門](https://qiita.com/mido_app/items/fcbbdb2bcce3edf0d3f5)


#【手順3】Googleカレンダーからバイトの給料を計算するプログラムを作成
Googleカレンダーの予定をPythonを使って取得する方法は以下の記事を参考にしました．
[Google Calenderの予定をPython3から取得する](https://qiita.com/lobmto/items/c1a220a12ec9c1fad560)

次に，[公式のサンプル](https://developers.google.com/calendar/quickstart/python)と[公式リファレンス](https://developers.google.com/calendar/v3/reference)を参考に，給料を計算するプログラムを作っていきます．

プログラムは大きく以下に分かれます．

* handle_slack_event()：エントリポイント
* MakePayMsg()：ユーザテキストに対応した給料を計算し，メッセージを作成
* CalculatePay()：日給を計算

フルコードは[github](https://github.com/tkd26/SlackBot_PayManager)にあるので，そちらを参照してください．

## handle_slack_event()

エントリポイントです．ユーザが送信したテキストを解析し，それを元にメッセージを投稿します．

```python
# -----エントリポイント-----
def handle_slack_event(slack_event, context):

    # 受け取ったイベント情報をCloud Watchログに出力
    logging.info(json.dumps(slack_event))

    # Event APIの認証
    if "challenge" in slack_event:
        return slack_event.get("challenge")

    # ボットによるイベントまたはメッセージ投稿イベント以外の場合
    # 反応させないためにそのままリターンする
    # Slackには何かしらのレスポンスを返す必要があるのでOKと返す
    # （返さない場合、失敗とみなされて同じリクエストが何度か送られてくる）
    if is_bot(slack_event) or not is_message_event(slack_event):
        return "OK"

    # ユーザからのメッセージテキストを取り出す
    text = slack_event.get("event").get("text")

    # 給料計算クラスの宣言
    pay_msg = MakePayMsg()
    
    # ユーザからのテキストを解析して，メッセージを作成
    if 'help' in text:
        msg = '知りたい情報に対応する番号を入力してください！\n'
        msg += '(1)来月の給料\n'
        msg += '(2)今年の給料\n'
        msg += '(3)給料のログ\n'
    elif text == '1':
        msg = '来月の給料は￥{}です！'.format(pay_msg.monthpay())
    elif text == '2':
        msg = '{}'.format(pay_msg.yearpay())
    elif text == '3':
        msg = '給料ログ\n{}'.format(pay_msg.paylog())
    else:
        msg = '\\クエー/'
    
    # メッセージの投稿
    post_message_to_slack_channel(msg, slack_event.get("event").get("channel"))

    # メッセージの投稿とは別に、Event APIによるリクエストの結果として
    # Slackに何かしらのレスポンスを返す必要があるのでOKと返す
    # （返さない場合、失敗とみなされて同じリクエストが何度か送られてくる）
    return "OK"
```


## MakePayMsg()
ユーザテキストに対応した給料を計算し，メッセージを作成します．

```python
# -----給料を計算し，メッセージを作成する-----
class MakePayMsg():
    def __init__(self):
    
        self.SCOPES = ['https://www.googleapis.com/auth/calendar.readonly']
        self.now = datetime.datetime.now()
        self.events = self.get_event() # Googleカレンダーから取り出したイベント
        self.pay_log = self.make_paylog() # 今年分の給料ログ

    # ---Googleカレンダーからイベントを取り出す---
    def get_event(self):
        creds = None
        if os.path.exists('token.pickle'):
            with open('token.pickle', 'rb') as token:
                creds = pickle.load(token)
        # If there are no (valid) credentials available, let the user log in.
        if not creds or not creds.valid:
            if creds and creds.expired and creds.refresh_token:
                creds.refresh(Request())
            else:
                flow = InstalledAppFlow.from_client_secrets_file(
                    'credentials.json', self.SCOPES)
                creds = flow.run_local_server(port=0)
            # Save the credentials for the next run
            with open('/tmp/token.pickle', 'wb') as token:
                pickle.dump(creds, token)

        service = build('calendar', 'v3', credentials=creds)

        # バイトのシフトを登録しているカレンダーを選択
        calender_id = os.environ['CALENDER_ID']

        page_token = None
        events = service.events().list(calendarId=calender_id, pageToken=page_token).execute()
        return events

    # ---今年分の給料ログを作成する---
    def make_paylog(self):
        pay_log = []
        cal = CalculatePay(1013, 1063, 1.25, 22) # 時給情報を入力
        
        # eventからバイトの開始時間と終了時間を取り出し，給料計算する
        for event in self.events['items']:
            # 開始時間と終了時間をdatetimeに変形
            stime = event['start']['dateTime']
            stime = datetime.datetime(
                int(stime[0:4]), int(stime[5:7]), int(stime[8:10]),
                int(stime[11:13]), int(stime[14:16]))
            etime = event['end']['dateTime']
            etime = datetime.datetime(
                int(etime[0:4]), int(etime[5:7]), int(etime[8:10]),
                int(etime[11:13]), int(etime[14:16]))
            
            # 給料計算をする期間
            # (x-1)年12月~x年11月に働いた分がx年の給料
            if self.now.month != 12:
                sdate = datetime.date(self.now.year-1, 12, 1)
                edate = datetime.date(self.now.year, 11, 30)
            else:
                sdate = datetime.date(self.now.year, 12, 1)
                edate = datetime.date(self.now.year+1, 11, 30)

            # 1年分の給料をログとして記録
            if (stime.date() >= sdate) and (etime.date() <= edate):
                # 開始時間と終了時間から1日分の給料計算
                daypay = cal.calculate(stime, etime)
                # 働いた分が翌月の給料になるように調整
                if stime.month==12:
                    daypay_dir = {'date':stime.date(), 'month':1, 'pay':daypay}
                else:
                    daypay_dir = {'date':stime.date(), 'month':stime.month+1, 'pay':daypay}
                pay_log += [daypay_dir]
        pay_log = sorted(pay_log, key=lambda x:x['date'])
        return pay_log
            
    # ---来月の給料を表示するメッセージを作成---
    def monthpay(self):
        mpay = 0
        for i in self.pay_log:
            if self.now.month!=12:
                if i['month'] == (self.now.month+1):
                    mpay += i['pay']
            else:
                if i['month'] == 1:
                    mpay += i['pay']
        return mpay

    # ---1年分の給料を表示するメッセージを作成---
    def yearpay(self):
        mpay_list = [0] * 12
        for i in self.pay_log:
            mpay_list[i['month']-1] += i['pay']
        msg = ''
        for i, mpay in enumerate(mpay_list):
            msg += '{}月 ￥{:,}\n'.format(i+1, mpay)
        msg += '\n合計￥{}'.format(sum(mpay_list))
        return msg

    # ---1年分のログを表示するメッセージを作成---
    def paylog(self):
        msg = ''
        month = 0
        for i in self.pay_log:
            while i['month'] != month:
                msg += '\n{}月\n'.format(month+1)
                month += 1
            msg += '{} ￥{:,}\n'.format(i['date'], i['pay'])
        return msg
```

ここで注意点ですが，Lambda が書き込みできるのは`/tmp`配下のファイルのみです．
そのため，あるファイルへの書き込み処理をする場合，ローカルで実行した時にはエラーが起きなかったのに，Lambdaで実行したところ`[Errno 30] Read-only file system`が発生すると言うことがあります．
このプログラムでもエラーが出たため，以下のように変更しました．

```python:変更前
with open('token.pickle', 'wb') as token:
    pickle.dump(creds, token)
```

```python:変更後
with open('/tmp/token.pickle', 'wb') as token:
    pickle.dump(creds, token)
```



## CalculatePay()
日給を計算します．

```python

# -----日給を計算する-----
class CalculatePay():
    def __init__(
        self, basic_pay, irregular_pay, night_rate, night_time):

        self.basic_pay = basic_pay # 平日の時給
        self.irregular_pay = irregular_pay # 土日祝日の時給
        self.night_rate = night_rate # 深夜給の増額率
        self.night_time = night_time # 深夜給になる時間

    # ---日給を計算---
    def calculate(self, stime, etime):
        night_time = datetime.datetime(stime.year, stime.month, stime.day, self.night_time)
        
        if stime.weekday() >= 5 or jpholiday.is_holiday(stime.date()):
            pay = self.irregular_pay
        else:
            pay = self.basic_pay

        if etime >= night_time:
            normal_time = self.get_h(night_time - stime)
            night_time = self.get_h(etime - night_time)
            daypay = normal_time * pay + night_time * (pay * self.night_rate)
        else:
            normal_time = self.get_h(etime - stime)
            daypay = normal_time * pay

        return round(daypay)

    # ---x時間y分→h時間表示に変換---
    def get_h(self, delta_time):
        h = delta_time.seconds / 3600
        return h
```


# 【手順4】AWS lambdaに導入

Googleカレンダーから給料を計算するプログラムを書く上で，いくつかのモジュールをインストールしました．
何もせずにAWS Lambdaでこのプログラムを実行すると，ModuleNotFoundErrorが出ます．
様々なモジュールををAWS Lambda上でも使用できるようにするために，Python用のAWS Lambdaデプロイパッケージを作成します．簡潔に言うと，プロジェクトディレクトリにすべての依存モジュールをインストールし，実行ファイルと一緒にzipでアップロードします．
これは，以下のサイトの通りに実行すれば良いです．
[【Python】AWS Lambdaで外部モジュールを使用する](https://qiita.com/SHASE03/items/16fd31d3698f207b42c9)


# 実行結果

最近Googleカレンダーにバイトのシフトを記録し始めたので，データが少ないです．
このbotを導入したので，今後はシフト管理もGoogleカレンダーでしていきたいと思います．
(バイト先がどこかわかりやすいアイコンだなあ)

### helpの表示
<img width="420" alt="スクリーンショット 2019-11-28 0.37.29.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364169/fead165b-3c14-0a2b-c66c-f863153b9948.png">

### 来月の給料の表示
<img width="420" alt="スクリーンショット 2019-11-28 0.36.52.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364169/2a62429b-3375-c0a2-ffb1-6459c192378e.png">

### 今年の給料
<img width="420" alt="スクリーンショット 2019-11-28 0.37.05.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364169/dc4b95a2-26e0-fe86-2d48-5828bee6a74b.png">

### 給料のログ
<img width="420" alt="スクリーンショット 2019-11-28 0.42.47.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364169/84a50bca-fcb4-6111-3e80-e2d45208996a.png">

### その他
<img width="420" alt="スクリーンショット 2019-11-28 0.45.04.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364169/0aefd849-fcf0-a577-3591-de87ffa818bf.png">