オンライン雑談会の日程調整で 「調整さんの回答期限は今日だよ!!」 をSlackに自動投稿する仕組みをサーバーレスで作りました

雑談会の日程調整に調整さんを使っています。人間が「今日が締切だよ!」と告知するのがめんどくさくなったので、サーバーレスで自動化してみました。
2020.04.13

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

定期開催しているオンライン雑談会があります。日時の調整に調整さんが大活躍しています。 おおよその作業フローは下記です。

  1. 月初に「調整さんを作ってね」のリマインダーがSlackに自動投稿される(以前に作成した仕組み)
  2. 人間が調整さんを作成する
  3. 人間がSlackのチャンネルで案内する(URLと締切)
  4. 人間が締切日に「今日が締切だよ!」と案内する

地味にめんどくさい、かつ、忘れがちな 3.4.をテコ入れしてサーバーレスによる自動化に挑戦しました。

なお、下記で「開催日は今日だよ!」と案内する仕組みも導入しました。

おすすめの方

  • AWS SAMを使いたい
  • SlackでAppを作りたいい
  • SlackでEvents APIを使いたい
  • SlackでIncoming Webhookを使いたい
  • LambdaでSlackに投稿したい

最初に作ったものをご紹介(完成版)

作業フロー(ユーザ視点)

  1. 毎月初日に「調整さん作ってね」がチャンネルに自動投稿される(以前に作成)
  2. 人間が調整さんを作成し、Slackのワークフローで登録する(URLと締切日)
  3. 締切日の10時に「今日が締切だよ」がチャンネルに自動投稿される

1. 毎月初日に「調整さん作ってね」がチャンネルに自動投稿される

毎月初日に「調整さん作ってね」がチャンネルに自動投稿される

コレ自体は以前に作成した仕組みです。

2. 人間が調整さんを作成し、Slackのワークフローで登録する(URLと締切日)

調整さんを作成したあと、Slackのワークフローを人間が起動し、「調整さんのURL」と「回答期限」を入力します。 このワークフローはチャンネルに投稿してお知らせします。

Slackのワークフローで調整さんと期限を入力する

Slackのワークフローがっチャンネルに投稿する

3. 締切日の10時に「今日が締切だよ」がチャンネルに自動投稿される

「今日が期限だよ!」が自動投稿される

全体概要

SlackのEvents APIを利用してチャンネルのメッセージを監視し、ワークフロー経由で投稿した「調整さんのURLと期限」をDynamoDBへ保存します。 その後、1日1回の頻度でDynamoDBを確認し、期限が今日の項目があればSlackにその旨を投稿しています。

全体概要

DynamoDBのハッシュキーは年月日のunixtime(0時0分0秒)を使っています。これは処理を楽にするため、かつ、同じ日に複数の調整さんを登録しないためです。 また、DynamoDBのデータ削除はTTL機能を使って自動削除させています。最大でも48時間後の削除になりえますが、少ないサンプル数での実績としてほぼすぐ消えたこと、および、2日連続で通知されることを許容します。

環境&前提

項目 バージョン
macOS Mojave 10.14.6
Python 3.7
SAM CLI version 0.37.0

下記の続きで作成していきます。

目次(ここから下)

期日をDynamoDBに保存するAPI(Lambda)を作成する(verify用)

チャンネルのメッセージを受け取るAPIを作成しますが、「そのAPIは有効なの?」を確認する必要があるため、まずはSlackの仕様に基づいた応答を返すAPIを作成します。

AWS SAMテンプレート

前回の内容も含んでいます。また、この時点で下記も追加しています。

  • DynamoDBの定義
  • LambdaにDynamoDBFullAccessを付与

template.yml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Chouseisan-reminder

Parameters:
  ChouseisanNotifySlackUrl:
    Type : AWS::SSM::Parameter::Value<String>

