この記事は Webhook で interaction を処理する Discord Bot を Cloudflare Worker にデプロイした際の記録です。Discord Bot と Discord サーバー間の通信方法には次の 2 種類があります。
- Gateway: WebSocket で双方向通信する方式。 discord.py や discord.js といったライブラリが実装しており、Bot の全機能を使用できる。
- Webhook: Interaction ごとに HTTP リクエストを使用する方式。Discord と Bot クライアントが相互にリクエストを送受信する。
後者では WebSocket のコネクションを維持しなくてよいためサーバーレスと相性が良くお財布にも優しいです。今回はこの方式を用いて作成した Bot を Cloudflare Workers にデプロイしました。
今回は Bot の作成に TypeScript を用いましたが、Cloudflare Workers がサポートする他の言語や Wasm サーバーをデプロイすることもできます。またデプロイには Denoflare を用いましたが Wrangler でも同様の手順です。
環境の作成
Denoflare のドキュメントに従ってセットアップします。デプロイ情報を Git に記録するためレポジトリに denoflare.jsonc を配置し、認証情報は .env ファイルに配置します。
{
"$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 から認証情報を読み込む際は代替手段を用意する必要があります。
src/mod.ts はオブジェクト { fetch: (req: Request): Promise<Response> } をデフォルトエクスポートしているため、deno serve コマンドで直接サーバーを建てられます。Cloudflare Workers で提供される env や ctx を使用するには適宜ラップする必要があります。denoflare serve を使う場合は一部のメソッドが使用できなくなります。
{
"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 のリクエストを正しく処理できないためエンドポイントを有効化する (後述) ことができません。
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 が特定のリクエストをエンドポイントに送信し、期待されるレスポンスが帰ってきたらエンドポイントが有効化されます。
署名の検証
Gateway 方式では Bot がトークンを用いて Discord サーバーと通信します。Discord 側が正しい Bot クライアントかどうかを判定する格好です。一方 Webhook 方式では Bot サーバーが正しい Discord サーバーから送られたリクエストかを判定する必要があります。そのためには Bot ごとに発行される公開鍵を用いてリクエストの署名を検証します。
Discord から送られるリクエストにはヘッダーに X-Signature-Ed25519 と X-Signature-Timestamp を設定します。署名検証に失敗した場合は 401 を返さなければならず、サボるとエンドポイントの有効化に失敗します。
公式の discord-interactions npm パッケージ では署名検証メソッドと一部の定数定義を提供しています。verifyKey メソッドを用いることで次のように署名を検証することができます。
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敗)。
この時点でデプロイするとエンドポイントを有効化できるようになるはずです。
公式ドキュメント ではなぜか公式の署名検証ライブラリである discord-interactions ではなく tweetnacl パッケージを用いた例が示されています。
リクエストボディの取得には arrayBuffer メソッドを使用しています。bytes メソッドも存在しますが Cloudflare のドキュメンテーションには正式に存在していません。
ライブラリを使わずに署名検証する
Cloudflare Workers では Web Crypto API の多くのメソッドに対応しており、ED25519 方式の署名検証にも対応しています。SubtleCrypto のメソッドを用いることで署名検証を行うことができます (discord-interactions も利用可能な場合 SubtleCrypto を利用し、Node 環境では別のライブラリを使用しています)。
せっかくなので Web 標準 API で実装してみます。verifyKey に対応するメソッドは次のように実装できます。
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;
}
Interaction を処理する
エンドポイントに指定されると fetch 関数には interaction が POST されるようになります。例えば PING に応答するには次のようにします。
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 などでデプロイ時に実行するとyいと思います。
ダイスロールを行うコマンドの例です。次のようなコードでスラッシュコマンドを登録することができ、ユーザーがコマンドを使用するとサーバーに type: 2 の interaction が飛んできます。詳しいスキーマなどはドキュメントを参照してください。
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`,
));
}
}
