1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

DiscordのスラッシュコマンドbotをCloudflare Workersで作る

1
Posted at

動機

スラッシュコマンドのみで完結するDiscord botを作ることになった。

通常のDiscord botがWebSocketで常時接続なのに対し、スラッシュコマンドのみの場合はHTTPを待ち受けるエントリポイントを用意するだけで済む。
この場合、サーバーレス系のクラウドサービスと相性が良い。

できるだけ無料枠のあるサービスで作りたい。
最初はGoogle Apps Scripts(GAS)なんかが一番無料のまま作り放題なんじゃないかと思って使おうとしたのだが、無理なことが途中で分かって諦めた。

GASが無理な理由 第一に、リクエストのヘッダーを取得できなかったから。Discordのスラッシュコマンドは署名として`X-Signature-Ed25519`, `X-Signature-Timestamp`といった独自のHTTPヘッダーを使用しており、bot側はこれらの値を検証しなければいけない(後述)。しかしどうやらGASではヘッダーの値にアクセスすることはできないようだった。\ 第二に、署名検証のライブラリが無さそうだったから。Ed25519の署名の検証には専門知識(と、おそらくは高度な実装)が必要そうで、通常はサードパーティのライブラリを使う場面のようだ。NPMには`tweetnacl`というライブラリがあるのだが、GASにはNPMモジュールをインポートできるような機能はない。……と思っていたが、今調べ直したら一応clasp使いつつトランスパイルも入れて頑張ればNPMモジュールを導入できるっぽい?これ無理な理由じゃなくなってきたな……ただしアップデートとかも含めてメンテナンスが面倒なのは確か。

ちなみに、直接のエントリポイントとしてではなく、処理の委譲先としてGASを使用している人はいたので紹介しておく。
https://zenn.dev/inaniwaudon/articles/08bd891f106177

そこでGoogle Cloud FunctionsやCloudflare Workers等が次の候補に上がった。
これらの中でどれが良いかはよく分からないが、Cloudflareって触ったことなくて気になっていたので今回はCloudflare Workersで行くことにした。
とりあえず無料枠は充分っぽい。

手順

アプリケーションの登録

まずはDiscordの開発者ポータルを開いて新しいアプリケーションを登録する。
名前、説明、アイコン画像等をここで設定する。

botの招待

アプリケーションの登録をしたらもうサーバーに導入できるので、動作確認用のサーバーにはこの時点で招待してしまって良い。
上述のページで作ったアプリケーションの画面を開いたら、「OAuth2」を選び、「OAuth2 URL Generator」でScopesからapplication.commandsだけオンにして、生成されたURLをどこかに貼ってクリックすれば、あとは流れで好きなサーバーに招待できる。
Scopesはbotに与える権限を示すっぽい。今回必要なのはスラッシュコマンド関係の権限なのでapplication.commandsとなる。

コマンドの登録

次にDiscordに対してbotのコマンドを登録していく。
これはつまり、ユーザーがDiscordのGUIからどのようなコマンドを呼べるようになるかの設定作業であり、コマンドのインターフェースの登録と言って良い。
すなわち、実際のコマンドの動作はまだここでは実装しない。(この登録作業が終わるとコマンドの呼び出しが可能になるが、呼び出しても「アプリケーションが応答しませんでした」となる。)

さて、現状この作業はHTTP API経由でしかできない。
一度APIを叩けば設定完了って感じなので、ターミナルからcurlで叩いて終わりでもいいのだが、Infrastructure as Code的な考えでここではスクリプトを残すことにする。
なお、同じコマンドを何度も登録することに問題はない。設定は上書きされる。逆に言うと、違うコマンドの設定は上書きされない。なのでコマンド名のリネームをする場合などは、古いコマンドを自分で削除しないといけない。

APIのドキュメントはこれ。
https://discord.com/developers/docs/interactions/application-commands

コマンドのスコープにはグローバルと特定サーバーがある。
特定サーバー向けの設定は即時に反映されるので、開発時はそちらでテストするのがオススメらしい。
私がやってみた限りではグローバルの設定変更も十分早かった。
これから紹介するスクリプトでは、一応どちらもできるようにしておく。

というわけでスクリプトを書いてみた。
後にCloudflare Workers向けのコードをTypeScriptで書くことになるので、こちらのスクリプトもJavaScript(Node.js)にした。
スクリプト用の適当なフォルダを作り、以下のファイル群を配置する。

