📛

Cloudflare Workers で Hono と一緒に Discord BOT を動かす

に公開

はじめに

Discord では、手軽に BOT を作成するための API が用意されており、さまざまなライブラリが存在するため手軽に BOT を作成することができます。
しかし、その多くは BOT のサーバーを建てる必要があり、常に BOT を立ち上げておきたい場合は、サーバーも常に起動していなければならず、動かしておくマシンの用意やサーバーへのデプロイにコストがかかることがあります。
Heroku の有料化以後、我々は無料で Discord BOT を運用できる場所を探し求めているわけですが、今回は、Cloudflare Workers を使ってランタイムコストなしで Discord BOT を運用 し、さらに Cloudflare Workers KV との連携 や、Hono エンドポイントとの共存 方法を紹介します。

具体的には、KV に保存されたクイズ問題を読んでユーザーに提供したり、ユーザーの提出した回答と答え合わせしてスコアを書き込んだり する BOT を作成します。

成果物

今回作成したコードは以下のレポで公開しています:

https://github.com/p1atdev/quiz-bot-workers

今回使うもの

Discord Hono

今回は Hono 上で構築されている Discord BOT ライブラリである、Discord Hono を使用します。

https://discord-hono.luis.fun/ja/builders/components/

Discord Hono は Cloudflare Workers 上で動作 する、数少ない Discord BOT ライブラリの一つです。
名前の通り Hono を利用していることから、Discord BOT を動かしながら、同時に通常の HTTP API を公開することもできます。

Cloudflare Workers 上で動かせるため、Workers の機能を利用することができます。
例えば、 Key-Value ストレージである、Cloudflare Workers KV を利用することで、簡単なデータの読み書き が可能となり、コストをかけずに BOT の機能を充実させることができます。

何かのサービスを作るにあたってデータの置き場所は悩みがちですが、Cloudflare Workers KV であれば (頻繁な読み書きでなければ) 無料で利用できるので、便利です。もし複雑なデータを取り扱いたい場合は、必要に応じて Cloudflare D1 (SQLite)などを使うこともできます。

Cloudflare Workers

Cloudflare Workers を使うには、Cloudflare のアカウントが必要なので作っておきましょう。

https://www.cloudflare.com/ja-jp/

トークン作成

今回、Cloudflare の REST API から KV を読むタイミングがあるため、必要な権限を与えたトークンを作成する必要があります。

プロフィール > API トークン > API トークン > トークンを作成する から、適当なテンプレートを選択し、画像のように KV の読み取り権限を与えてトークンを作成します。


これだけあればいい

アカウント ID

また、ダッシュボードトップページから、アカウント ID を控えておきます。

KV Namespace 作成

KV であらかじめ新しい Namespace を作成しておき、ID を控えておきます。(この ID はシークレット情報ではないので、wrangler.toml にベタ書きしても大丈夫です)

あとで使います。

KV にデータ作成

今回使用するクイズのデータを手動で作成しておきます。

キー: quiz_q1

バリュー
{
  "question": "9.9 と 9.11 はどっちが大きいですか?",
  "answer": "9.9",
  "hint": "小数に注目してください!"
}

Discord BOT セットアップ

Discord Developer Portal に入り、Bot を新たに作成します。

https://discord.com/developers/

必要なトークン等は以下です:

  • Application ID: 数字の ID
  • Public Key: Application ID の下にある Public Key
  • Bot Token: Bot のタブを開いて、Reset Token を押すと表示されるトークン

あとで使います。

Discord Hono の簡単な説明

主に公式ドキュメントを見てもらえればいいと思いますが、今回使う機能をざっくり説明します。

コマンドの登録

Discord BOT の Slash コマンドを使うには、登録処理を実行する必要があります。Discord Hono では register 関数を利用してコマンドを登録します。

import { Command, Option, register } from 'discord-hono'

const commands = [
  new Command('ping', 'pong を返答'),
  new Command('image', '画像ファイルを返答').options(
    new Option('text', 'テキスト付で').required(),
  ),
]

register(
  commands,
  process.env.DISCORD_APPLICATION_ID,
  process.env.DISCORD_TOKEN,
  //process.env.DISCORD_TEST_GUILD_ID,
)

https://discord-hono.luis.fun/ja/helpers/register/

Command クラスを作成し、後ろにチェインしていくことでさまざまなオプションを追加する形になっています。
実際に型補完を見た方がイメージが掴みやすいかと思います。

コマンドの応答

実行されたコマンドに応答するには、 DiscordHono.command() メソッドに処理を渡す形になります。Hono の .get() 等に似ていますね。

import { DiscordHono } from 'discord-hono'

const app = new DiscordHono()
app.command('ping', (c) => c.res('Pong!!'))

export default app

