Cloudflare Workers (+Denoflare) で Discord Bot を作る

この記事は Webhook で interaction を処理する Discord Bot を Cloudflare Worker にデプロイした際の記録です。Discord Bot と Discord サーバー間の通信方法には次の 2 種類があります。

- Gateway: WebSocket で双方向通信する方式。 [discord.py](https://discordpy.readthedocs.io/en/stable/) や [discord.js](https://discord.js.org/) といったライブラリが実装しており、Bot の全機能を使用できる。
- Webhook: Interaction ごとに HTTP リクエストを使用する方式。Discord と Bot クライアントが相互にリクエストを送受信する。

後者では WebSocket のコネクションを維持しなくてよいためサーバーレスと相性が良くお財布にも優しいです。今回はこの方式を用いて作成した Bot を Cloudflare Workers にデプロイしました。

https://discord.com/developers/docs/interactions/overview#preparing-for-interactions

今回は Bot の作成に TypeScript を用いましたが、Cloudflare Workers がサポートする他の言語や Wasm サーバーをデプロイすることもできます。またデプロイには [Denoflare](https://denoflare.dev/) を用いましたが [Wrangler](https://developers.cloudflare.com/workers/wrangler/) でも同様の手順です。

## 環境の作成

[Denoflare](https://denoflare.dev/cli/) のドキュメントに従ってセットアップします。デプロイ情報を Git に記録するためレポジトリに `denoflare.jsonc` を配置し、認証情報は `.env` ファイルに配置します。

```jsonc:denoflare.jsonc
{
  "$schema": "https://raw.githubusercontent.com/skymethod/denoflare/v0.7.0/common/config.schema.json",
  "scripts": {
    "your-bot-name": {
      "path": "./src/mod.ts",
      "compatibilityDate": "2025-05-29",
      "observability": true,
      "bindings": {
        "BOT_PUBLIC_KEY": {
          "value": "public key of your bot"
        }
      }
    }
  }
}
```

deno.json は以下のように設定します。Denoflare をインストールしている場合は `denoflare` のタスクは必要ありませんが、`--env-file` オプションが使えないため `.env` から認証情報を読み込む際は代替手段を用意する必要があります。

:::note
`src/mod.ts` はオブジェクト `{ fetch: (req: Request): Promise<Response> }` をデフォルトエクスポートしているため、`deno serve` コマンドで直接サーバーを建てられます。Cloudflare Workers で提供される `env` や `ctx` を使用するには適宜ラップする必要があります。`denoflare serve` を使う場合は一部のメソッドが使用できなくなります。
:::

```json:deno.json
{
  "tasks": {
    "dev": "deno serve -E --env-file --port 8080 --watch ./src/mod.ts",
    "deploy": "deno task denoflare push your-bot-name --config ./denoflare.jsonc",
    "denoflare": "deno run --env-file -A --unstable-worker-options https://raw.githubusercontent.com/skymethod/denoflare/v0.7.0/cli/cli.ts",
    "tail": "deno task denoflare tail your-bot-name"
  }
}
```

現段階で src/mod.ts はこのような内容になっていると思います。この状態でデプロイしても問題ありませんが、Discord のリクエストを正しく処理できないためエンドポイントを有効化する (後述) ことができません。

```typescript:src/mod.ts
export default {
    async fetch(req: Request, env: Record<string, string>): Promise<Responce> {
        return new Response("Ok", { status: 200 });
    }
}
```

## Developper Portal の設定

Gateway タイプの Bot の登録をする際には Developper Portal からトークンを取得し、それを Bot に設定して用います。Webhook の場合は Interactions Endpoint URL を設定します。設定を保存する際に Discord が特定のリクエストをエンドポイントに送信し、期待されるレスポンスが帰ってきたらエンドポイントが有効化されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1291112/fda4d546-1d5a-4edc-b43b-68e4396bb8ac.png)

## 署名の検証

Gateway 方式では Bot がトークンを用いて Discord サーバーと通信します。Discord 側が正しい Bot クライアントかどうかを判定する格好です。一方 Webhook 方式では Bot サーバーが正しい Discord サーバーから送られたリクエストかを判定する必要があります。そのためには Bot ごとに発行される公開鍵を用いてリクエストの署名を検証します。

Discord から送られるリクエストにはヘッダーに `X-Signature-Ed25519` と `X-Signature-Timestamp` を設定します。署名検証に失敗した場合は 401 を返さなければならず、サボるとエンドポイントの有効化に失敗します。

公式の [`discord-interactions` npm パッケージ](https://github.com/discord/discord-interactions-js/tree/main) では署名検証メソッドと一部の定数定義を提供しています。`verifyKey` メソッドを用いることで次のように署名を検証することができます。

```typescript:src/mod.ts
import { verifyKey } from "npm:discord-interactions";

export default {
	async fetch(request: Request, env: Record<string, string>): Promise<Response> {
        // POST 以外は 405 を返す
		if (request.method !== 'POST') {
			return new Response('Method Not Allowed', { status: 405 });
		}

        // `/` 以外のリクエストは 404 を返す
		const url = new URL(request.url);
		if (url.pathname !== '/') {
			return new Response('Not Found', { status: 404 });
		}

        // 署名関係のヘッダーが設定されていなかったら 401 を返す
		const signature = request.headers.get("X-Signature-Ed25519");
		const timestamp = request.headers.get("X-Signature-Timestamp");
		if (!signature) {
			return new Response('X-Signature-Ed25519 header is missing', { status: 401 });
		}
		if (!timestamp) {
			return new Response('X-Signature-Timestamp header is missing', { status: 401 });
		}

		const body = await request.arrayBuffer();

		const vefified = await verifyKey(body, signature, timestamp, env.DISCORD_BOT_PUBLIC_KEY);
		if (!vefified) {
			return new Response('Unauthorized', { status: 401 });
		}

        // 決め打ちで PONG を返す
		return new Response(JSON.stringify({ type: 1 }), {
            status: 200,
            headers: {
                "Content-Type": "application/json",
            },
        });
	},
};
```

ここで返している `PONG` は `PING` (`{"type": "1"}` の POST リクエスト) に対する応答です。エンドポイントの有効化のときは `PING` しか飛んでこないのでとりあえず何でも `PONG` を返すことにしています。ヘッダーに `Content-Type` を設定しないと (`PING` は問題ありませんが) アプリケーションコマンドの応答ができなくなります (1敗)。

この時点でデプロイするとエンドポイントを有効化できるようになるはずです。

:::note
[公式ドキュメント](https://discord.com/developers/docs/interactions/overview#setting-up-an-endpoint-validating-security-request-headers) ではなぜか公式の署名検証ライブラリである `discord-interactions` ではなく `tweetnacl` パッケージを用いた例が示されています。
:::

:::note warn
リクエストボディの取得には `arrayBuffer` メソッドを使用しています。[`bytes`](https://developer.mozilla.org/ja/docs/Web/API/Request/bytes) メソッドも存在しますが Cloudflare のドキュメンテーションには正式に存在していません。
:::

<details>
<summary>ライブラリを使わずに署名検証する</summary>

Cloudflare Workers では Web Crypto API の多くのメソッドに対応しており、`ED25519` 方式の署名検証にも対応しています。`SubtleCrypto` のメソッドを用いることで署名検証を行うことができます (`discord-interactions` も利用可能な場合 `SubtleCrypto` を利用し、Node 環境では別のライブラリを使用しています)。

せっかくなので Web 標準 API で実装してみます。`verifyKey` に対応するメソッドは次のように実装できます。

```typescript
export async function verify(
	body: ArrayBuffer,
	signature: string,
	timestamp: string,
	publicKey: string
): Promise<boolean> {
	const key = await crypto.subtle.importKey(
		"raw",
		hexToBytes(publicKey),
		{ name: "ed25519" },
		false,
		["verify"]
	);
	const signatureBytes = hexToBytes(signature);
	const timestampBytes = new TextEncoder().encode(timestamp);
    
	const data = concatUint8Array(timestampBytes, new Uint8Array(body));

	return crypto.subtle.verify({ name: "ed25519" }, key, signatureBytes, data);
}

// hex string を Uint8Array にする
function hexToBytes(hex: string): Uint8Array {
	const bytes = new Uint8Array(hex.length / 2);
	for (let i = 0; i < bytes.length; i += 1) {
		bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
	}
	return bytes;
}

// 2 つの Uint8Array を連結する
function concatUint8Array(arr1: Uint8Array, arr2: Uint8Array): Uint8Array {
	const result = new Uint8Array(arr1.length + arr2.length);
	result.set(arr1);
	result.set(arr2, arr1.length);
	return result;
}
```
</details>

## Interaction を処理する

エンドポイントに指定されると `fetch` 関数には interaction が POST されるようになります。例えば `PING` に応答するには次のようにします。

```typescript:src/mod.ts
const message = JSON.parse(body);
if(message.type === 1) {
    return new Response(JSON.stringify({ type: 1 }), {
        status: 200,
        headers: {
            "Content-Type": "application/json",
        },
    });
}
```

## アプリケーションコマンドを使う

アプリケーションコマンド (スラッシュコマンドなど) を使用するにはコマンドを登録し、`PING` と同様に処理を追加する必要があります。コマンドは特定の API を叩くことで追加することができます。変更時にだけ叩けばよいため Actions などでデプロイ時に実行するとｙいと思います。

ダイスロールを行うコマンドの例です。次のようなコードでスラッシュコマンドを登録することができ、ユーザーがコマンドを使用するとサーバーに `type: 2` の interaction が飛んできます。詳しいスキーマなどはドキュメントを参照してください。

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

```typescript
const diceCommand = {
  type: 1, // スラッシュコマンド
  name: "dice",
  description: "Roll a dice",
  options: [
    {
      type: 3, // string
      name: "dice",
      description: "ダイスの設定 (例: 1d6)",
      required: true,
    },
  ],
};

const applicationId = Deno.env.get("APPLICATION_ID");
if (!applicationId) {
  throw new Error("APPLICATION_ID environment variable is not set");
}

const botToken = Deno.env.get("BOT_TOKEN");
if (!botToken) {
  throw new Error("BOT_TOKEN environment variable is not set");
}

// deno-fmt-ignore
const url = `https://discord.com/api/v10/applications/${applicationId}/commands`;
const headers = {
  "User-Agent": "DiscordBot (full-scratch, 0.1.0)",
  Authorization: `Bot ${botToken}`,
  "Content-Type": "application/json",
} satisfies HeadersInit;

const commands = [diceCommand];
const encoder = new TextEncoder();

for (const command of commands) {
  await Deno.stdout.write(
    encoder.encode(`Registering command: ${command.name}...`),
  );
  const res = await fetch(url, {
    headers,
    method: "POST",
    body: JSON.stringify(diceCommand),
  });

  if (!res.ok) {
    const errorText = await res.text();
    await Deno.stdout.write(encoder.encode(
      `failed (${res.status})\nResponce: ${errorText}\n`,
    ));
  } else {
    const body = await res.json();
    await Deno.stdout.write(encoder.encode(
      `success (ID: ${body.id})\n`,
    ));
  }
}
```