lib/discord.js
const API_URL_BASE = "https://discord.com/api/v10";

function getApiUrlApp(appId) {
  return `${API_URL_BASE}/applications/${appId}`;
}

function getApiUrlGuild(appId, guildId) {
  const urlOfApp = getApiUrlApp(appId);
  return `${urlOfApp}/guilds/${guildId}`;
}

function getApiUrlCommands(appId) {
  const urlOfApp = getApiUrlApp(appId);
  return `${urlOfApp}/commands`;
}

function getApiUrlGuildCommands(appId, guildId) {
  const urlOfGuild = getApiUrlGuild(appId, guildId);
  return `${urlOfGuild}/commands`;
}

function getApiUrlCommand(appId, commandId) {
  const urlOfCommands = getApiUrlCommands(appId);
  return `${urlOfCommands}/${commandId}`;
}

function getApiUrlGuildCommand(appId, guildId, commandId) {
  const urlOfCommands = getApiUrlGuildCommands(appId, guildId);
  return `${urlOfCommands}/${commandId}`;
}

export default class Discord {
  constructor(botToken, appId, { guildId = "" } = {}) {
    this.botToken = botToken;
    this.appId = appId;
    this.guildId = guildId;
  }

  async listCommand() {
    const url =
      this.guildId === ""
        ? getApiUrlCommands(this.appId)
        : getApiUrlGuildCommands(this.appId, this.guildId);
    const headers = makeHeaders(this.botToken);

    const resp = await fetch(url, {
      method: "GET",
      headers,
    });
    const respBodyText = await resp.text();
    const respBody = respBodyText === "" ? {} : JSON.parse(respBodyText);

    return { ok: resp.ok, value: respBody };
  }

  async addCommand(command) {
    const url =
      this.guildId === ""
        ? getApiUrlCommands(this.appId)
        : getApiUrlGuildCommands(this.appId, this.guildId);
    const headers = makeHeaders(this.botToken);

    const resp = await fetch(url, {
      method: "POST",
      headers,
      body: JSON.stringify(command),
    });
    const respBodyText = await resp.text();
    const respBody = respBodyText === "" ? {} : JSON.parse(respBodyText);

    return { ok: resp.ok, value: respBody };
  }

  async removeCommand(commandId) {
    const url =
      this.guildId === ""
        ? getApiUrlCommand(this.appId, commandId)
        : getApiUrlGuildCommand(this.appId, this.guildId, commandId);
    const headers = makeHeaders(this.botToken);

    const resp = await fetch(url, {
      method: "DELETE",
      headers,
    });
    const respBodyText = await resp.text();
    const respBody = respBodyText === "" ? {} : JSON.parse(respBodyText);

    return { ok: resp.ok, value: respBody };
  }
}

function makeHeaders(botToken) {
  return {
    Authorization: `Bot ${botToken}`,
    "Content-Type": "application/json",
  };
}
index.js
import { parseArgs } from "node:util";

import Discord from "./lib/discord.js";
import commands from "./commands.json" with { type: "json" };
import config from "./config.json" with { type: "json" };
const { botToken, appId, guildId } = config;

async function main() {
  const { values: options, positionals: args } = parseArgs({
    allowPositionals: true,
    options: {
      global: {
        type: "boolean",
        short: "g",
        default: false,
      },
    }});

  // Get arguments.
  if (args.length <= 0) {
    console.error("Required argument subcommand");
    process.exit(1);
  }
  const subcommand = args[0];

  // Execute.
  const discord = new Discord(botToken, appId, {
    guildId: options.global ? "" : guildId,
  });
  switch (subcommand) {
    case "list":
      {
        const { ok, value } = await discord.listCommand();
        console.dir(value, { depth: null });
      }
      break;

    case "add":
      {
        if (args.length <= 1) {
          console.error("Required argument command name");
          process.exit(1);
        }
        const commandName = args[1];

        const command = commands[commandName];
        if (!command) {
          console.error("Invalid command name");
          process.exit(1);
        }

        const { ok, value } = await discord.addCommand(command);
        console.dir(value, { depth: null });
      }
      break;

    case "remove":
      {
        if (args.length <= 1) {
          console.error("Required argument command ID");
          process.exit(1);
        }
        const commandId = args[1];

        const { ok, value } = await discord.removeCommand(commandId);
        console.dir(value, { depth: null });
      }
      break;

    default:
      console.error("Invalid subcommand");
      process.exit(1);
      break;
  }
}