https://discord-hono.luis.fun/ja/interactions/discord-hono/

第一引数にコマンド名、第二引数に処理を渡す形になっています。
チャンネル情報やコマンドを使用したユーザーの情報などは、c.interaction から取得可能です。

app.command("hello", (c) =>  {
    const name = c.interaction.member?.user.global_name;
    if (!name) {
        return c.res("name not found");
    }
    return c.res(`Hello, ${name}`);
})

また、引数のついたコマンドの場合は、c.var に JSON の形で渡されるので、そこから取得できます。

app.command("image", (c) =>  {
    const params = c.var as {
        text: string;
    };
    // TODO
})

埋め込みメッセージを返す場合は、Embed クラスを利用します。

app.command("embed", (c) => {
    return c.res(
        {
            embeds: [
                new Embed().title("Title").description("Description").fields(
                    {
                        name: "Name",
                        value: "Value",
                        inline: true,
                    },
                    {
                        name: "Name2",
                        value: "Value2",
                        inline: true,
                    },
                ).color(0x00ff00),
            ],
        },
    );
})

API ラッパー

また、今回は使用しませんが、コマンドへの返答ではなく自発的にメッセージを送りたい場合などは、c.rest()createRest() から Discord API のラッパーを通じて API を叩くことで実現できます。
c.rest() は、Context が利用できる場合 (Interaction 応答時)、createRest() はContext が利用できない場合に使用し、どちらもインターフェースは同じなので置き換えるだけで使えます。

createRestの場合
import {
	_channels_$_messages,
	_users_me_channels,
	createRest,
} from "discord-hono";

// userId を指定して DM を送る関数
const sendDM = async (
	env: Bindings,
	userId: string,
	message: string,
): Promise<void> => {
	const channel = await createRest(env.DISCORD_TOKEN)(
		"POST",
		_users_me_channels,
		[],
		{
			recipient_id: userId,
		},
	);
	const channelId = (await channel.json()).id;
	await createRest(env.DISCORD_TOKEN)(
		"POST",
		_channels_$_messages,
		[
			channelId,
		],
		message,
	);
};

上の関数では、/users/@me/channels を叩いて DM チャンネルを作成し、得られたチャンネル ID を利用して /channels/{channel.id}/messages からメッセージを送信しています。

このように、Interaction 以外の操作を行う場合は、Discord API のラッパーを使うことで実現できます。

実装

Bun を使いますが Bun 独自の機能は使ってないので Node.js でも動くはずです。Deno で動くかは分からないです。

https://bun.sh

適当にフォルダを作成して、bun init で初期化しておきます。

.dev.vars, .env.local

先にシークレット情報などを用意しておきます。

.dev.vars
DISCORD_APPLICATION_ID=BOT の Application ID
DISCORD_PUBLIC_KEY=BOT の Public Key
DISCORD_TOKEN=BOT のトークン
# DISCORD_GUILD_ID=サーバーの ID
.env.local
CLOUDFLARE_API_TOKEN=作成した API トークン
CLOUDFLARE_KV_ID=作成した KV Namespace の ID
CLOUDFLARE_ACCOUNT_ID=コピーしたアカウントID

DISCORD_APPLICATION_ID=さっきのと同じやつ
DISCORD_PUBLIC_KEY=
DISCORD_TOKEN=
DISCORD_GUILD_ID=

package.json

今回使うライブラリをインストールします。

bun add discord-hono hono
bun add --dev discord-api-types @cloudflare/workers-types cloudflare wrangler
# お好みで
# bun add --dev @biomejs/biome

"scripts" を追加して、以下のようにします。

package.json
{
  "name": "quiz-bot-workers",
  "module": "src/main.ts",
  "type": "module",
  "private": true,
  "scripts": {
    "register": "bun --env-file=.env.local run src/register.ts",
    "dev": "wrangler dev",
    "types": "wrangler types",
    "deploy": "wrangler deploy"
  },
  "devDependencies": {
    "@biomejs/biome": "1.9.4",
    "@cloudflare/workers-types": "^4.20250412.0",
    "@types/bun": "latest",
    "cloudflare": "^4.2.0",
    "discord-api-types": "^0.37.120",
    "wrangler": "^4.10.0"
  },
  "peerDependencies": {
    "typescript": "^5"
  },
  "dependencies": {
    "discord-hono": "^0.16.4",
    "hono": "^4.7.6"
  }
}
  • "register": Discord BOT のコマンドを登録するためのコマンド
  • "dev": Cloudflare Workers のローカル開発用コマンド (Hono エンドポイント向けで、Discord BOT の動作確認はできない)
  • "types": Cloudflare Workers の型定義を生成するコマンド
  • "deploy": Cloudflare Workers にデプロイするためのコマンド

wrangler.toml

