汎用的に使える週次で担当者を通知するSlackbotを作成した

#やりたいこと
弊チームでは毎週金曜日に担当者が技術事例を発表する試みがあるのですが、日々のタスクに集中しすぎて自分の担当週を忘れることがあり、思い出した誰かが前日に教えてあげたり、当日の明朝にふと思い出すなどで、時間的に余裕がないまま技術記事をまとめるということが度々発生していました。

そこで週次に担当者を通知するだけのSlackbotを作成することとしました。よくよく考えると、週次のアラート担当者へのメンション通知などユースケースは意外とあると思うので、できる限り楽に汎用的に使えるように心がけて作成しました。


#当記事の前提
- AWS環境を使用できること
- Slackを利用していること

#構成
<img width="654" alt="slackbot_arch.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/316261/26173f54-3791-00ba-8d96-9123bd167fb2.jpeg">



CloudWatchEventsで決められたタイミングでLambdaを発火し、担当者をSlackに通知するシンプル構成です。



#作業の流れ
1. SlackチャンネルのWebhookURLを取得する。
2. 各担当者のSlackのメンバーIDを取得する。
3. CFnからスタックを作成する。

##手順
「1.SlackチャンネルのWebhookURLを取得する」については、https://qiita.com/vmmhypervisor/items/18c99624a84df8b31008 を、「2.各担当者のSlackのメンバーIDを取得する。」については、http://help.receptionist.jp/?p=1100#memberid を参考にすると良いでしょう。

CFnテンプレートとLambdaのソースコードについては下記にあげておきますので、ご利用いただければと思います。
CFnに環境に応じたパラメータを設定することでslackbotを作成することが可能です。

|パラメータ|説明|
|:-----|:-----|
| SlackWebhookUrl | 手順1.で取得したSlackチャンネルのWebhookURLを設定します。|
| MemberId | 手順2. で取得したメンバーIDを一つ設定します。ここで設定した方に対してメンション通知が行われます。 |
|CommaSeparatedSlackMemberId| 手順2. で取得した全てのメンバーIDをカンマ区切りで入力します。MemberIdの設定を起点に、左から右、最後まで行ったら最初に戻る、という順番となります。|
|ScheduleExpression|cron式で通知する日時を設定します。|

###CFnテンプレート


```yaml

AWSTemplateFormatVersion: '2010-09-09'
Parameters:
  SlackWebhookUrl:
    Type: String
    Default: 'https://hooks.slack.com/services/XXXXXXXXX/XXXXXXXX/XXXXXXXXXXXXXXXXXXXXX'
  MemberId:
    Type: String
    Default: 'AAAAAAAA'
  CommaSeparatedSlackMemberId:
    Type: String
    Default: 'AAAAAAAA,BBBBBBBB,CCCCCCCC'
  ScheduleExpression: 
    Type: String
    Default: 'cron(0 0 ? * MON *)'

## Create Lambda Functions
Resources:
  LambdaFunctionNotifyMessageToMember:
    Type: AWS::Lambda::Function
    Properties:
      Handler: index.lambda_handler
      Runtime: python3.7
      Code: 
        ZipFile: |
          import boto3
          import json
          import os
          import urllib
          import urllib.request
          
          def set_next_member(member_id, slack_webhook_url):
              comma_separated_
member_id = os.getenv("COMMA_SEPARATED_SLACK_MEMBER_ID")
              lambda_function_name = os.getenv("AWS_LAMBDA_FUNCTION_NAME")
              members_list = comma_separated_member_id.split(",")
              members_list_index = members_list.index(member_id)
              if members_list_index == len(members_list) - 1:
                  next_member_id = members_list[0]
              else:
                  next_member_id = members_list[members_list_index + 1]
              
              client = boto3.client('lambda')
              client.update_function_configuration(
                  FunctionName=lambda_function_name,
                  Environment={
                      'Variables': {
                          'MEMBER_ID': next_member_id,
                          'SLACK_WEBHOOK_URL': slack_webhook_url,
                          'COMMA_SEPARATED_SLACK_MEMBER_ID': comma_separated_member_id
                      }
                  }
              )
              
          def lambda_handler(event, context):
              member_id = os.getenv("MEMBER_ID")
              slack_webhook_url = os.getenv("SLACK_WEBHOOK_URL")
              message = "今週の担当者は<@" + member_id + ">さんです！\n張り切っていきましょう！！"
              
              send_data = {
                  "username": "担当者通知ボット",
                  "icon_emoji": ":rocket:",
                  "text": message,
              }
          
              send_text = "payload=" + json.dumps(send_data)
          
              request = urllib.request.Request(
                  slack_webhook_url,
                  data=send_text.encode("utf-8"), 
                  method="POST"
              )
              with urllib.request.urlopen(request) as response:
                  response_body = response.read().decode("utf-8")
          
              set_next_member(member_id, slack_webhook_url)
      MemorySize: 128
      Timeout: 60
      Environment:
        Variables:
          SLACK_WEBHOOK_URL: !Ref SlackWebhookUrl
          COMMA_SEPARATED_SLACK_MEMBER_ID: !Ref CommaSeparatedSlackMemberId
          MEMBER_ID: !Ref MemberId
      Role: !GetAtt IAMRoleNotifyMessageToMember.Arn

  IAMRoleNotifyMessageToMember:
    Type: 'AWS::IAM::Role'
    Properties:
      AssumeRolePolicyDocument:
        Version: '2008-10-17'
        Statement:
        - Effect: 'Allow'
          Principal:
            Service: 'lambda.amazonaws.com'
          Action: 'sts:AssumeRole'
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy
        - !Ref IAMManagedPolicyLamdbaUpdateFunctionConfiguration

  IAMManagedPolicyLamdbaUpdateFunctionConfiguration:
    Type: AWS::IAM::ManagedPolicy
    Properties: 
      Path: "/"
      PolicyDocument: 
        Version: "2012-10-17"
        Statement: 
          - Effect: Allow
            Action:
              - lambda:UpdateFunctionConfiguration
            Resource: "*"

  LambdaPermissionNotifyMessageToMember: 
    Type: AWS::Lambda::Permission
    Properties: 
      FunctionName: !Ref LambdaFunctionNotifyMessageToMember
      Action: "lambda:InvokeFunction"
      Principal: "events.amazonaws.com"
      SourceArn: !GetAtt EventsRuleNotifyMessageToMemberSchedule.Arn

  EventsRuleNotifyMessageToMemberSchedule:
    Type: AWS::Events::Rule
    Properties: 
      Description: "Notify message to the member in charge this week"
      RoleArn: !GetAtt IAMRoleLambdaExecutionNotifyMessageToMember.Arn
      ScheduleExpression: !Ref ScheduleExpression
      Targets: 
        - Arn: !GetAtt LambdaFunctionNotifyMessageToMember.Arn
          Id: "Slackbot"

  IAMRoleLambdaExecutionNotifyMessageToMember:
    Type: "AWS::IAM::Role"
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: events.amazonaws.com
            Action: "sts:AssumeRole"
```