main().catch(console.error);
commands.json
{
  "dice": {
    "name": "dice",
    "description": "サイコロを振る🎲"
  },
  "echo": {
    "name": "echo",
    "description": "受けた発言を繰り返す🦜",
    "options": [
      {
        "type": 3,
        "name": "message",
        "description": "メッセージ",
        "required": true
      }
    ]
  }
}
config.json
{
  "botToken": "*****",
  "appId": "*****",
  "guildId": "*****"
}
.gitignore
config.json

使い方は、

  1. config.jsonの値を埋める。(※コミットしないように注意!)
    • botTokenappIdには、Discordの開発者ポータルで確認できる値を入れる。アプリケーションを選んだ後、General Information画面のApplication IDをappIdに、Bot画面のTokenをbotTokenに入れる。(Tokenは生成時にしか表示されないため、値を手元に控えておく必要がある。仮にTokenが分からなくなった場合、Reset Tokenで再生成する必要がある。)
    • guildIdには動作確認用のDiscordサーバーのIDを入れる(Discordの「サーバー」は英語では「Guild」となる)。これは上述の特定サーバースコープの設定時に必要な値であり、グローバルスコープしか設定しない場合は必要ない。DiscordサーバーのIDは、Discordの開発者モードを有効にした上でサーバーアイコンを右クリックすることで調べられる。(cf. 公式Q&A
  2. commands.jsonに作成したいコマンドを定義する。
    • キーがコマンド名、値がコマンドオブジェクト。コマンドオブジェクトの型はDiscordのAPIドキュメント参照。
  3. 以下のように呼ぶ。
# 以下、`-g`が有ればグローバルスコープ、無ければ特定サーバースコープが対象。
# コマンドの一覧
$ node index.js list -g
# コマンドの登録
$ node index.js add -g <コマンド名>
# コマンドの削除(コマンドIDは上のlistコマンドで調べる)
$ node index.js remove -g <コマンドID>

Workersのデプロイ

ここからCloudflare Workers(以下、Workers)上でエントリポイントを実装していく。
まずはデプロイできることを確認する。ここではwranglerというCLIツールを用いるやり方を採用する。
公式チュートリアルの通りにnpm createを行いつつ、以下の点を変更する。

  • 言語はJavaScriptではなくTypeScriptにする。
  • デプロイをしたいか聞かれたらyesとする。

これでとりあえずHello World的なWorkerがデプロイされる。

WorkersのGitリポジトリ連携設定

上述のチュートリアルの続きを見れば、npx wrangler deployでいつでもデプロイをできることが分かる1が、どうせならGitHub等のGitリポジトリと連携しておくと楽だ。
連携すると、リポジトリに変更をプッシュするだけで自動でデプロイが行われるため、コードとデプロイの同期が保証される。

設定はCloudflareのダッシュボード画面で行う。
「設定 - ビルド - Gitリポジトリ」の「接続」をクリックし、まずは「Gitアカウント」から「新しいGitHub(or GitLab)接続」を作成する。
この時にアクセス権限を特定のリポジトリに絞ることができる。
アクセス権限の設定を変えたければ、再度「新しいGitHub(or GitLab)接続」を作成すれば良い。
ここまで行けば、あとは「リポジトリ」と「ブランチ」を選択するだけだ。
ただし、Workers実装のルートディレクトリが/でない場合はルートディレクトリのパスも設定する必要がある。

Worker実装:PINGへの返答

さて、Discordのスラッシュコマンドのエントリポイントは、前提として2つの機能を実装していなければならない。
後にDiscordの開発者ポータルでエントリポイントのURLを設定することになるが、その際にはエントリポイントがこれらの機能をちゃんと備えているか、Discordによるチェックが行われる。チェックに合格しない限り、URLの設定は成功しない。

その2つの必須機能の内1つは、DiscordからのPINGメッセージにPONGを返すことである。
具体的には下記のようなコードとなる。

src/index.ts
import { ResponseError } from './types';

export default {
	async fetch(request, env, ctx): Promise<Response> {
		const body = await request.text();
		// ※スラッシュコマンドはinteractionの内の1つとして位置付けられる。
		let interaction;
		try {
			interaction = JSON.parse(body);
		} catch (err) {
			if (err instanceof SyntaxError) {
				const err: ResponseError = {
					title: 'Broken Request Body',
					detail: "Your request's body is broken.",
				};
				return Response.json(err, { status: 400 });
			}
			throw err;
		}

		// interaction typeの1がPING
		if (interaction.type === 1) {
			// interaction callback typeの1がPONG
			const body = { type: 1 };
			return Response.json(body);
		}

		// その他のケースはとりあえず未対応
		const err: ResponseError = {
			title: 'Unexpected Request Body',
			detail: "Your request's body is something different from our expectations.",
		};
		return Response.json(err, { status: 400 });
	},
} satisfies ExportedHandler<Env>;
src/types.ts
export interface ResponseError {
	title: string;
	detail: string;
}

以下のようにリクエストを送り、{"type":1}(=PONG)が返ってくれば成功だ。

$ curl -d '{"type":1}' <エントリポイントのURL>

また、以下のようなテストコードを書くこともできる。
npm create時点ではunit styleとintegration styleが例示されているが、ここではunit styleだけ扱い、integration styleは省略する。)

