middyを使ってDiscord BotのInteractionsの制御処理とメインロジックを分離する！

## 🛠️作るもの

- AWS LambdaでDiscordのボットのスラッシュコマンド(Application Commands)をWebhookで受け取り、処理を行うサーバレスアプリケーション
- TypeScriptとAWS CDKを用いて実装
- **`middy`を使って、後述するDiscord特有の処理とビジネスロジックを分けて簡潔に書く**

:::note
- **middyを使ってDiscord APIのInteractionsを制御する部分**の説明がメインなので、それ以外の部分は端折って書いています
- CDKの使い方やDiscordのApplicationの作り方については記載していません
:::

ディレクトリ構成などの詳細は、この記事の検証用に実装した以下のリポジトリを確認してください。

https://github.com/Ry0xi/discord-interactions-middlewares

## ℹ️Discord APIのInteractionsとは

Discordボットのスラッシュコマンド(`Application Commands`)から受け取るメッセージのことを`Interactions`と言います。

Interactionsを扱うには、メイン処理以外に以下の実装を行う必要があります。

- `PING`メッセージに対応する
- 署名ヘッダーを基に認証を行う

https://discord.com/developers/docs/interactions/receiving-and-responding

これらの処理をメインの処理と同じ場所に記述すると複雑なコードになってしまうため、この記事ではそれぞれの処理をmiddyを使ってミドルウェアとして実装します。

## 🌟middyとは

`middy`は、AWS Lambda関数を容易に作成し管理するためのNode.jsフレームワークです。このフレームワークは、ミドルウェアパターンを採用しています。これにより、メインのビジネスロジックとその他の機能（認証、バリデーション、エラーハンドリングなど）を分離し、それぞれを個別のミドルウェアとして扱うことができます。

https://middy.js.org/

## 💻実装

今回は、テストコードは書かずに実際に動かして動作確認します。

### AWS CDKでLambda関数を作成

`discord-interactions-middlewares`というディレクトリ名でTypeScriptのプロジェクトを作成しました。

```sh
cdk init app --language=typescript
```

`@middy/core`をLambda関数の依存関係に追加して、まずはデプロイ確認のための仮の処理として、環境変数やイベントをログに出力して200レスポンスを返す簡単な処理を実装しました。

```handlers/discord-bot-handler/index.ts
import middy from '@middy/core';
import type {
    APIGatewayProxyEventV2,
    APIGatewayProxyResultV2,
} from 'aws-lambda';

import { getEnv } from '@/handlers/utils';

const handleInteraction = async (
    event: APIGatewayProxyEventV2,
): Promise<APIGatewayProxyResultV2> => {
    console.log('Start handling interaction.');
    console.log('SSM_PREFIX:', getEnv('SSM_PREFIX'));
    console.log('event:', event);

    return {
        statusCode: 200,
        body: 'ok',
    };
};

export const handler = middy().handler(handleInteraction);
```


<details><summary>CDKのスタック</summary>

Node.jsランタイムで動作するLambda関数にSSMのパラメータストアにアクセスするためのポリシーをアタッチしています。また、後ほどDiscordのボットの設定でINTERACTIONS ENDPOINT URLを設定するため、Lambda Function URLを設定しています。

