#はじめに
Googleカレンダーとても便利ですよね.Gmailと連動できるし,PCから開けるし,シンプルだし.ただ,不満に思う点があります.それがバイトの給料を計算してくれないことです.シフト管理アプリと連動してくれれば良いのに...
と言うことで,気になってた各種APIとかAWSとかを初心者なりに使いつつ,作ってみました.
#使ったもの
- Slack
 - AWS
 - Googleカレンダー
 - Python
 
#アーキテクチャ全体像
以下のような構成でbotを作成しました.
#手順
- Slack APIのサイト上でbot作成用のアプリを作成する
 - AWS上にSlackイベントを受け取るエンドポイントを作成する
 - GoogleAPIを使って,Googleカレンダーからバイトの給料を計算するプログラムを作成
 - AWS lambdaに導入
 
#【手順1】【手順2】Slack botを作成し,AWS上にエンドポイントを作成する
この手順は,以下の記事にとてもお世話になりました.記事の通りに進めると,問題なくできると思います.
AWS初心者でもわかる! ブラウザ上で完結! AWS+Slack Event APIを使ったSlackボット超入門
#【手順3】Googleカレンダーからバイトの給料を計算するプログラムを作成
Googleカレンダーの予定をPythonを使って取得する方法は以下の記事を参考にしました.
Google Calenderの予定をPython3から取得する
次に,公式のサンプルと公式リファレンスを参考に,給料を計算するプログラムを作っていきます.
プログラムは大きく以下に分かれます.
- handle_slack_event():エントリポイント
 - MakePayMsg():ユーザテキストに対応した給料を計算し,メッセージを作成
 - CalculatePay():日給を計算
 
フルコードはgithubにあるので,そちらを参照してください.
handle_slack_event()
エントリポイントです.ユーザが送信したテキストを解析し,それを元にメッセージを投稿します.
# -----エントリポイント-----
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()
ユーザテキストに対応した給料を計算し,メッセージを作成します.
# -----給料を計算し,メッセージを作成する-----
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が発生すると言うことがあります.
このプログラムでもエラーが出たため,以下のように変更しました.
with open('token.pickle', 'wb') as token:
    pickle.dump(creds, token)
with open('/tmp/token.pickle', 'wb') as token:
    pickle.dump(creds, token)
CalculatePay()
日給を計算します.
# -----日給を計算する-----
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で外部モジュールを使用する
実行結果
最近Googleカレンダーにバイトのシフトを記録し始めたので,データが少ないです.
このbotを導入したので,今後はシフト管理もGoogleカレンダーでしていきたいと思います.
(バイト先がどこかわかりやすいアイコンだなあ)
helpの表示
来月の給料の表示
今年の給料
給料のログ
その他