test/index.spec.ts
import { env, createExecutionContext, waitOnExecutionContext } from 'cloudflare:test';
import { describe, it, expect } from 'vitest';
import worker from '../src/index';

// For now, you'll need to do something like this to get a correctly-typed
// `Request` to pass to `worker.fetch()`.
const IncomingRequest = Request<unknown, IncomingRequestCfProperties>;

describe('The Entrypoint Worker', () => {
	it('responds to ping', async () => {
		const request = new IncomingRequest('http://example.com', {
			method: 'POST',
			body: '{"type":1}',
		});
		// Create an empty context to pass to `worker.fetch()`.
		const ctx = createExecutionContext();

		const response = await worker.fetch(request, env, ctx);
		// Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions
		await waitOnExecutionContext(ctx);

		expect(response.status).toBe(200);
		const respBody = await response.json();
		expect(respBody).toHaveProperty('type', 1);
	});
});

npm testで実行可能だ。

Worker実装:署名の検証

エントリポイントに必須な2つの機能の内、もう1つは署名の検証である。
エントリポイントに対してDiscordから送られてくるリクエストには、Discordによるデジタル署名が付いてくる。
これはつまり、リクエストを送ってきたのが本当にDiscordなのかが分かるということだ。
そこでスラッシュコマンドの開発者は、リクエストがちゃんとDiscordのものであることを検証し、偽物だった場合には処理を行わずに401(Unauthorized)を返す必要がある。

この処理はスラッシュコマンドのセキュリティのために必要なものだ。
この処理が無い場合に困るのは開発者の方で、Discord自体は困らないような気もするので、Discordがこれを推奨するのを通り越して強制までしてくる理由は私には分からない。親切心なのか、プラットフォームとしての責務みたいなものなのか。
いずれにせよ、この処理が無いエントリポイントを設定しようとすると弾かれる。
(おそらくDiscordは正しい署名と誤った署名でそれぞれPINGリクエストを送ってテストしてきている。)

公開鍵の取り扱い

署名の検証を実装するためにはまず、Discordのアプリケーションに割り当てられた公開鍵(Public Key)が必要だ。
これは開発者ポータルのGeneral Information画面で確認できる。

公開鍵はコード上では環境変数に入れて扱いたい。
Workersでは環境変数の扱い方は2種類ある。通常のものとシークレットだ。
シークレットは暗号化して取り扱われる。ダッシュボード上でもデフォルトでは値が隠されるよう考慮されるし、gitリポジトリには公開しないのが前提となる。
通常のものはその逆で、平文で取り扱われるし、wrangler.jsoncというファイル上で設定することでgitリポジトリに公開するのも普通となる。

環境変数全般について:
https://developers.cloudflare.com/workers/configuration/environment-variables/
シークレットについて:
https://developers.cloudflare.com/workers/configuration/secrets/

公開鍵は「公開」とはいえ、無闇に公開しないで済むならそれに越したことはない。
今回はシークレットとして扱う。

ということでまずは、ポータルで確認した公開鍵を.dev.varsに記入する。
このファイルは.envみたいなものだと思ってもらって構わない。
というか.envも対応しているので、.envにしてしまっても良い。