###ソースコード抜粋

簡単に説明しますと、環境変数に登録したSlackのアカウントに紐づくID（メンバーID）にメンションをつけてメッセージを送り、その後自分自身（Lambda）の環境変数を次の担当者に更新し、次回の実行を待つという流れになります。

```python
import boto3
import json
import os
import urllib
import urllib.request

def set_next_member(member_id, slack_webhook_url):
    comma_separated_member_id = os.getenv("COMMA_SEPARATED_SLACK_MEMBER_ID")
    lambda_function_name = os.getenv("AWS_LAMBDA_FUNCTION_NAME")
    members_list = comma_separated_member_id.split(",")
    members_list_index = members_list.index(member_id)
    if members_list_index == len(members_list) - 1:
        next_member_id = members_list[0]
    else:
        next_member_id = members_list[members_list_index + 1]
    
    client = boto3.client('lambda')
    client.update_function_configuration(
        FunctionName=lambda_function_name,
        Environment={
            'Variables': {
                'MEMBER_ID': next_member_id,
                'SLACK_WEBHOOK_URL': slack_webhook_url,
                'COMMA_SEPARATED_SLACK_MEMBER_ID': comma_separated_member_id
            }
        }
    )
    
def lambda_handler(event, context):
    member_id = os.getenv("MEMBER_ID")
    slack_webhook_url = os.getenv("SLACK_WEBHOOK_URL")
    message = "今週の担当者は<@" + member_id + ">さんです！\n張り切っていきましょう！！"
    
    send_data = {
        "username": "担当者通知ボット",
        "icon_emoji": ":rocket:",
        "text": message,
    }

    send_text = "payload=" + json.dumps(send_data)

    request = urllib.request.Request(
        slack_webhook_url,
        data=send_text.encode("utf-8"), 
        method="POST"
    )
    with urllib.request.urlopen(request) as response:
        response_body = response.read().decode("utf-8")

    set_next_member(member_id, slack_webhook_url)
```

##最後に
何かしらの理由で、その週の担当をスキップして次の週も同じ担当者で、、というケースもあるかと思いますが、その場合はAWS CLIでCFnのスタックを更新してあげれば良いかと思います。
コマンドとしては以下のような感じになるかと。

```sh
aws cloudformation update-stack --stack-name [CFnスタック名] \
--use-previous-template --capabilities CAPABILITY_IAM \
--parameters ParameterKey=SlackWebhookUrl,UsePreviousValue=true \
             ParameterKey=ScheduleExpression,UsePreviousValue=true \
             ParameterKey=CommaSeparatedSlackMemberId,UsePreviousValue=true \
             ParameterKey=MemberId,ParameterValue=[担当者のUID]
```

少しコマンドが長くなりますが、値を変更したくないパラメータは「UsePreviousValue=true」をつけてあげないと、CFnテンプレートで指定しているデフォルト値に設定されてしまうので注意が必要です。