wrangler.toml は以下のようにします。

wrangler.toml
name = "quiz-bot-workers" # この名前でデプロイされる
main = "src/main.ts"
compatibility_date = "2025-04-12" # 今日の日付でいいと思う
compatibility_flags = ["nodejs_compat_v2"]


[[kv_namespaces]]
binding = "KV" # KV という名前でバインド (スクリプトでこの名前で使う)
id = "ここにKVのID"

[observability]
enabled = true

[placement]
mode = "smart"

この時点で、bun run types を実行すると、型定義ファイルである worker-configuration.d.ts が生成されます。tsconfig.json に追加すると良いでしょう。

tsconfig.json
{
 "compilerOptions": {
    ... 略
  },
  "include": [
    "src/**/*",
    "worker-configuration.d.ts"
  ]
}

src/main.ts

これが BOT のレスポンスを返す部分です。

最終的に以下のようになりました。

src/main.ts
import { DiscordHono, Embed } from "discord-hono";
import { Hono } from "hono";
import {
    getAllQuizzes,
    getQuiz,
    getUser,
    incrementUserScore,
    setUserQuizCompleted,
} from "./kv";

type Env = {
    Bindings: {
        KV: KVNamespace;
    };
    Variables: Record<string, string>;
};

const bot = new DiscordHono<Env>()
    .command("list", async (c) => {
        const quizzes = await getAllQuizzes(c.env.KV);
        return c.res(
            {
                embeds: [
                    new Embed().title("Quiz List").fields(
                        ...Object.entries(quizzes).map(([key, quiz]) => {
                            return {
                                name: key,
                                value: quiz.question,
                                inline: false,
                            };
                        }),
                    ),
                ],
            },
        );
    })
    .command("answer", async (c) => {
        const userId = c.interaction.member?.user.id;
        if (!userId) {
            return c.res("User not found");
        }
        const params = c.var as {
            quiz: string;
            answer: string;
        };
        const quiz = await getQuiz(c.env.KV, params.quiz);
        if (!quiz) {
            return c.ephemeral().res("Quiz not found");
        }
        if (quiz.answer === params.answer) {
            await incrementUserScore(c.env.KV, userId);
            await setUserQuizCompleted(c.env.KV, userId, params.quiz);

            return c.ephemeral().res("Correct!");
        }

        return c.ephemeral().res("Incorrect answer. Please try again.");
    })
    .command("hint", async (c) => {
        const params = c.var as {
            quiz: string;
        };
        const quiz = await getQuiz(c.env.KV, params.quiz);
        if (!quiz) {
            return c.ephemeral().res("Quiz not found");
        }

        return c.ephemeral().res(`Hint: ${quiz.hint}`);
    })
    .command("profile", async (c) => {
        const userId = c.interaction.member?.user.id;
        if (!userId) {
            return c.res("User not found");
        }

        const user = await getUser(c.env.KV, userId);

        return c.res(
            {
                embeds: [
                    new Embed()
                        .title("Profile")
                        .fields(
                            {
                                name: "Score",
                                value: `${user.score}`,
                            },
                            ...Object.entries(user.quizzes).map(
                                ([key, value]) => {
                                    return {
                                        name: key,
                                        value: value
                                            ? "Completed"
                                            : "Not Completed",
                                        inline: true,
                                    };
                                },
                            ),
                        ),
                ],
            },
        );
    });

const app = new Hono<Env>();

app.mount("/bot", bot.fetch);
app.get("*", (c) => {
    return c.text("Hello, Hono!");
});

export default app;

コードの最後の方で Honoapp を作成していますが、この辺りで、/bot パス以下を Discord BOT の Interaction エンドポイントとしてマウントし、それ以外には Hello, Hono! を返すようにしています。つまり、Hono のエンドポイントと Discord BOT のエンドポイントを同時に動かすことができるようになっています。

フロントエンドの操作と Discord BOT を連携させるみたいなこともできそうですね。

src/kv.ts

Workers KV の読み書き処理に関する実装です。

ここでは、クイズやユーザーのスコアの型定義、getput 処理を実装しています。

src/kv.ts
export interface Quiz {
    question: string;
    answer: string;
    hint: string;
}

export interface User {
    score: number;
    quizzes: Record<string, boolean>;
}

export const QUIZ_PREFIX = "quiz_";
export const USER_PREFIX = "user_";

export const getQuiz = async (
    kv: KVNamespace,
    quizId: string,
): Promise<Quiz | null> => {
    const quiz = await kv.get<Quiz>(`${QUIZ_PREFIX}${quizId}`, {
        type: "json",
    });
    if (!quiz) {
        return null;
    }

    return quiz;
};