どちらにせよ、このファイルはリポジトリにプッシュしない。
npm createの時点で.gitignore.dev.vars*が登録されているのでこの辺は安心だ。

.dev.vars
DISCORD_PUBLIC_KEY="<公開鍵>"

なお、.gitignoreには!.dev.vars.exampleという行もある。
これはつまり、以下のように.dev.vars.exampleを作ってプッシュしておくことにより、.dev.varsのキーだけを示すテンプレートをリポジトリに残しておけるということだ。

.dev.vars.example
DISCORD_PUBLIC_KEY="*****"

これでWorkerをローカル実行した際に、fetch関数のenv引数にDISCORD_PUBLIC_KEYの値が入ってくるようになる。

src/index.ts
async fetch(request, env, ctx): Promise<Response> {
	const publicKey = env.DISCORD_PUBLIC_KEY;
	if (publicKey == null || publicKey === '') {
		throw new Error('Missing env var "DISCORD_PUBLIC_KEY".');
	}
}

TypeScriptの場合、env.DISCORD_PUBLIC_KEYが型の上では定義されていないので、型エラーが発生する。
この場合、npx wrangler typesあるいはnpm run cf-typegenで型定義を更新してやれば良い。

ちなみに上のコードでは例外を投げている。
Worker上で例外を投げると、自動で500(Internal Server Error)を返してくれる。
例外によって終了するのは各リクエストであり、Workerのリクエスト待機状態は継続する。
例外の詳細はログに載るし、ローカル実行の時(=開発時)のみレスポンスにも記載されたりする。
例外を下手にハンドリングするよりは、積極的にCloudflareにハンドルさせる方が便利だと思う。

さて、上では「.dev.varsを定義することで "ローカル実行した際に" 値が使用できる」ということを述べた。
ローカル実行はnpx wrangler devあるいはnpm run devにて行うことができる。
また、npm testの際にも.dev.varsの値は使用される。
一方で、本番環境についての環境変数は別途設定が必要だ。.dev.varsの値が使われることはない。

本番環境の環境変数はダッシュボードで設定できる。
「設定 - 変数とシークレット」で「追加」をクリックし、「タイプ」は「シークレット」を選んで、「変数名」と「値」を設定すれば良い。

あるいは以下のコマンドを実行し、続くダイアログに値を入れる形でも設定できる。

$ npx wrangler secret put DISCORD_PUBLIC_KEY

実装

これでようやく署名の検証を実装する準備が整った。
署名の検証にはtweetnaclというNPMモジュールを用いる。
まずはnpm install tweetnaclを行う。

後は以下の流れを実装する。

  1. ヘッダから署名(X-Signature-Ed25519)とタイムスタンプ(X-Signature-Timestamp)を受け取る。
  2. リクエスト本文の先頭にタイムスタンプをつけたものを公開鍵で検証する。
  3. 検証に失敗したら401(Unauthorized)を返す。

ということでコードに起こすと以下のようになる。

src/index.ts
import { sign } from 'tweetnacl';

import { ResponseError } from './types';

const TEXT_ENCODER = new TextEncoder();

export default {
	async fetch(request, env, ctx): Promise<Response> {
		// Get env vars.
		const publicKey = env.DISCORD_PUBLIC_KEY;
		if (publicKey == null || publicKey === '') {
			throw new Error('Missing env var "DISCORD_PUBLIC_KEY".');
		}

		// Get request's headers and body.
		const signature = request.headers.get('X-Signature-Ed25519');
		const timestamp = request.headers.get('X-Signature-Timestamp');
		if (signature == null || signature === '' || timestamp == null || timestamp === '') {
			const err: ResponseError = {
				title: 'Unauthorized',
				detail: `Headers for signature is missing.`,
			};
			return Response.json(err, { status: 401 });
		}
		const body = await request.text();

		// 署名の検証。
		if (!signatureIsValid(publicKey, body, timestamp, signature)) {
			const err: ResponseError = {
				title: 'Unauthorized',
				detail: 'Your signature is invalid.',
			};
			return Response.json(err, { status: 401 });
		}

		// ※スラッシュコマンドはinteractionの内の1つとして位置付けられる。
		let interaction;
		// 以下、変更が無いので略
	},
} satisfies ExportedHandler<Env>;