```lib/discord-interactions-middlewares-stack.ts
import path = require('path');

import * as cdk from 'aws-cdk-lib';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { FunctionUrlAuthType } from 'aws-cdk-lib/aws-lambda';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import type { Construct } from 'constructs';

export interface DiscordInteractionsMiddlewaresStackProps
    extends cdk.StackProps {
    ssmPrefix: string;
}

export class DiscordInteractionsMiddlewaresStack extends cdk.Stack {
    constructor(
        scope: Construct,
        id: string,
        props: DiscordInteractionsMiddlewaresStackProps,
    ) {
        super(scope, id, props);

        const lambdaPolicy = new iam.Policy(this, 'lambdaPolicy', {
            statements: [
                new iam.PolicyStatement({
                    effect: iam.Effect.ALLOW,
                    actions: [
                        'kms:Decrypt',
                        'ssm:GetParametersByPath',
                        'ssm:GetParameters',
                        'ssm:GetParameter',
                    ],
                    resources: [
                        'arn:aws:kms:*:*:key/CMK',
                        `arn:aws:ssm:*:*:parameter/${props.ssmPrefix}/*`,
                    ],
                }),
            ],
        });

        const discordInteractionsHandler = new NodejsFunction(
            this,
            'discordInteractionsHandler',
            {
                entry: path.join(
                    __dirname,
                    '../handlers/discord-bot-handler/index.ts',
                ),
                handler: 'handler',
                depsLockFilePath: path.join(
                    __dirname,
                    '../handlers/package-lock.json',
                ),
                runtime: lambda.Runtime.NODEJS_20_X,
                bundling: {
                    minify: true,
                    sourceMap: true,
                    // https://middy.js.org/docs/best-practices/bundling#bundlers
                    externalModules: [],
                },
                environment: {
                    SSM_PREFIX: props.ssmPrefix,
                },
            },
        );
        discordInteractionsHandler.role?.attachInlinePolicy(lambdaPolicy);
        discordInteractionsHandler.addFunctionUrl({
            authType: FunctionUrlAuthType.NONE,
        });
    }
}
```

```bin/discord-interactions-middlewares.ts
#!/usr/bin/env node
import 'source-map-support/register';

import * as cdk from 'aws-cdk-lib';
import * as dotenv from 'dotenv';

import { DiscordInteractionsMiddlewaresStack } from '@/lib/discord-interactions-middlewares-stack';
import { getRequiredEnv } from '@/lib/utils';

dotenv.config();

const env = {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
};

const app = new cdk.App();
new DiscordInteractionsMiddlewaresStack(
    app,
    'DiscordInteractionsMiddlewaresStack',
    {
        env,
        ssmPrefix: getRequiredEnv('SSM_PREFIX'),
    },
);
```

</details>

試しにデプロイしてみて、テストイベントを発行します。

```
cdk deploy
```

AWS Consoleで、Lambdaの関数の詳細ページからテストのリクエストを送信してみます。`API Gateway AWS Proxy`をイベントのテンプレートから選びます。
![スクリーンショット 2023-12-17 181127.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1022001/744f199f-3544-4588-4eea-2eb98a62d1f7.png)

このように200レスポンスが返り、ログに環境変数やイベントが出力されていればOKです。

![スクリーンショット 2023-12-17 181038.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1022001/88c7f58f-e641-2d3b-fd63-a3527e1edc75.png)

### 🌟PINGメッセージを制御するミドルウェアの実装

Discordのインタラクションを扱うのに便利な`discord-interactions`というパッケージがあるので依存関係に追加します。

https://www.npmjs.com/package/discord-interactions

Discordが送信してくるリクエストボディの型を最低限定義します。

https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object-interaction-data

```interaction-event-schema.ts
import type { APIGatewayProxyEventV2 } from 'aws-lambda';
import type { InteractionType } from 'discord-interactions';

export interface DiscordInteractionEvent
    extends Omit<APIGatewayProxyEventV2, 'body'> {
    body: InteractionBodyType;
}

export interface InteractionBodyType {
    id: string;
    application_id: string;
    type: InteractionType;
    token: string;
    // type: 1の時以外は存在する
    // https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object-interaction-data
    data?: ApplicationCommandInteractionData;
}

export interface ApplicationCommandInteractionData {
    id: string;
    type: ApplicationCommandType;
    name: string;
    // Discordのパラメータ上ではオプショナルだが、コマンドの情報のために必須
    options: Array<ApplicationCommandInteractionDataOption>;
}

export interface ApplicationCommandInteractionDataOption {
    name: string;
    value: string | number | boolean;
    options?: Array<ApplicationCommandInteractionDataOption>;
}

// https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-types
export enum ApplicationCommandType {
    CHAT_INPUT = 1,
    USER = 2,
    MESSAGE = 3,
}
```

PINGメッセージを取り扱うミドルウェアを作成します。

```handlers/middlewares/discord-handle-ping-message.ts
import type middy from '@middy/core';
import type { APIGatewayProxyResultV2 } from 'aws-lambda';
import { InteractionType } from 'discord-interactions';

import type { DiscordInteractionEvent } from '@/handlers/interaction-event-schema';

const discordHandlePingMessageMiddleware = (): middy.MiddlewareObj<
    DiscordInteractionEvent,
    APIGatewayProxyResultV2
> => {
    /**
     * PING - PONG
     *
     * @see https://discord.com/developers/docs/interactions/receiving-and-responding#receiving-an-interaction
     */
    const discordHandlePingMessageMiddlewareBefore: middy.MiddlewareFn<
        DiscordInteractionEvent,
        APIGatewayProxyResultV2
    > = async (request): Promise<APIGatewayProxyResultV2 | void> => {
        const interactionType = request.event.body.type;

        if (interactionType === InteractionType.PING) {
            return {
                statusCode: 200,
                body: JSON.stringify({
                    type: InteractionType.PING,
                }),
                headers: {
                    'Content-Type': 'application/json',
                },
            };
        }
    };

    return {
        before: discordHandlePingMessageMiddlewareBefore,
    };
};

