無料のCloudflare WorkersでDiscord botを作る

# はじめに
Discordのbotを作るときどうしてますか？
discord.pyあたりはプログラムを常駐させる必要があります。
サーバーを用意するのもお金がかかります。
他にも、VPSを契約したり...
お金もかかるし面倒ですよね。やめよう。

# Cloudflare Workers
Cloudflare Workersなら無料でできちゃう！素晴らしいね！

:::note warn
完全無料というわけでもなく、無料枠の中であれば無料という形です。
それでも1日10万リクエストまで無料なので、よっぽどのことがない限り使い切ることはありません。
:::

# 準備
## Discordのアプリケーションを作成する
まずは[Discord Developer Portal](https://discord.com/developers/applications)に行きます。
"New Application" からアプリを作成...お決まりの流れですね。

## Public Keyを取得
"General Information" にあると思います。
PUBLIC KEYって書いてあるところ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125423/f4ff1b57-01d8-4080-9a08-6c7bbfd795db.png)
これをコピーしてメモしておきます。

## Tokenを取得
"Bot" のところに行って "Reset Token" を押します。
同じくメモ。

## 自分のサーバーに導入
OAuth2のところに飛んで、 "OAuth2 URL Generator" までスクロール。
"bot" にチェックを入れ、一番下までスクロールすると "Generated URL" とか書かれてると思います。
そのURLをコピーして開くとサーバーの選択画面が出てきます。
入れたいサーバーを選んで認証しましょう。

## サーバー側の用意
今回は2つのチャンネルを用意します。
再現する場合は参考にして下さい。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125423/0fa18c26-36c0-4d84-929c-2c3fb3717f07.png)
構成はこんな感じです。
- ボタン
    - ID: 1342746671523823658
- 送信先
    - ID: 1342746684161265684

## インタラクション用のメッセージを送信
HTTP通信が出来るものなら何でもいいです。
ここでは例として[REQBIN](https://reqbin.com/)を使用します。
URLを入力するところに
```
https://discord.com/api/channels/<チャンネルID>/messages
```
を入力します。
そして、 "Body" を開いて "JSON" を選択。
```json
{"embeds":[{"title":"テスト", "description": "テスト用モーダル"}],"components": [{"type":1,"components":[{"type": 2,"label": "送信","style": 3,"custom_id": "submit"}]}]}
```
記入例: 「ボタン」チャンネル (1342746671523823658)にボタンを送信
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125423/5fa13c7e-bac3-4feb-8c1c-af658397b568.png)
次は "Auth" を開きます。
"Custom" を選択して、 "Authorization" のところに
```
Bot <Token>
```
を入力します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125423/87812618-e141-4034-a68c-9e2bd9362375.png)
次は "Header" を開きます。
Keyに "User-Agent" と入力して、Valueには "MyBot" と入力。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125423/ac65d431-cb53-43f4-8d66-452d13f1ac5a.png)
ここまで出来たら右上にある青いSendボタンを押しましょう。
うまく行けば「ボタン」チャンネルにボタンが送信されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125423/ea29b317-3bf0-4538-9312-480b4c5d5284.png)

## Cloudflare Workersを作成
アカウントの作成方法は今回省略します。
[Cloudflare Dashboard](https://dash.cloudflare.com/)を開いて、左側のリストから "Compute (Workers)" を選びます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125423/fca37605-f58a-4b06-9e1f-880386b960ef.png)
こんな画面が出てくるので、右上の "Create" を押します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125423/76429527-ecd0-49e4-9a12-6ab26e8f1250.png)
次は "Create Worker" を押します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125423/958f16cf-09d7-4bb2-81eb-c632dba897ff.png)
するとWorkerを作成する画面になります。
名前は適当でいいです。
今回は例として "cloudflare-workers-discord-bot" にします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125423/801d9178-3b46-4e4a-a730-eff88e5e49a1.png)
そしたら右下にある青い "Deploy" ボタンを押します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125423/3582199e-9041-4cd4-8225-2e0f86225a1f.png)
そのまま右上の青い "Continue to Project" ボタンを押します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125423/f604cff1-31ec-4130-984a-87b5b36cf1c8.png)
そしたらこんな画面に飛びます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125423/7f18cd94-b202-4329-85fd-98aa01a4ce08.png)
右上の "Edit code" ボタンを押して下さい。
こんなマーク:
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125423/7716252c-984c-48c3-80ad-42caa73f3e09.png)
するとこんな画面に飛びます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125423/2d682dad-58b5-4bd7-8f1f-b791439e400c.png)