function signatureIsValid(publicKey: string, body: string, timestamp: string, signature: string): boolean {
	const message = timestamp + body;

	let signatureBytes: Uint8Array, publicKeyBytes: Uint8Array;
	try {
		signatureBytes = Uint8Array.fromHex(signature);
		publicKeyBytes = Uint8Array.fromHex(publicKey);
	} catch (err) {
		if (err instanceof SyntaxError) {
			return false;
		}
		throw err;
	}
	const messageBytes = TEXT_ENCODER.encode(message);

	return sign.detached.verify(messageBytes, signatureBytes, publicKeyBytes);
}

tweetnaclが扱うデータ型がUint8Arrayである一方、Discordの署名や公開鍵は16進数で表現された文字列なので、この辺の変換が肝になる。
上のコードではUint8Array.fromHex()を使用したが、これはかなり新しい関数のようで、2025年12月現在、Cloudflare Workers上で動きはしたが、TypeScriptの型定義が追いついていなかった。
よって、型エラー解消のために以下のファイルを作る必要がある。(srcフォルダではなくルートに置いていることに注意。)

global.d.ts
interface Uint8ArrayConstructor {
	fromHex(hex: string): Uint8Array;
}

interface Uint8Array {
	toHex(): string;
}

その後、tsconfig.jsonglobal.d.tsをincludeする。
tsconfig.jsonはルートとtestフォルダの2つあるので、どちらも編集する必要がある。

型エラーが解消できたら後はテストだ。
ここでは、PINGにPONGを返すパターンと、誤った署名に対して401を返すパターンをテストする。

tweetnaclでは公開鍵と秘密鍵のペアを生成したり、秘密鍵で署名をしたりすることができる。

test/index.spec.ts
import { createExecutionContext, waitOnExecutionContext } from 'cloudflare:test';
import { sign } from 'tweetnacl';
import { describe, it, expect } from 'vitest';
import worker from '../src/index';

// For now, you'll need to do something like this to get a correctly-typed
// `Request` to pass to `worker.fetch()`.
const IncomingRequest = Request<unknown, IncomingRequestCfProperties>;

const TEXT_ENCODER = new TextEncoder();
const SEED = TEXT_ENCODER.encode('SeedForTest234567890123456789012');

describe('The Entrypoint Worker', () => {
	it('responds to ping', async () => {
		const body = '{"type":1}';
		const timestamp = '12345';
		const { publicKey, signature } = makeSignature(body, timestamp);

		const request = new IncomingRequest('http://example.com', {
			method: 'POST',
			headers: {
				'X-Signature-Ed25519': signature,
				'X-Signature-Timestamp': timestamp,
			},
			body,
		});
		const env = {
			DISCORD_PUBLIC_KEY: publicKey,
		};
		// Create an empty context to pass to `worker.fetch()`.
		const ctx = createExecutionContext();

		const response = await worker.fetch(request, env, ctx);
		// Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions
		await waitOnExecutionContext(ctx);

		expect(response.status).toBe(200);
		const respBody = await response.json();
		expect(respBody).toHaveProperty('type', 1);
	});

	it('refuses a wrong signature', async () => {
		const body = '{"type":1}';
		const timestamp = '12345';
		const { publicKey, signature } = makeSignature(body, timestamp);
		const wrongSignature = destroySignature(signature);

		const request = new IncomingRequest('http://example.com', {
			method: 'POST',
			headers: {
				'X-Signature-Ed25519': wrongSignature,
				'X-Signature-Timestamp': timestamp,
			},
			body,
		});
		const env = {
			DISCORD_PUBLIC_KEY: publicKey,
		};
		// Create an empty context to pass to `worker.fetch()`.
		const ctx = createExecutionContext();

		const response = await worker.fetch(request, env, ctx);
		// Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions
		await waitOnExecutionContext(ctx);

		expect(response.status).toBe(401);
		const respBody = await response.json();
		expect(respBody).toHaveProperty('title', 'Unauthorized');
		expect(respBody).toHaveProperty('detail', 'Your signature is invalid.');
	});
});

function makeSignature(body: string, timestamp: string): { publicKey: string; signature: string } {
	const message = timestamp + body;

	const messageBytes = TEXT_ENCODER.encode(message);
	const keyPair = sign.keyPair.fromSeed(SEED);
	const publicKey = keyPair.publicKey.toHex();
	const signature = sign.detached(messageBytes, keyPair.secretKey).toHex();
	return { publicKey, signature };
}