Resources:
  ChouseisanReminderFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: chouseisan-reminder-function
      CodeUri: src/notify_1st_message/
      Handler: app.lambda_handler
      Runtime: python3.7
      Timeout: 10
      Environment:
        Variables:
          TZ: Asia/Tokyo
          INCOMMING_WEBHOOK_URL: !Ref ChouseisanNotifySlackUrl
      Events:
        NotifySlack:
          Type: Schedule
          Properties:
            Schedule: cron(0 0 1-7 * ? *) # 日本時間で毎月1-7日のAM9時

  ChouseisanReminderFunctionLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/aws/lambda/${ChouseisanReminderFunction}"

  ChouseisanSaveDeadlineFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: chouseisan-save-deadline-function
      CodeUri: src/save_deadline/
      Handler: app.lambda_handler
      Runtime: python3.7
      Timeout: 10
      Environment:
        Variables:
          TZ: Asia/Tokyo
          REMINDER_TABLE_NAME: !Ref ChouseisanReminderTable
      Policies:
        - arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess
      Events:
        WorkflowApi:
          Type: Api
          Properties:
            Path: /reminder
            Method: post

  ChouseisanSaveDeadlineFunctionLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/aws/lambda/${ChouseisanSaveDeadlineFunction}"

  ChouseisanReminderTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: chouseisan-reminder-table
      KeySchema:
        - AttributeName: deadline
          KeyType: HASH
      AttributeDefinitions:
        - AttributeName: deadline
          AttributeType: N
      TimeToLiveSpecification:
        AttributeName: expiration
        Enabled: true
      BillingMode: PAY_PER_REQUEST

Outputs:
  SaveDeadlineApi:
    Description: "save deadline api"
    Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/reminder/"

Lambdaコード

src/save_deadline/app.pyを新規作成します。 Events APIのドキュメントに従って、受け取ったパラメータのchallengeを返しています。

app.py

import json

def lambda_handler(event, context):
    body = json.loads(event['body'])

    return {
        'statusCode': 200,
        'body': json.dumps(
            {'challenge': body['challenge']},
        ),
    }

ビルド&デプロイ

sam build
sam package \
    --output-template-file packaged.yaml \
    --s3-bucket cm-fujii.genki-chouseisan-reminder-deploy-bucket
sam deploy \
    --template-file packaged.yaml \
    --stack-name Chouseisan-Reminder-Stack \
    --capabilities CAPABILITY_NAMED_IAM \
    --no-fail-on-empty-changeset \
    --parameter-overrides ChouseisanNotifySlackUrl=/Slack/INCOMING_WEBHOOK_URL/channel_name/choseisan_reminder

Slackのアプリを作成する

下記を持つアプリを作成します。

  • Event Subscriptions (Events API)
  • Incomming Webhook

まずはアプリを新規作成する

SlackのBasic app setupを開き、アプリを新規作成します。Create a new slack appを選択します。

Slackのアプリを新規作成する

続いてCreate New Appを選択します。

Create New Appを選択

「App Name」を適当に記入して、Create Appを選択します。

Create Appを選択

Event Subscriptionsを設定する

Event Subscriptionsを選択します。

Event Subscriptionsを選択

有効化したあと、Requests URLを入力します。VerifiedになればOKです!

URLを入力する

続いてSubscribe to bot eventsmessage.channelsを追加し、Saveします。

message.channelsを追加してSaveする

Incomming Webhookを設定する

Incoming Webhooksを選択してOnにします。

Incoming Webhooksを選択してOnにします。

続いてAdd New Webhook to Workspaceを選択して追加します。

Add New Webhook to Workspaceを選択する

Slackアプリをワークスペースに追加する

OAuth & Permissionsを選択し、Install App to Workspaceを選択します。

Add New Webhook to Workspaceを選択する

Slackのチャンネルにアプリを追加する

任意のチャンネルの詳細メニューから追加できます。

Slackのチャンネルにアプリを追加する

Slackのワークフローを作成する

ワークフローを新規作成

Slackの左上を選択し、ワークフロービルダーを起動します。

ワークフロービルダーを起動する

適当に名前をつけます。

ワークフローに名前をつける

人間が起動するためショートカットを選択します。

ショートカットを選択する

適当に入力します。

必要事項を入力する

ワークフローのステップを追加(フォーム入力)

ステップを追加を選択します。

ステップを追加する

フォームを作成するを選択します。

フォームを作成する

調整さんのURLと回答期限を入力するフォームを作成します。

フォームを作成する

それぞれは下記です。

URL入力フォーム

期限入力フォーム

ワークフローのステップを追加(メッセージ送信)

さらにステップを追加します。 今度はメッセージを送信を選択します。

メッセージを送信する

そして保存しましょう。

ワークフローを保存する

ワークフローを公開する

右上の公開するボタンを選択すればOKです!

ワークフローを公開する

JSONメモ

このワークフローを実行したとき、Lambdaには次のJSONが来ました。

