朝活が辛いので推しに応援してもらいながら朝活頑張るbotを作ったよ

この記事は[ぷりぷりあぷりけーしょんず Advent Calendar 2020](https://qiita.com/advent-calendar/2020/ppap)の10日目を穴埋めする記事です。

## 朝活をしたいけど寒くて辛い

最近、仕事前の切り替えスイッチにと朝活を始めようと決心したのですが見事に3日坊主で終わりました。
本当にきっかり3日で終わった。ことわざ？凄い！

## どんな物事が継続できるか？

こんな私でも今月頭からしっかり継続できていることがあります。
それが[FitBoxing2](https://fitboxing.net/2/)です

![fitboxing.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/227898/07c20e5e-b7a6-fe1d-906f-59f819f56efc.jpeg)

毎日30分ほどのキツいコースを可愛い女の子（orイケメン）インストラクターが褒めたり応援してくれたりしながらこなしていくゲームです。

つまり

- 寒い
- 誰も褒めてくれない
- 誰も応援してくれない
- 寒い

大体こんな理由でやめてしまった朝活も可愛い女の子が褒めてくれたり応援してくれたら継続できるような予感がします。

## 朝活botを作ったよ

Slackに朝活チャンネルを作り、毎朝褒めたり応援したりしてくれるbotを作りました。

- SlackのEventAPIでLambdaを発火  
  - channelにjoinしたらユーザー登録
  - botにメンションしたら朝活記録を登録
  - channelから去ったらユーザー削除
- CloudWatch Eventsで毎朝応援通知

データの格納先はDynamoDB、AWSの各サービスはCDKで管理していきます。

### bot

日本のKAWAIIの代表といえば、そうこの方ですね

![asakatsu-bot2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/227898/20181eef-b7a0-559b-968e-0b9ca884d098.png)

[Slack App](https://api.slack.com/apps)からアプリを作成しBotイメージに上記の写真を設定し、この辺りの権限を付与しておいてあげます。

![権限.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/227898/58381d56-1ddf-249d-7fa3-f984a3280863.png)

### CDK

AWS CDKで各サービスを定義していきます。

#### Lambda
今回は弊コミュニティの素晴らしいエンジニアが[記事にしていた](https://qiita.com/misaosyushi/items/104445be7d7d3ba304bc)NodejsFunctionを使ってLambdaを定義します。

```typescript
const handler = new NodejsFunction(this, 'asakatsu-handler', {
    functionName: `${appName}-handler`,
    timeout: Duration.seconds(10),
    runtime: lambda.Runtime.NODEJS_12_X,
    initialPolicy: [
        new iam.PolicyStatement({
            actions: ['dynamodb:*'],
            effect: iam.Effect.ALLOW,
            resources: [
                Stack.of(this).formatArn({
                    service: 'dynamodb',
                    resource: 'table',
                    resourceName: 'Asakatsu*'
                })
            ]
        }),
        new iam.PolicyStatement({
            actions: ["ssm:GetParameter"],
            effect: iam.Effect.ALLOW,
            resources: [
                Stack.of(this).formatArn({
                    service: "ssm",
                    resource: "parameter",
                    resourceName: "asakatsu-bot/*",
                }),
            ],
        }),
    ]
});
```

NodejsFunctionで定義するとランタイムがなぜかデフォルトで`NODEJS_10_X`になってしまうので最新の`NODEJS_12_X`を指定しておきます。

今までは定義した関数のファイルの場所を指定しなければなりませんでしたが

> (experimental) Path to the entry file (JavaScript or TypeScript).
If the NodejsFunction is defined in stack.ts with my-handler as id (new NodejsFunction(this, 'my-handler')), the construct will look at stack.my-handler.ts and stack.my-handler.js.

とのことなので`asakatsu-bot-stack.asakatsu-handler.ts`という少々長ったらしいファイルではありますがそこにLambda関数を用意してあげることにします。

また、最新のNodejsFunctionはesbuildを使ってビルドを行なっていますがローカルにesbuildがないととんでもないサイズのdockerイメージでビルドされてしまうので予め`npm install --save-dev esbuild`しておきます。

```bash
$ docker image list
REPOSITORY                                                             TAG                 IMAGE ID            CREATED             SIZE
cdk-baf5f3dcfbe97271b31c2605b638d0610712bf0d176333d75863abaeb2ec6ddb   latest              e51cd2a7cb5e        4 days ago          1.9GB
amazon/aws-sam-cli-build-image-nodejs12.x                              latest              2eada5d9f2c4        2 weeks ago         1.87GB
```

#### DynamoDB

続いてDynamoDBの定義です。
今回NodejsFunctionの恩恵でLambdaのhandlerと一緒にビルドしてもらえることになったのでここに定義したテーブルの仕様をCDKとhandlerで二重管理しなくて良いようになりました。

```typescript
import * as cdk from '@aws-cdk/core';
import {RemovalPolicy} from '@aws-cdk/core';
import * as dynamodb from '@aws-cdk/aws-dynamodb';
import {AttributeType} from '@aws-cdk/aws-dynamodb';
import {TableOptions} from '@aws-cdk/aws-dynamodb/lib/table';

interface TableProps extends TableOptions {
    readonly tableName: string;
}

interface DynamoDBTable {
    readonly id: string;
    readonly tableProps: TableProps;
}

export const User: DynamoDBTable = {
    id: 'asakatsu-user',
    tableProps: {
        tableName: 'AsakatsuUser',
        partitionKey: {
            name: 'id',
            type: AttributeType.STRING
        },
        removalPolicy: RemovalPolicy.DESTROY
    }
}

export const Achievement: DynamoDBTable = {
    id: 'asakatsu-achievement',
    tableProps: {
        tableName: 'AsakatsuAchievement',
        partitionKey: {
            name: 'date',
            type: AttributeType.STRING
        },
        sortKey: {
            name: 'user_id',
            type: AttributeType.STRING
        },
        removalPolicy: RemovalPolicy.DESTROY
    },
}

export function createTable(stack: cdk.Stack): void {
    const tables: DynamoDBTable[] = [User, Achievement];
    tables.forEach(table => {
        new dynamodb.Table(stack, table.id, table.tableProps);
    });
}
```

例えばユーザー登録でDynamoDBを呼び出すときはここで定義している`tableName`をそのまま使うことができます。
(おそらくもっとちゃんとテーブル定義を作り込むことはできそうですが今回はざっくりこんな感じで)

```typescript
import {DB} from './common';
import {User} from '../dynamodb';

export async function register(userId: string): Promise<void> {
    try {
        await DB.put({
            TableName: User.tableProps.tableName,
            Item: {
                id: userId
            }
        }).promise();
    } catch (e) {
        throw e;
    }
}
```

#### その他

その他APIGatewayやCloudWatchEventsなどの定義ですが特筆する点もないのでざっくりこんな感じなんだなーと見てもらえればと思います。

```typescript

// cloud watch events
const lambdaTarget = new eventsTarget.LambdaFunction(handler);
new events.Rule(this, `${appName}-event`, {
    ruleName: `${appName}`,
    targets: [lambdaTarget],
    schedule: events.Schedule.cron({
        minute: '30',
        hour: '21',
    })
});

// api gateway
const api = new apigateway.RestApi(this, `${appName}-gw`, {
    restApiName: `${appName}-gw`
});

const integration = new apigateway.LambdaIntegration(handler, {
    proxy: true
});

api.root.addResource(`${appName}`)
    .addMethod('POST', integration);
```

### EventHookされるスクリプト

上述通り、今回はAWSLambdaがEvent発火をトリガーに実行されます。
LambdaはNodejsFunctionの定義の簡易さの恩恵を最大に活かすため、コントローラとなるhandlerだけの定義を中心に以下のようなdir構成にしました。


```bash
.
├── asakatsu-bot-stack.asakatsu-handler.ts
├── asakatsu-bot-stack.ts
├── dynamodb.ts
└── lambda
    ├── common.ts
    ├── index.ts
    ├── notify-handler.ts
    ├── register-handler.ts
    ├── remove-handler.ts
    └── report-handler.ts
```

```typescript:asakatsu-bot-stack.asakatsu-handler.ts
import {
    Response,
    SUCCESS_RESPONSE,
    report,
    register,
    remove, notify,
} from './lambda';


function challenge(challenge: string): Response {
    return {
        'isBase64Encoded': false,
        'statusCode': 200,
        'headers': {'Content-type': 'application/json'},
        'body': JSON.stringify({'challenge': challenge})
    };
}

export async function handler(event: any): Promise<Response> {
    if (event.source == 'aws.events') {
        await notify();
        return SUCCESS_RESPONSE;
    }

    const body = JSON.parse(event.body);

    if (body.challenge) {
        return challenge(body.challenge);
    }

    switch (body.event.type) {
        case 'member_joined_channel':
            await register(body.event.user);
            break;
        case 'member_left_channel':
            await remove(body.event.user);
            break;
        case 'app_mention':
            await report(body.event.user, body.event.text);
            break;
    }

    return SUCCESS_RESPONSE;
}
```

Lambda実行時に受け取る`event`に合わせて呼び出すhandlerを指定するようにしました。
小さなAPIを作るのであれば管理が統一されるのとイベントが増えるたびに上記のhandlerに追加していくだけで良いので楽そうです。

各handlerの詳細についてはDynamoDBにストアする、Slackで応援メッセージを通知するなどのロジックになるので今回は割愛します。

## 完成したよ。

`cdk deploy`をし、SlackAppの設定をworkspaseのchannelにしたら完成です。

![asakatsu-pamyupamyu.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/227898/303e0729-a973-62bf-7dd2-54a6ffdb8bed.png)

できました。これで明日から毎朝ぱみゅぱみゅが応援してくれるのでどんなに寒くて辛くてもお布団から脱出し朝活ができそうです。好きな人に嘘なんてつけないですからね。