function destroySignature(signature: string): string {
	// 1バイト目を反転させ、正しいsignatureを誤ったものにする。
	const buf = Uint8Array.fromHex(signature);
	buf[0] = buf[0] ^ 0xff;
	return buf.toHex();
}

エントリポイントURLの設定

これで2つの必須機能が実装できたので、満を持してエントリポイントをDiscord上で登録する。
開発者ポータルにて、General Information画面でInteractions Endpoint URLにWorkerのURLを入力すればいい。
Saveを押した時に、Discordによるチェックが行われる。
ここで何も言われずに設定の変更が完了できれば、チェックは合格だ。
2つの必須機能が上手く実装できていなくて、チェックに不合格だった場合、「指定されたインタラクション・エンドポイントURLを認証できませんでした。」の表示がされ、設定の変更は保存されない。

Worker実装:メッセージを返す

ここまで来たらあとはシンプルだ。
Discord上でスラッシュコマンドを送信すれば、それに対応したリクエストがDiscordからエントリポイントに送られてくる。
レスポンスを返すことで、メッセージを返す等のアクションを起こすことができる。

スラッシュコマンドにまつわるリクエストやレスポンスについての詳細は以下のドキュメントを参照。
https://discord.com/developers/docs/interactions/receiving-and-responding

特にInteraction Callback Typeの表では、レスポンスで起こせるアクションが列挙されている。
特に使うのは4 (CHANNEL_MESSAGE_WITH_SOURCE)と5 (DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE)だろう。
それぞれ、メッセージを返すアクションと、遅延メッセージを返すアクションだ。

遅延メッセージ(正式名称かどうかは不明)とは何かというと、「後からメッセージを送るから一旦待って〜」というシグナルだ。
これを送ると、Discordの画面ではbotが「考え中…」というメッセージを表示する。
この間にエントリポイントから改めて専用のAPIに追加メッセージを送信することができる。
前提として、スラッシュコマンドへのレスポンスは3秒以内に送らないとタイムアウトになってしまう。
時間のかかる返事をしたい場合、3秒以内にまずは遅延メッセージを返せば、そこから15分の間追加メッセージを送信できるようになる。
返事が1つのメッセージに収まらず、複数に分割したい場合にも便利だ。

ここでは遅延メッセージの実装までは行わない。通常のメッセージを返す処理のみ実装する。

署名の検証に成功した後のリクエストを扱うので、きっと変なリクエストは無いだろうと信じ、今回は細かいバリデーションを全部サボる。
気になる人はZodか何かで型バリデーションとかするといいだろう。

src/index.ts
export default {
	async fetch(request, env, ctx): Promise<Response> {
		// interactionのパースが終わるところまで省略

		switch (interaction.type) {
			case 1: // PING
				return handlePing();

			case 2: {
				// APPLICATION COMMAND
				const data = interaction.data;
				switch (data.name) {
					case 'dice':
						return handleDice();
					case 'echo':
						return handleEcho(data.options);
				}
				break;
			}
		}

		const err: ResponseError = {
			title: 'Unexpected Request Body',
			detail: "Your request's body is something different from our expectations.",
		};
		return Response.json(err, { status: 400 });
	},
} satisfies ExportedHandler<Env>;

function handlePing(): Response {
	const body = { type: 1 }; // PONG
	return Response.json(body);
}

function handleDice(): Response {
	const roll = getRandomInt(6) + 1;
	const body = {
		type: 4, // CHANNEL_MESSAGE_WITH_SOURCE
		data: {
			content: `${roll}`,
		},
	};
	return Response.json(body, { headers: { 'Content-Type': 'application/json' } });
}

function handleEcho(options: any[]): Response {
	const optionMessage = options.find((option: any) => option.name === 'message');
	const message = optionMessage.value;

	const body = {
		type: 4, // CHANNEL_MESSAGE_WITH_SOURCE
		data: {
			content: message,
		},
	};
	return Response.json(body, { headers: { 'Content-Type': 'application/json' } });
}

// 0 to (max - 1)
function getRandomInt(max: number): number {
	return Math.floor(Math.random() * max);
}

完成

コードの全体は以下に上げた。
https://github.com/ikngtty/discord-slash-command-bot-cloudflare-sample

  1. このコマンドはpackage.jsonで定義されているので、npm run deployでも呼び出せる。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?