{
    "token": "xxxxx",
    "team_id": "xxxxx",
    "api_app_id": "xxxxx",
    "event": {
        "type": "message",
        "subtype": "bot_message",
        "text": "調整さんに記入をお願いします!\n期限は *2020/04/06* です!\n<https://chouseisan.com/s?h=xxxxx>",
        "ts": "1586171318.003000",
        "username": "reminder_misc_join_201901_workflow",
        "bot_id": "xxxxx",
        "channel": "xxxxx",
        "event_ts": "1586171318.003000",
        "channel_type": "channel"
    },
    "type": "event_callback",
    "event_id": "xxxxx",
    "event_time": 1586171318,
    "authed_users": [
        "xxxxx"
    ]
}

期日をDynamoDBに保存するAPI(Lambda)を作成する

AWS SAMにはDynamoDBの定義を追加済みなのでLambdaコードのみを修正します。

Lambdaコードを変更

Lambdaコード(src/save_deadline/app.py)を下記にします。

app.py

import boto3
import json
import logging
import os
import re

from datetime import datetime

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

dynamodb = boto3.resource('dynamodb')

def lambda_handler(event, context):
    main(event)
    return {
        'statusCode': 200
    }

def main(event):
    logger.info(json.dumps(event))

    body = json.loads(event['body'])
    logger.info(json.dumps(body))

    if 'username' not in body['event']:
        logger.info('No username.')
        return

    if 'reminder_misc_join_201901_workflow' != body['event']['username']:
        logger.info('No workflow message.')
        return

    deadline = parse_timestamp(body['event']['text'])
    url = parse_url(body['event']['text'])
    logger.info(f'deadline: {deadline}, url: {url}')

    # 当日11時をDynamoDBのTTL期限とする
    expiration = deadline + 60*60*11

    put_item(deadline, url, expiration)


def parse_timestamp(text):
    pattern = r'.+\n期限は \*(\d{4}/\d{1,2}/\d{1,2})\* です!'
    res = re.match(pattern, text)
    if res:
        # 0時のunixtimeを返す
        return int(datetime.strptime(res.group(1), '%Y/%m/%d').timestamp())
    raise ValueError


def parse_url(text):
    pattern = r'.+\n.+\n<(.+)>'
    res = re.match(pattern, text)
    if res:
        return res.group(1)
    raise ValueError

def put_item(deadline, url, expiration):
    table_name = os.environ['REMINDER_TABLE_NAME']
    table = dynamodb.Table(table_name)
    res = table.put_item(Item={
        'deadline': deadline,
        'expiration': expiration,
        'url': url
    })
    logger.info(res)

ビルド&デプロイ

sam build
sam package \
    --output-template-file packaged.yaml \
    --s3-bucket cm-fujii.genki-chouseisan-reminder-deploy-bucket
sam deploy \
    --template-file packaged.yaml \
    --stack-name Chouseisan-Reminder-Stack \
    --capabilities CAPABILITY_NAMED_IAM \
    --no-fail-on-empty-changeset \
    --parameter-overrides ChouseisanNotifySlackUrl=/Slack/INCOMING_WEBHOOK_URL/channel_name/choseisan_reminder

動作確認(簡易)

Slackのワークフローを実行します。

Slackのワークフローで調整さんと期限を入力する

そうすると、DynamoDBにデータが保存されました!

DynamoDBにデータが保存されている

締め切りを告知するLambdaを作成する

1日1回起動し、今日が締め切りなら告知するLambdaを作成していきます。

AWS SAM(全部)

template.yml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Chouseisan-reminder

Parameters:
  ChouseisanNotifySlackUrl:
    Type : AWS::SSM::Parameter::Value<String>