export default discordHandlePingMessageMiddleware;
```

上記のミドルウェアをハンドラに追加します。
ついでに、middyには便利なミドルウェアが用意されているためいくつか導入します。

```discord-bot-handler/index.ts
import middy from '@middy/core';
import httpErrorHandlerMiddleware from '@middy/http-error-handler';
import httpHeaderNormalizerMiddleware from '@middy/http-header-normalizer';
import httpJsonBodyParserMiddleware from '@middy/http-json-body-parser';
import inputOutputLoggerMiddleware from '@middy/input-output-logger';
import type {
    APIGatewayProxyEventV2,
    APIGatewayProxyResultV2,
} from 'aws-lambda';

import discordHandlePingMessageMiddleware from '@/handlers/middlewares/discord-handle-ping-message';
import { getEnv } from '@/handlers/utils';

const handleInteraction = async (
    event: APIGatewayProxyEventV2,
): Promise<APIGatewayProxyResultV2> => {
    console.log('Start handling interaction.');
    console.log('SSM_PREFIX:', getEnv('SSM_PREFIX'));
    console.log('event:', event);
    return {
        statusCode: 200,
        body: 'ok',
    };
};

export const handler = middy()
    // input and output logging
    // https://middy.js.org/docs/middlewares/input-output-logger/
    .use(inputOutputLoggerMiddleware())
    // normalize HTTP headers to lowercase
    // https://middy.js.org/docs/middlewares/http-header-normalizer
    .use(httpHeaderNormalizerMiddleware())
    // parse HTTP request body and convert it into an object
    // https://middy.js.org/docs/middlewares/http-json-body-parser
    .use(httpJsonBodyParserMiddleware())
    .use(discordHandlePingMessageMiddleware())
    // handle uncaught errors that contain the properties statusCode and message and creates a proper HTTP response for them
    // https://middy.js.org/docs/middlewares/http-error-handler
    .use(httpErrorHandlerMiddleware())
    .handler(handleInteraction);
```

動作確認は、Lambda Function URLを有効化しているのでCLIからcurlでリクエストを送ってみます。

curlで自分のデプロイしたLambda関数の関数URLにリクエストを送信してください。

```shell
curl 'https://6pgjnfyzwvb536p2bejnh6tcra0snzxj.lambda-url.ap-northeast-1.on.aws/' -d '{"type":1}' -H 'Content-Type:application/json'
```

`{"type": 1}`をリクエストボディのJSON文字列に含めた際に、`{"type":1}`と返ってきたらOKです。

### 🌟認証を行うミドルウェアの実装

Interactionsの認証の手順はこちらに記載されています。

https://discord.com/developers/docs/interactions/receiving-and-responding#security-and-authorization

`discord-interactions`にInteractionsの認証関連の処理を行うメソッドが含まれているので、それを使ってミドルウェアを実装します。

```handlers/middlewares/discord-authorization.ts
import type middy from '@middy/core';
import { createError } from '@middy/util';
import type { APIGatewayProxyResult } from 'aws-lambda';
import { verifyKey } from 'discord-interactions';

import type { DiscordInteractionEvent } from '@/handlers/interaction-event-schema';
import { getEnv, getParameter } from '@/handlers/utils';

const discordAuthorizationMiddleware = (): middy.MiddlewareObj<
    DiscordInteractionEvent,
    APIGatewayProxyResult
> => {
    /**
     * Discord Authorization
     *
     * @see https://discord.com/developers/docs/interactions/receiving-and-responding#security-and-authorization
     */
    const discordAuthorizationMiddlewareBefore: middy.MiddlewareFn<
        DiscordInteractionEvent,
        APIGatewayProxyResult
    > = async (request): Promise<APIGatewayProxyResult | void> => {
        const headers = request.event.headers;
        const signature = headers['x-signature-ed25519'];
        const timestamp = headers['x-signature-timestamp'];
        const publicKey = await getParameter(
            `/${getEnv('SSM_PREFIX')}/discordPublicKey`,
        );

        if (
            !signature ||
            !timestamp ||
            !publicKey ||
            !verifyKey(request.event.rawBody, signature, timestamp, publicKey)
        ) {
            throw createError(401, 'discord authorization failed.');
        }
    };

    return {
        before: discordAuthorizationMiddlewareBefore,
    };
};

export default discordAuthorizationMiddleware;
```

上記のミドルウェアをハンドラーに追加します。

また、オブジェクトへ変換する前のリクエストボディのJSON文字列が認証に必要ですが、`http-json-body-parser`ミドルウェアによって元の文字列が削除されてしまうため、別のパラメータ(`rawBody`)としてイベントに保持するためのインラインのミドルウェアも追加します。

```handlers/discord-bot-handler/index.ts
export const handler = middy<APIGatewayProxyEventV2>()
    .use(inputOutputLoggerMiddleware())
    .use(httpHeaderNormalizerMiddleware())
    // add raw body for discord-authorization
    .before((request) => {
        (
            request.event as APIGatewayProxyEventV2 & { rawBody?: string }
        ).rawBody = request.event.body;
    })
    .use(httpJsonBodyParserMiddleware())
    .use(discordAuthorizationMiddleware())
    .use(discordHandlePingMessageMiddleware())
    .use(httpErrorHandlerMiddleware())
    .handler(handleInteraction);