## コードを設定
今回、サンプルとしてこんなコードを用意しました。
コピペしてコードを上書きしてください。
- "clientPublicKey"
- "sendChannel"
- "botToken"

のそれぞれ3つは先程入手したものに置き換えてください。
```js
const clientPublicKey = "<Public Key>";
const sendChannel = "<「送信先」のチャンネルID>";
const botToken = "<Token>";

async function verifyKey(rawBody, signature, timestamp, clientPublicKey) {
  try {
    const timestampData = valueToUint8Array(timestamp);
    const bodyData = valueToUint8Array(rawBody);
    const message = concatUint8Arrays(timestampData, bodyData);
    const publicKey =
      typeof clientPublicKey === 'string'
        ? await crypto.subtle.importKey(
            'raw',
            valueToUint8Array(clientPublicKey, 'hex'),
            {
              name: 'ed25519',
              namedCurve: 'ed25519',
            },
            false,
            ['verify'],
          )
        : clientPublicKey;
    const isValid = await crypto.subtle.verify(
      {
        name: 'ed25519',
      },
      publicKey,
      valueToUint8Array(signature, 'hex'),
      message,
    );
    return isValid;
  } catch (ex) {
    return false;
  }
}

async function handleRequest(request) {
  const timestamp = request.headers.get("X-Signature-Timestamp") || "";
  const signature = request.headers.get("X-Signature-Ed25519") || "";

  if (!timestamp || !signature) {
    return new Response("[discord-interactions] Invalid signature", { status: 401 });
  }

  const rawBody = new Uint8Array(await request.arrayBuffer());
  const isValid = await verifyKey(rawBody, signature, timestamp, clientPublicKey);

  if (!isValid) {
    return new Response("[discord-interactions] Invalid signature", { status: 401 });
  }

  const body = JSON.parse(new TextDecoder().decode(rawBody)) || {};
  if (body.type === 1) {
    return new Response(JSON.stringify({ type: 1 }), {
      headers: { "Content-Type": "application/json" },
    });
  }
  if (body.type === 3) {
    if (body.data.component_type === 2) {
      if (body.data.custom_id === "submit") {
        return new Response(JSON.stringify({
          type: 9,
          data: {
            title: "テストモーダル",
            custom_id: "submit",
            components: [
              {
                type: 1,
                components: [
                  {
                    type: 4,
                    custom_id: "test",
                    label: "テスト",
                    style: 2,
                    placeholder: "テスト",
                    required: true
                  }
                ]
              }
            ]
          }
        }), {
          headers: { "Content-Type": "application/json" },
        });
      }
    }
  }
  if (body.type === 5) {
    if (body.data.custom_id === "submit") {
      const content = body.data.components[0].components[0].value;
      await fetch(`https://discord.com/api/channels/${sendChannel}/messages`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "Authorization": "Bot "+botToken,
          "User-Agent": "MyBot"
        },
        body: JSON.stringify({
          embeds: [
            {
              "title": "送信されました",
              "fields": [
                {
                  "name": "送信者",
                  "value": `<@${body.member.user.id}>`
                },
                {
                  "name": "入力内容",
                  "value": content
                }
              ]
            }
          ]
        })
      });
      return new Response(JSON.stringify({
        type: 4,
        data: {
          flags: 64,
          embeds: [
            {
              "title": "送信完了",
              "fields": [
                {
                  "name": "入力内容",
                  "value": content
                }
              ]
            }
          ]
        }
      }), {
        headers: { "Content-Type": "application/json" },
      });
    }
  }
  return new Response("OK", { status: 200 });
}