Resources:
  ChouseisanReminderFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: chouseisan-reminder-function
      CodeUri: src/notify_1st_message/
      Handler: app.lambda_handler
      Runtime: python3.7
      Timeout: 10
      Environment:
        Variables:
          TZ: Asia/Tokyo
          INCOMMING_WEBHOOK_URL: !Ref ChouseisanNotifySlackUrl
      Events:
        NotifySlack:
          Type: Schedule
          Properties:
            Schedule: cron(0 0 1-7 * ? *) # 日本時間で毎月1-7日のAM9時

  ChouseisanReminderFunctionLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/aws/lambda/${ChouseisanReminderFunction}"

  ChouseisanSaveDeadlineFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: chouseisan-save-deadline-function
      CodeUri: src/save_deadline/
      Handler: app.lambda_handler
      Runtime: python3.7
      Timeout: 10
      Environment:
        Variables:
          TZ: Asia/Tokyo
          REMINDER_TABLE_NAME: !Ref ChouseisanReminderTable
      Policies:
        - arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess
      Events:
        WorkflowApi:
          Type: Api
          Properties:
            Path: /reminder
            Method: post

  ChouseisanSaveDeadlineFunctionLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/aws/lambda/${ChouseisanSaveDeadlineFunction}"

  ChouseisanReminderTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: chouseisan-reminder-table
      KeySchema:
        - AttributeName: deadline
          KeyType: HASH
      AttributeDefinitions:
        - AttributeName: deadline
          AttributeType: N
      TimeToLiveSpecification:
        AttributeName: expiration
        Enabled: true
      BillingMode: PAY_PER_REQUEST

  ChouseisanNotifyDeadlineFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: chouseisan-notify-deadline-function
      CodeUri: src/notify_deadline_message/
      Handler: app.lambda_handler
      Runtime: python3.7
      Timeout: 10
      Environment:
        Variables:
          TZ: Asia/Tokyo
          REMINDER_TABLE_NAME: !Ref ChouseisanReminderTable
          INCOMMING_WEBHOOK_URL: !Ref ChouseisanNotifySlackUrl
      Policies:
        - arn:aws:iam::aws:policy/AmazonDynamoDBReadOnlyAccess
      Events:
        NotifySlack:
          Type: Schedule
          Properties:
            Schedule: cron(0 1 * * ? *) # 日本時間AM10時に毎日通知する

  ChouseisanNotifyDeadlineFunctionLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/aws/lambda/${ChouseisanNotifyDeadlineFunction}"

Outputs:
  SaveDeadlineApi:
    Description: "save deadline api"
    Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/reminder/"

Lambdaコード

app.py

import boto3
import json
import logging
import os
import requests

from botocore.exceptions import ClientError
from datetime import date, datetime

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

INCOMMING_WEBHOOK_URL = os.environ['INCOMMING_WEBHOOK_URL']

dynamodb = boto3.resource('dynamodb')


def lambda_handler(event, context):
    today = get_today()
    logger.info(f'today: {today}')

    remind_data = get_remind_data(today)
    logger.info(f'get_remind_data(): {remind_data}')

    if remind_data is None:
        return

    # Slackに通知する
    post_slack(remind_data)


def get_today():
    today = date.today()
    # 今日の0時0分0秒のunixtimeを返す
    return int(datetime(today.year, today.month, today.day).timestamp())


def get_remind_data(deadline):
    table_name = os.environ['REMINDER_TABLE_NAME']
    table = dynamodb.Table(table_name)
    try:
        res = table.get_item(Key={
                'deadline': deadline
            }
        )
    except ClientError as e:
        logger.error(e.response['Error']['Message'])
        return None
    else:
        return res.get('Item', None)


def post_slack(remind_data):
    # https://api.slack.com/incoming-webhooks
    # https://api.slack.com/docs/message-formatting
    # https://api.slack.com/docs/messages/builder
    payload = {
        # https://www.webfx.com/tools/emoji-cheat-sheet/
        'icon_emoji': ':bangbang:',
        'text': '<!here> 今日が締切です!! 記入お願いします!\n',
        'attachments': [
            {
                'text': remind_data['url']
            }
        ]
    }

    url = f'https://{INCOMMING_WEBHOOK_URL}'

    # http://requests-docs-ja.readthedocs.io/en/latest/user/quickstart/
    try:
        response = requests.post(url, data=json.dumps(payload))
    except requests.exceptions.RequestException as e:
        logger.error(e)
    else:
        logger.info(response.status_code)

ビルド&デプロイ

sam build
sam package \
    --output-template-file packaged.yaml \
    --s3-bucket cm-fujii.genki-chouseisan-reminder-deploy-bucket
sam deploy \
    --template-file packaged.yaml \
    --stack-name Chouseisan-Reminder-Stack \
    --capabilities CAPABILITY_NAMED_IAM \
    --no-fail-on-empty-changeset \
    --parameter-overrides ChouseisanNotifySlackUrl=/Slack/INCOMING_WEBHOOK_URL/channel_name/choseisan_reminder

動作確認(期限の通知)

期日のAM10時に通知が来ました!!

「今日が期限だよ!」が自動投稿される

さいごに

Slackのワークフローとサーバーレスな組み合わせで、期日を自動通知する仕組みを作ってみました。 少しでも便利になったはずです!!

参考