export const getAllQuizzes = async (
    kv: KVNamespace,
): Promise<Record<string, Quiz>> => {
    const quizzes: Record<string, Quiz> = {};
    const list = await kv.list<Quiz>({
        prefix: QUIZ_PREFIX,
    });

    for (const item of list.keys) {
        const quiz = await kv.get<Quiz>(item.name, {
            type: "json",
        });
        if (quiz) {
            quizzes[item.name.replace(QUIZ_PREFIX, "")] = quiz;
        }
    }

    return quizzes;
};

export const getUser = async (
    kv: KVNamespace,
    userId: string,
): Promise<User> => {
    const user = await kv.get<User>(`${USER_PREFIX}${userId}`, {
        type: "json",
    });
    if (!user) {
        await createUser(kv, userId);
        return getUser(kv, userId);
    }

    return user;
};

export const createUser = async (
    kv: KVNamespace,
    userId: string,
): Promise<void> => {
    const quizzes = await getAllQuizzes(kv);

    const user: User = {
        score: 0,
        quizzes: Object.keys(quizzes).reduce((acc, quizId) => {
            acc[quizId] = false;
            return acc;
        }, {} as Record<string, boolean>),
    };

    await kv.put(`${USER_PREFIX}${userId}`, JSON.stringify(user));
};

export const incrementUserScore = async (
    kv: KVNamespace,
    userId: string,
): Promise<void> => {
    const user = await getUser(kv, userId);
    if (!user) {
        // create
        await createUser(kv, userId);
        return incrementUserScore(kv, userId);
    }

    user.score += 1;
    await kv.put(`${USER_PREFIX}${userId}`, JSON.stringify(user));
};

export const setUserQuizCompleted = async (
    kv: KVNamespace,
    userId: string,
    quizId: string,
): Promise<void> => {
    const user = await getUser(kv, userId);
    if (!user) {
        // create
        await createUser(kv, userId);
        return setUserQuizCompleted(kv, userId, quizId);
    }

    user.quizzes[quizId] = true;
    await kv.put(`${USER_PREFIX}${userId}`, JSON.stringify(user));
};

src/register.ts

Discord Hono のコマンドを登録する部分です。
ここでは、Cloudflare API を経由して KV に保存されたクイズの ID を取得し、コマンドの選択肢に設定しています。
これによって、ユーザーはクイズ ID を手動で入力する必要がなくなります。

src/register.ts
import Cloudflare from "cloudflare";
import { Command, Option, register } from "discord-hono";
import { QUIZ_PREFIX } from "./kv";

const client = new Cloudflare({
    apiToken: process.env.CLOUDFLARE_API_TOKEN,
});

const keys = [];

for await (
    const key of await client.kv.namespaces.keys.list(
        process.env.CLOUDFLARE_KV_ID,
        {
            account_id: process.env.CLOUDFLARE_ACCOUNT_ID,
            prefix: QUIZ_PREFIX,
        },
    )
) {
    keys.push(key.name.replace(QUIZ_PREFIX, ""));
}

const commands = [
    new Command("list", "クイズを取得します"),
    new Command("answer", "クイズに回答します").options(
        new Option("quiz", "クイズ ID", "String").required().choices(
            ...keys.map((key) => ({
                name: key,
                value: key,
            })),
        ),
        new Option("answer", "回答", "String").required(),
    ),
    new Command("hint", "ヒントを取得します").options(
        new Option("quiz", "クイズ ID", "String").required().choices(
            ...keys.map((key) => ({
                name: key,
                value: key,
            })),
        ),
    ),
    new Command("profile", "現在の回答状況を取得します"),
];

register(
    commands,
    process.env.DISCORD_APPLICATION_ID,
    process.env.DISCORD_TOKEN,
    //process.env.DISCORD_GUILD_ID, // お好みで
);

以下のコマンドを実行して Slash コマンドを登録します。

bun run register

デプロイ

ここまで完了したら、以下のコマンドでデプロイできます。初回の場合は Cloudflare ログインが必要になると思いますが、指示に従って進めてください。

bun run deploy

すると、https://<your-worker-name>.<your-worker-domain>.workers.dev が作成されるので、Discord BOT の General Information > Interaction Endpoint URL/bot パスを追加した https://<your-worker-name>.<your-worker-domain>.workers.dev/bot に設定します。

動作確認

こんな感じになりました:

終わりに

レポジトリ再掲:

https://github.com/p1atdev/quiz-bot-workers

Cloudflare Workers 上で Discord BOT を動かす方法を紹介しました。

この方法を使うことで、ランタイムコストなしで Discord BOT を運用することができ、さらに Cloudflare Workers KV を利用することで、データの保存や取得も簡単に行うことができます。

Cloudflare 便利〜

参考文献

https://github.com/luisfun/discord-hono-example

GitHubで編集を提案

Discussion