Slackシャッフル ランチ ボットをAWSサーバレスアーキテクチャで作る

# はじめに
## 内容

- Slackボットを作ります．
- AWSサーバレスアーキテクチャとして開発します．
- お題はシャッフルランチです．

これからSlackボットを作られる方や，AWSサーバレスアーキテクチャの初心者の方に参考になれば幸いです．

## 作るもの
### みんなでランチに行きたい！が..
職場，サークル，その他組織のメンバの交流を図るのにランチは良いですよね．しかし，いつもと同じメンバと行ってしまう．一緒に行きたいけどなかなか誘えない．そんなときシャッフルランチを企画してみましょう．定期的に，ランダムでグループ分けしたメンバでランチに行きす．

### Slackボットで出席日確認とグループ分け
ボットがシャッフルランチに参加できる日のアンケートを行い，それに基づいてメンバをランダムで割り振ります．

![image.png](https://qiita-image-store.s3.amazonaws.com/0/73358/2037d091-e3bb-86e2-2369-3ce5897538bf.png)
![image.png](https://qiita-image-store.s3.amazonaws.com/0/73358/afc1be76-861c-b925-c927-028e24c19c96.png)

## 作り方
### AWSサーバレスアーキテクチャ
本記事ではシャッフルランチをSlackボットをAWSサーバレスアーキテクチャで作ります．
具体的には以下のサービスを利用します．Slackを除いてすべてAWSのサービスです．

| サービス | 利用目的 |
|:---|:----|
| Lambda | 各種処理の実行のため |
| DynamoDB | メンバの名簿，出席可能かどうかを記録するため |
| CloudWatch | シャッフルランチボット定期実行のため |
| API Gateway | SlackイベントのWebhookとして利用するため |
| CloudFormation | 各サービスへのデプロイ |
| ParameterStore | Slack APIのキーを保持するため |
| Slack | 本ボットのUIを担当 |

# 仕様
### シャッフルランチのルール
若干ややこしいですが，次のルールに基づいてシャッフルランチを開催・参加するものとします．

- 毎週火曜日(*1)に開催される．（シャッフルランチ自体は毎週火曜に開催されているが，全員が参加するわけではない．）
- メンバは一人あたり原則月2回(*1)参加する．
- 前月の最終週に来月の出席可能日をアンケートする．例えばメンバは5月の出席可能日を4月最終数に回答する．

*1: オプションで変更可能

### シナリオ
今日は2018年4月22日．横山さんがSlackを確認すると，ボットのこんなメッセージを見つけた．早速，横山さんは5月22日以外は都合が良いので，「出席できる」をクリックした．
![image.png](https://qiita-image-store.s3.amazonaws.com/0/73358/2037d091-e3bb-86e2-2369-3ce5897538bf.png)

数日後，改めてSlackを確認するとシャッフルランチの参加日と一緒に食事するメンバが割り振られていた．都合がつかず「出席できる」を押さなかった5月22日は避けられている．

- 5月1: teamルイージ　森さん，田中さん，山下さん，佐藤さんの4人と食事することになった．
- 5月15日: teamマリオ　 渡辺さん, 臼井さん, 西村さん, 川瀬さんの4人と食事することになった．


![image.png](https://qiita-image-store.s3.amazonaws.com/0/73358/52a53f71-5216-b4f3-b065-1c95037e93d7.png)

# 構築していく
注: スクリーンショットは2018年4月1日現在の物です．

## Slack Appの作成
#### 作成
https://api.slack.com を開いて”Start Building”をクリック．Slack Appを作成します．

![3b37785f-8d61-4e07-828f-efce455bdfd3.png](https://qiita-image-store.s3.amazonaws.com/0/73358/ed0fd98d-4d15-c9bc-d440-ef32454ab300.png)

#### 設定

管理画面が表示されたら"OAuth & Permissions"に移動します．
![d81dfaf6-aab9-41cd-b914-88e995151141.png](https://qiita-image-store.s3.amazonaws.com/0/73358/fe9b924a-5633-18f2-80cd-45e5e1151374.png)

Scopesでプルダウンメニューから”Send messages as shuffle luncher”を選択します．

![b620afdf-74fe-434b-a8a3-c63ce53517ff.png](https://qiita-image-store.s3.amazonaws.com/0/73358/16b86db4-4a17-feec-1d1b-f768a67465a6.png)

そして保存します．

保存すると，ページ上部の「Install App to Workspace」ボタンが押せるようになるので，押します．確認画面が表示されるので許可しましょう．
![image.png](https://qiita-image-store.s3.amazonaws.com/0/73358/8c68c6f5-58b4-3ae7-9901-de12b956389e.png)

#### OAuth Access Tokenの発行を確認

OAuth認証トークンが発行されます．言うまでもないですが，流出させてはいけない文字列です．
![d2f3ca95-68da-4878-a3e5-f5b1a1b6daa9.png](https://qiita-image-store.s3.amazonaws.com/0/73358/9ce89012-0d89-9d48-9088-2a270846759d.png)


## Slack OAuthトークンをAWS側に保存
先ほどのOAuthトークンは，AWS側のプログラムからSlack APIにリクエストする際に必要ですが，直接コードに書くべきではありません．こういったパラメータを保持するためのサービス，ParameterStoreを利用しましょう．

Webのコンソールから Systems Manager, Parameter Storeへと進め，パラメータストアで保存します．
![image.png](https://qiita-image-store.s3.amazonaws.com/0/73358/acae7494-11b2-a667-a4e8-aea428fe1781.png)

| フィールド | 値 |
|:----|:-----|
| 名前 | shuffle_luncher_slack_token |
| タイプ | SecureString |
| 値 | 先ほどのトークン |

ParameterStoreに保存しておけば AWS SDKに含まれる関数から値を取得できます．


## できあがったもの
#### リポジトリ
今回，自分が作成したコードはGithubで公開しており，samファイルも記述したため，数個のコマンドで構築できます．細かい実装についてはGithubのコードを見ていただければ幸いです．

- [Github hiroyky/shuffle_luncher](https://github.com/hiroyky/shuffle_luncher)

#### ビルド
```
git clone git@github.com:hiroyky/shuffle_luncher.git
cd shuffle_luncher
npm install
npm run build
```

#### デプロイ
[aws-cliがインストール](https://docs.aws.amazon.com/ja_jp/streams/latest/dev/kinesis-tutorial-cli-installation.html)されて実行できることが前提です．

```
./deploy/deploy.sh <デプロイ時のS3バケット名> <SlackのチャンネルID>
```

- デプロイ時のS3バケット名: デプロイするパッケージを保存するバケット名
    - SlackのチャンネルID: 投稿するSlackチャンネルのID，URLのmessages/&lt;SlackのチャンネルID&gt;

## コードの要素
*コードの行数などは記事執筆時とずれる可能性があります．

### 出席可能日アンケート

| 概要 | 内容 |
|:---|:---|
| エントリポイント | [askForAttendanceDates](https://github.com/hiroyky/shuffle_luncher/blob/master/src/index.ts#L14)|

月の指定曜日の日付を算出し，Slackに投稿します．Attachementとしてボタン要素を作成しています．
CloudWatchからcronで実行すれば毎月アンケートされます．
https://github.com/hiroyky/shuffle_luncher/blob/master/src/drivers/slack-driver.ts#L13

```TypeScript
const arg = {
            token: this.env.slackToken,
            channel: this.env.slackChannelId,
            text: `シャッフルランチ出席可能日確認：出席できる日のボタンを全て押してください．${expires}まで`,
            fallback: '',
            attachments: new Array<any>()
        };
        candidateDates.forEach(date => {
            arg.attachments.push({
                callback_id: (date.getTime() / 1000).toString(),
                title: `${date.getMonth()+1}月${date.getDate()}日`,
                attachment_type: 'default',
                actions: [
                    {
                        type: 'button', 
                        text: '出席できる', 
                        name: `attendance_${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`
                    }
                ]
            });
            arg.attachments.push({
                callback_id: this.getAttachmentId(date),
                text: ''
            });
        });
```

attachmentの``callback_id``がどのボタンをクリックしたかの識別子になります．

### ボタンを押されたときのイベント：出席可能日登録
ユーザがボタンを押したときのエントリポイントです．API Gatewayを経由して実行されます．
DynamoDBに日付をフィールドにして，出席できるとしてtrueが登録されます．

| 概要 | 内容 |
|:---|:---|
| エントリポイント | [interactoinHandler](https://github.com/hiroyky/shuffle_luncher/blob/master/src/index.ts#L27)|

##### Slack Webhook

Slack Appの設定画面で「Interactive Components」を開きます．「Request URL」にユーザがボタンを押したときにwebhookされるURLを指定します．インタラクションがあるたびにこのURLにPOSTされます．POSTの内容にはクリックしたユーザ情報や押したボタンの情報が含まれます．ボタンのcallback_idでどれが押されたかを取得します．
![screenshot1.png](https://qiita-image-store.s3.amazonaws.com/0/73358/995814aa-c2fc-a511-3964-2177f2f1985a.png)

##### API Gateway

AWS Api GatewayにてWebhookのエントリポイントとなるAPIを作成します．APIはPOSTリクエストを受け取ると，LambdaのinteractoinHandlerを実行するように設定します．
![screenshot2.png](https://qiita-image-store.s3.amazonaws.com/0/73358/a715a677-9eba-d49a-a48c-c0780588b476.png)

設定できたらAPIのURLを先程のSlackの「Interactive Components」に設定します．

##### Lambda: Slack系の処理

API Gateway経由で実行されるLambdaを作成します．引数のeventにslackでボタンを押したユーザや押したボタンの識別子が含まれています．それを取得しましょう．

APIGatewayEventのbodyに含まれています．しかしながら次のようになっています．

```
body: 'payload=%7B%22type%22%3A%22interactive_message%22%2C.....'
```

``payload=``の右側の値を取得し，URLデコードし，JSON.parseする必要があります．

```ts
protected geSlacktPayload(event: APIGatewayEvent): InteractiveMessagePayload {
        const body = event.body;
        if(body === null) {
            throw new HttpError(400, "request body is null");
        }
        const payload = body.split('&').map((param) => {
            const keyValue = param.split('=');
            return {key: keyValue[0], value: keyValue[1]};
        }).filter((keyValue) => keyValue.key === 'payload')[0].value;
        
        return JSON.parse(decodeURIComponent(payload)) as InteractiveMessagePayload;
    }
```

https://github.com/hiroyky/shuffle_luncher/blob/master/src/slack-payload-parser.ts#L24-L34

すると次のようなJSONが取得できます．元のメッセージも含めていろいろ取得できています．（一部伏せ字にしています）

```json
{
    "type": "interactive_message",
    "actions": [
        {
            "name": "attendance_2018-5-29",
            "type": "button",
            "value": ""
        }
    ],
    "callback_id": "1527552000",
    "team": {
        "id": "xxxxxxxxx",
        "domain": "<Slackのドメイン名>"
    },
    "channel": {
        "id": "xxxxxxxxxx",
        "name": "general"
    },
    "user": {
        "id": "xxxxxxxxxx",
        "name": "user_name"
    },
    "action_ts": "1525132254.646051",
    "message_ts": "1525109072.000136",
    "attachment_id": "9",
    "token": "xxxxxxxxxxxxxxxxxxxxxxx",
    "is_app_unfurl": false,
    "original_message": {
        "text": "シャッフルランチ出席可能日確認：出席できる日のボタンを全て押してください．4月26日まで",
        "username": "shuffle+luncher",
        "bot_id": "B9SNL3VA4",
        "attachments": [
            {
                "callback_id": "1525132800",
                "title": "5月1日",
                "id": 1,
                "actions": [
                    {
                        "id": "1",
                        "name": "attendance_2018-5-1",
                        "text": "出席できる",
                        "type": "button",
                        "value": "",
                        "style": ""
                    }
                ],
                "fallback": "5月1日"
            },
            {
                "callback_id": "members-1525132800",
                "id": 2,
                "fallback": "[no+preview+available]"
            },
            {
                "callback_id": "1525737600",
                "title": "5月8日",
                "id": 3,
                "actions": [
                    {
                        "id": "2",
                        "name": "attendance_2018-5-8",
                        "text": "出席できる",
                        "type": "button",
                        "value": "",
                        "style": ""
                    }
                ],
                "fallback": "5月8日"
            },
            {
                "callback_id": "members-1525737600",
                "id": 4,
                "fallback": "[no+preview+available]"
            },
            {
                "callback_id": "1526342400",
                "title": "5月15日",
                "id": 5,
                "actions": [
                    {
                        "id": "3",
                        "name": "attendance_2018-5-15",
                        "text": "出席できる",
                        "type": "button",
                        "value": "",
                        "style": ""
                    }
                ],
                "fallback": "5月15日"
            },
            {
                "callback_id": "members-1526342400",
                "id": 6,
                "fallback": "[no+preview+available]"
            },
            {
                "callback_id": "1526947200",
                "title": "5月22日",
                "id": 7,
                "actions": [
                    {
                        "id": "4",
                        "name": "attendance_2018-5-22",
                        "text": "出席できる",
                        "type": "button",
                        "value": "",
                        "style": ""
                    }
                ],
                "fallback": "5月22日"
            },
            {
                "callback_id": "members-1526947200",
                "id": 8,
                "fallback": "[no+preview+available]"
            },
            {
                "callback_id": "1527552000",
                "title": "5月29日",
                "id": 9,
                "actions": [
                    {
                        "id": "5",
                        "name": "attendance_2018-5-29",
                        "text": "出席できる",
                        "type": "button",
                        "value": "",
                        "style": ""
                    }
                ],
                "fallback": "5月29日"
            },
            {
                "callback_id": "members-1527552000",
                "id": 10,
                "fallback": "[no+preview+available]"
            }
        ],
        "type": "message",
        "subtype": "bot_message",
        "ts": "1525109072.000136"
    },
    "response_url": "https://hooks.slack.com/actions/xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    "trigger_id": "357062670966.329981082688.9177125d9306f3a50b8bdbf0071ec9b4"
}
```

例として，以下のように取得できますね．

```js
const req = JSON.parse("上記JSON文字列");
// ボタンをクリックしたユーザ情報
const triggeredUser = req.user;

// どのボタンをクリックしたか
const buttonId = req.callback_id;

// 元のメッセージ
const originalMessage = req.original_message
```

元のメッセージを加工して，既存のメッセージを更新することができます．その際，original_messageのts(おそらくタイムスタンプ)が識別子になるようです．

ここでは，元のメッセージに出席できるユーザ一覧のアッタチメントを追加して，書き換えを行っています．

```ts
protected web = new WebClient();

async updateAttedanceMembers(attedanceMembers: User[], date: Date, org: OriginalMessage) {
        const arg: any = Object.assign({}, org, {
            token: this.env.slackToken,
            channel: this.env.slackChannelId
        });
        const attachments = arg.attachments as Attachment[];
        const index = attachments.findIndex((el, i, arr) => el.callback_id === this.getAttachmentId(date));
        attachments[index] = {
            text: `${attedanceMembers.map(m => m.displayName ? m.displayName : m.name).join(', ')}`,
            callback_id: attachments[index].callback_id,
        };
        this.update(arg);
    }

    protected async update(arg: ChatUpdateArguments) {
        return await this.web.chat.update(arg);
    }
```

![image.png](https://qiita-image-store.s3.amazonaws.com/0/73358/ab520034-591d-2a3e-a265-9ce956567744.png)

##### Lambda: DynamoDBに出席可能者の登録

もちろん加えて，出席できるユーザをDynamoDBに記録しています．

```index.ts
 await memberManager.updateAttendanceOrNot(req.eventTriggeredUser, req.eventTriggeredDate);
```

```member-manager.ts
async updateAttendanceOrNot(user:SlackUser, date:Date) {
        const userData = await this.storageDriver.getUser(user.id);
        const dstValue = !userData.isAttendance(date);
        await this.storageDriver.updateUserAttedance(user.id, date, dstValue);
}
```

```drivers/storage-driver.ts
async updateUserAttedance(slackId: string, date: Date, isAttendance: boolean) {
        const param: DocumentClient.UpdateItemInput = {
            TableName: this.env.dynamoDBTableName,
            Key: { 'slack_id': slackId },
            UpdateExpression: "set #date = :attendace",
            ExpressionAttributeNames: {
                "#date": util.getAttendanceFieldName(date)
            },
            ExpressionAttributeValues: {
                ":attendace": isAttendance
            },
            ReturnValues: 'NONE'
        };
        await this.db.update(param).promise();
}
```

### シャッフルと通知
CloudWatchからcron実行されます．
メンバが「出席できる」と登録した日付を踏まえて，ランダムでメンバを割り振ります．

| 概要 | 内容 |
|:---|:---|
| エントリポイント | [shuffleMembers](https://github.com/hiroyky/shuffle_luncher/blob/master/src/index.ts#L45)|


# リポジトリ
- https://github.com/hiroyky/shuffle_luncher