discord の slash commands からEC2を操作できるようにしてみた

## はじめに

はじめまして、****@nahiro_tus**** です。突然ですが、皆さんはdiscordのslash commands機能をご存知ですか？

https://discord.com/developers/docs/interactions/application-commands

これはdiscordにコマンドを覚えさせることができる機能で、入力時にコマンドを補完をしてくれたり、コマンドに説明を追加したりできます。
今までのdiscord botはコマンドを覚えて根性で入力するしかありませんでしたが、この機能の登場によりbotを操作するハードルがグッと下がりました。今回はそんなslash commandsを利用してEC2の起動、停止、状態確認を実行できる機能を開発しました。

## 構成図
![Untitled (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1586001/444b07bd-b798-c0e9-ebfa-84ccf0f06961.png)
Lambda関数を分けて多段Lambda構成で実行しています。EC2の起動・停止・状態確認の処理に時間がかかるので、一度appでメッセージを返したあと、各Lambda関数から処理完了メッセージを送っています。具体的な処理内容については後述します。
この方法はクラスメソッドさんの記事を参考にしました。

https://dev.classmethod.jp/articles/discord-interaction-endpoint-deferred-multi-lambda/

## 実装

コード全文はこちらに上げています。(ちょくちょく改良しているので、若干変わっているかもしれません。)

https://github.com/Nalagami/control-ec2-for-lambda

前提条件は以下の様になっています。
- デプロイ可能なaws cliが設定されている
- 制御対象のEC2インスタンスがすでに作成されている

### Discord ****Applications を作成する****

portalページからbotを作成します。

https://discord.com/developers/applications

「New Application」をクリックして新規アプリを作成します。

![Untitled (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1586001/20fde768-330f-0e06-2666-8de2fe10b474.png)


**********************************************************General Information********************************************************** で以下の2つを控えておきます。
- **APPLICATION ID**
- **PUBLIC KEY**

![Untitled (3).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1586001/1fa59605-f3ff-cc32-e792-f69d43d94d51.png)


**bot** から **create token**を選択して、**TOKEN** を発行します。これも後ほど使うので控えておいてください。
(参考画像では、すでにtokenを発行していたので************************Reset token************************になっています。)

![Untitled (4).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1586001/f1d97e53-47c0-b417-61b2-01eb577fa927.png)

### slash commands の登録
discordはslash commandsの登録がAPIでしか行えないので、以下のコードを実行してコマンドを登録します。
```register-slash-commands.py
import requests
import os
import json

commands = {
    "name": "hello",
    "description": "control ec2",
    "options": [
        {
            "name": "action",
            "description": "start/stop/status",
            "type": 3,
            "required": True,
            "choices": [
                {"name": "start", "value": "start"},
                {"name": "stop", "value": "stop"},
                {"name": "status", "value": "status"},
            ],
        },
    ],
}

def main():
    url = f"https://discord.com/api/v10/applications/{os.environ['APPLICATION_ID']}/commands"
    headers = {
        "Authorization": f'Bot {os.environ["BOT_ACCESS_TOKEN"]}',
        "Content-Type": "application/json",
    }
    res = requests.post(url, headers=headers, data=json.dumps(commands))
    print(res.content)

if __name__ == "__main__":
    main()
```

```bash
export BOT_ACCESS_TOKEN="発行したアクセストークン"
export APPLICATION_ID="**APPLICATION ID**"
python register-slash-commands.py
```

### AWSへデプロイ

今回はserverless frameworkを使用してデプロイします。
serverless frameworkの説明は割愛しますが、簡単に言うとサーバレスアプリケーションの構成管理・デプロイツールです。詳しくは[こちら](https://serverless.co.jp/blog/25/)
以下のコマンドを`serverless.yml`がある環境で実行します。

```bash
npm install -g serverless
sls plugin install -n serverless-python-requirements
export BOT_ACCESS_TOKEN="発行したアクセストークン"
export APPLICATION_ID="控えておいたAPPLICATION ID"
export INSTANCE_ID="操作したいインスタンスのID"
export PUBLIC_KEY="控えておいたPUBLIC KEY"
sls deploy
```

デプロイに成功するとAPI gatewayのエンドポイントが発行されます。
![Untitled (5).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1586001/d9409abf-a6f3-192f-2932-19f3507bfef0.png)

こちらのURLをdiscord bot の **INTERACTIONS ENDPOINT URL** に登録します。

![Untitled (6).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1586001/ebf0b8dc-82a4-4dcf-f577-e6275d4d8fbd.png)

保存ボタンを押すとエンドポイントの認証確認が走ります。問題なければ、以下のような表示が出て設定が保存されます。
![Untitled (8).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1586001/a71219eb-beb7-1c97-12b4-5c37aed9686a.png)

### discordサーバへの招待

**bot** と **applications.commands** をつけて招待URLを発行します。

発行されたURLにアクセスして、サーバーにbotを招待します。

![Untitled (7).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1586001/1dab7485-61c6-fc63-4eea-927453747418.png)

### 動作確認

インスタンスが起動しているか確認しましょう。
`/hello status` と実行します。
![Untitled (9).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1586001/a16db395-878e-05c2-cabb-5001e12c7db3.png)
EC2が停止していることが確認できます。
それでは、EC2を起動します。`/hello start`と打ちます。
EC2のステータスチェックが完了するとパブリックIPが送られてきます。

![Untitled (10).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1586001/03ee5795-5698-afe6-05a5-0bf8be8852a8.png)

マネジメントコンソールからも確認してみます。

![Untitled (11).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1586001/0bd525cd-9297-8a10-01c2-7e42a9e45067.png)


指定したインスタンスIDのインスタンスが起動しています。
最後に、EC2を停止します。`/hello stop`と打ちます。

![Untitled (13).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1586001/acdc1f8e-be69-09e2-a8ab-1ba72076113d.png)

停止しました。マネジメントコンソールからも確認します。

![Untitled (12).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1586001/fbf5e55c-db9c-9025-bc0e-fc3813e4b673.png)
きちんと停止していますね。

## コード解説

量が多いので、Lambdaの非同期呼び出し部分とEC2の制御、非同期処理完了時のメッセージ送信3つにしぼって解説します。

### 他Lambda関数の非同期呼び出し

このアプリはappが別のlambda関数を呼び出す構成で成しています。スラッシュコマンドが実行されたとき、appがアクション(startやstopなど)の内容を読み取って、呼び出すlambda関数を分けています。
他のlambda関数の呼び出しにはboto3を利用しています。boto3はAWSのリソースを操作するPythonのライブラリの1つで、lambdaにはデフォルトでインストールされています。

```app.py
token = req.get("token", "")
parameter = {
    "token": token,
    "DISCORD_APP_ID": os.environ["APPLICATION_ID"],
}
payload = json.dumps(parameter)
boto3.client("lambda").invoke(
    FunctionName="discord-slash-command-dev-minecraft-ec2-start",
    InvocationType="Event",
    Payload=payload,
)
# async_ec2_start()
text = "hi " + username + ", server starting up …"

```

boto3でlambda関数を実行するとき、`InvocationType`を`Event`にすることで非同期で別のLambda関数を呼び出すことができます。呼び出すlambda関数に値を渡したいときは、`Payload`に値を渡してあげると呼び出されたLambda関数ハンドラの`event`に値が格納されます。
また、slash commandsは一定時間内にレスポンスを返さないとエラーになるため、一度このLambda関数でメッセージを返してあげます。

```app.py
return {
            "type": 4,  # InteractionResponseType.ChannelMessageWithSource
            "data": {"content": text},
        }
```

### EC2の制御

各Lambda関数ではboto3を用いてEC2の起動・停止・状態の読み取りを行っています。
下のコードはEC2インスタンスを起動する部分です。`wait_until_runnning()`でEC2が起動するまで待っています。

```minecraft-ec2-start.py
response = ec2_client.start_instances(InstanceIds=[instance_id])
            print(response)
            ec2_resource.wait_until_running()
```

起動時はインスタンスのインスタンスステータスとシステムステータスが`OK`になるまで待機しています。

```minecraft-ec2-start.py
while cont:
  status_response = ec2_client.describe_instance_status(
      InstanceIds=[instance_id]
  )
  if (
      status_response["InstanceStatuses"][0]["InstanceStatus"][
          "Status"
      ]
      == "ok"
      and status_response["InstanceStatuses"][0]["SystemStatus"][
          "Status"
      ]
      == "ok"
  ):
      cont = False
  else:
      time.sleep(10)
      total += 10
return {"status": 0, "ip": ec2_resource.public_ip_address}
```

### 非同期処理完了時のメッセージ送信
discord は [`https://discord.com/api/v10/webhooks/{application_id}/{interaction_token}`](https://discord.com/api/v10/webhooks/%7Bapplication_id%7D/%7Binteraction_token%7D)にリクエストすることで一時的に返したメッセージへ返信することができます。

```minecraft-ec2-start.py
requests.post(
        url=f"https://discord.com/api/v10/webhooks/{application_id}/{interaction_token}",
        data=payload,
        headers={
            "Content-Type": "application/json",
        },
    )
```

## まとめ
今回はslash commandsからEC2の起動・停止・状態確認を行えるようにしました。今までのdiscord botはサーバを1台立てて常にdicordと接続している必要がありましたが、slash commandsの登場により大幅にコストを抑えれるようになりました。元々こちらの記事で書いている制御用サーバのサーバーレス化のためにしたものですが、作っていると他にもいろんなことに流用できそうだと思いました。

今後は

- minecraftのサーバの起動・停止が行えるようにする
- minecraftサーバの構築、discord用エンドポイントの構築までterraformコマンドで一発デプロイできるようにする

を予定しています。

## 参考

https://zenn.dev/xianglishan/articles/d8832b84d3772c

https://zenn.dev/azechi/scraps/a98add131e8410

https://note.sarisia.cc/entry/discord-slash-commands/

https://github.com/ker0olos/aws-lambda-discord-bot

https://www.plan-b.co.jp/blog/tech/30863/