function concatUint8Arrays(arr1, arr2) {
  const merged = new Uint8Array(arr1.length + arr2.length);
  merged.set(arr1);
  merged.set(arr2, arr1.length);
  return merged;
}

function valueToUint8Array(value, format) {
  if (value == null) {
    return new Uint8Array();
  }
  if (typeof value === 'string') {
    if (format === 'hex') {
      const matches = value.match(/.{1,2}/g);
      if (matches == null) {
        throw new Error('Value is not a valid hex string');
      }
      const hexVal = matches.map((byte) => Number.parseInt(byte, 16));
      return new Uint8Array(hexVal);
    }

    return new TextEncoder().encode(value);
  }
  try {
    return new Uint8Array(value);
  } catch (ex) {
    // Runtime doesn't have Buffer
  }
  if (value instanceof ArrayBuffer) {
    return new Uint8Array(value);
  }
  if (value instanceof Uint8Array) {
    return value;
  }
  throw new Error(
    'Unrecognized value type, must be one of: string, Buffer, ArrayBuffer, Uint8Array',
  );
}

export default {
  async fetch(request, env, ctx) {
    var url = new URL(request.url);
    if (url.pathname === "/") {
      return handleRequest(request);
    }
    return new Response(url.pathname);
  },
};
```
ここまで出来たら右上の青いDeployボタンを押します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125423/e69da5fc-2b42-48ed-a26f-e1165d8d2b8a.png)

## botにURLを設定
botの設定画面に戻ります。
"INTERACTIONS ENDPOINT URL" にこのWorkerのURLを入力します。
今回はこれです。
```
https://cloudflare-workers-discord-bot.siyukatu.workers.dev/
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125423/694d370e-2c9b-4338-9f81-b725ba98e850.png)
右下の "Save Changes" で適用。

# 実際に使ってみる
先ほどの「送信」ボタンを押してみます。
するとモーダルが出ます。
内容は何でもいいので、試しに「テスト」と入力してみます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125423/6c156dd5-5054-4e49-bef7-a0db1ef62f23.png)
これで送信すると...
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125423/5abdae3c-7537-4d3d-a49d-228b37ba67bf.png)
もちろん「送信先」チャンネルにも送信されています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125423/1b9d025f-aa4c-47d9-9617-e026620f6dd6.png)

# 他にもできること
ここではボタンとモーダルの例を用いました。
でも、これを活用すればスラッシュコマンドや右クリックメニューの「アプリ」とかも作れます。
もちろん、User Installとかも出来ます。
インタラクションであれば何でも出来ます。
思いつく限りでは、
- チケット管理システム
    - ボタンを押す → モーダルに要件を入れてもらう → Discord APIを叩いてチャンネル作成
- 認証システム
    - KVやSQLを活用すれば出来る気がします。
    - KVは反映が遅い(難点)

などなど...
かなり幅広く応用出来ると思います。無料なのに。

# できないこと
WebSocketの接続が必要になるものなんかは出来ません。
従って、稀に見かける `!help` とかで動くものなんかは現実的ではありません。
一応cronでチャンネルのメッセージ履歴を取得すれば出来なくも無いですが...

# 宣伝
Discordのサーバー掲示板「鯖ちゃんねる」を運営しています。
従来の掲示板とは異なり、「投票」というシステムを実装しています。
サーバーをお持ちなら、ぜひ掲載してみて下さい。
https://discord.sabach.jp/ja

# 参考
- https://discord.com/developers/docs/interactions/receiving-and-responding
- https://github.com/discord/discord-interactions-js