```

```handlers/interaction-event-schema.ts
export interface DiscordInteractionEvent
    extends Omit<APIGatewayProxyEventV2, 'body'> {
    body: InteractionBodyType;
    rawBody: string; // added
}
```

後はDiscordのApplicationを作成して動作確認を行います。

### 🤖Discord Bot(Application)の作成

以下のドキュメントなどを参考にApplicationを作成し、BotとしてDiscordサーバー(Guild)に登録します。

https://discord.com/developers/docs/getting-started#step-1-creating-an-app

Applicationを作成したら、Discord Developer Potalからアプリの公開鍵を取得し、`.env`に指定します。その公開鍵をパラメータストアに登録する以下のCDKの処理を追加してデプロイします。

```lib/discord-interactions-middlewares-stack.ts
[{ key: 'discordPublicKey', value: `${props.discordPublicKey}` }].map(
    (kv) => ({
        kv: kv,
        param: new ssm.StringParameter(this, kv.key, {
            allowedPattern: '.*',
            description: `${kv.key}`,
            parameterName: `/${props.ssmPrefix}/${kv.key}`,
            stringValue: kv.value,
            tier: ssm.ParameterTier.STANDARD,
        }),
    }),
);
```

今回はWebhook経由でInteractionsを受け取るので、Discord Developer PotalからApplicationの`INTERACTIONS ENDPOINT URL`にデプロイしたLambda Function URLを指定して更新します。

![スクリーンショット 2023-12-18 230808.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1022001/94f0ff22-ae10-04f4-fa00-57c15de43a13.png)

:::note warn
ここでミドルウェアの動作確認が可能です。
URLの登録エラーが発生したら何かしらの処理が間違っているので、Lambdaのログを見てデバッグし修正する必要があります。
:::

### 🎉メインのビジネスロジックを実装する

後は自由に`handleInteraction`メソッドにビジネスロジックを実装します。

今回は簡単に、コマンドのオプションとして送られてきた文字列をそのままメッセージとして返答する処理にしました。

```handlers/discord-bot-handler/index.ts
import type {
    APIGatewayProxyEventV2,
    APIGatewayProxyResultV2,
} from 'aws-lambda';
import { InteractionResponseType, InteractionType } from 'discord-interactions';

import type { DiscordInteractionEvent } from '@/handlers/interaction-event-schema';

// Updated
const handleInteraction = async (
    event: DiscordInteractionEvent,
): Promise<APIGatewayProxyResultV2> => {
    if (event.body.type === InteractionType.APPLICATION_COMMAND) {
        return {
            statusCode: 200,
            body: JSON.stringify({
                type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
                data: {
                    content:
                        event.body.data?.options?.[0]?.value ??
                        'you can type any text.',
                },
            }),
            headers: {
                'Content-Type': 'application/json',
            },
        };
    }

    return {
        statusCode: 400,
        body: JSON.stringify({
            message: 'Bad Request',
        }),
    };
};
```

:::note
middyを使ってメインのビジネスロジックと他の処理が分割して実装したので、ビジネスロジックの実装に集中できるようになりました！！
:::

---

あとは、スラッシュコマンドを使えるようにアプリケーションに登録します。

https://discord.com/developers/docs/interactions/application-commands#create-global-application-command

<details><summary>コマンドを追加するスクリプト</summary>

```discord-scripts/add-command.json
{
  "name": "hello",
  "type": 1,
  "description": "hello...?",
  "options": [
    {
      "name": "text",
      "description": "anything you want to say",
      "type": 3,
      "required": true
    }
  ]
}
```

```sh
curl -X POST \
  "https://discord.com/api/v10/applications/$YOUR_APPLICATION_ID/commands" \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bot $YOUR_BOT_TOKEN" \
  -d "@./discord-scripts/add-command.json"
```

</details>

コマンドが登録出来たら、Botを招待したDiscordの任意のチャンネルからスラッシュコマンドを利用できます。

![スクリーンショット 2023-12-18 235039.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1022001/5e5399aa-52e3-4390-d631-0f793f14367c.png)

![スクリーンショット 2023-12-18 235046.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1022001/d12dac50-1bf2-1b1c-9477-6c02c9a76507.png)


## 📍まとめ

- middyを使うと、リクエスト毎の認証認可やロギングなどの処理をミドルウェアとして実装できる
- ミドルウェアとして実装することで、Lambda関数の開発でもビジネスロジックの実装に集中できる