Open8

Discordボットの基礎導入(Nodejs/TS)

teloshtelosh

Discord.jsで自鯖用Botを開発した記録

このドキュメントは、僕がDiscordサーバー用にBotを開発した際の手順と内容をまとめたものです。以下の手順に沿って開発をはじめました。

手順

1. npmで初期化

最初にプロジェクトのディレクトリを作成し、npm init コマンドでプロジェクトを初期化します。これは、package.json を作成し、プロジェクトの管理を簡単にするための手順です。

npm init -y

この後必要情報は自分で書き換えてもらえればok
例でpackage.jsonとtsconfig.jsonを提示しておく

  • package.json
{
  "name": botbotbot",
  "version": "1.0.0",
  "main": "main.js",
  "scripts": {
    "dev": "npm run compile && node build/deploy-commands.js && node build/main.js",
    "start": "node build/main.js",
    "compile": "tsc -p ."
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@discordjs/voice": "^0.17.0",
    "@supabase/supabase-js": "^2.45.4",
    "discord.js": "^14.16.3",
    "dotenv": "^16.4.5",
    "uuid": "^10.0.0",
    "ytdl-core": "^4.11.5"
  },
  "devDependencies": {
    "@eslint/js": "^9.11.1",
    "@types/node": "^22.7.4",
    "eslint": "^9.11.1",
    "globals": "^15.10.0",
    "supabase": "^1.200.3",
    "ts-node": "^10.9.2",
    "typescript": "^5.6.2",
    "typescript-eslint": "^8.8.0"
  }
}
  • tsconfig.json
{
  "compilerOptions": {
    "target": "es2016",
    "module": "commonjs",
    "rootDir": "./src",
    "outDir": "./build",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  }
}

2. 必要なパッケージのインストール

プロジェクトで使用する主要なパッケージと、開発時に役立つdevDependenciesをインストールします。

通常パッケージ:

  • @supabase/supabase-js: Supabaseと連携するためのライブラリ
  • discord.js: Discord APIとやり取りするためのメインライブラリ
  • dotenv: 環境変数を扱うためのライブラリ
  • uuid: 一意のIDを生成するためのライブラリ

開発用パッケージ(devDependencies):

  • @eslint/js: JavaScriptのコード品質を保つためのESLint
  • @types/node: Node.jsの型定義
  • eslint: コード品質チェック用ツール
  • globals: ESLint用のグローバル変数定義
  • supabase: Supabaseのクライアント
  • typescript: TypeScriptサポート
  • typescript-eslint: TypeScriptとESLintの連携
npm install @supabase/supabase-js discord.js dotenv uuid
npm install --save-dev @eslint/js @types/node eslint globals supabase typescript typescript-eslint
teloshtelosh

3. main.tsとdeploy-commands.tsの作成 + env

DiscordのAPIとやり取りするためのファイルを作成します。
ついでにenvファイルの仮の状態も提供します。

  • .env
# NODE_ENV=development
NODE_ENV=production
# Discordの開発portalから取得できる Token とClientIDを入力
DISCORD_TOKEN="{Discord Bot Token}"
# BOT CLIENT ID
CLIENT_ID="{Discord Client ID}"

# 接続するサーバーのID
GUILD_ID="{接続するサーバーのID}"
# consoleをn流すchannel
CONSOLE_CHANNEL_ID="{ログ情報をDiscordで確認したい場合のチャンネルID}"

# データベースの設定
SUPABASE_PASS="{database pass}" #これに関してはmemo
SUPABASE_URL="{supabase url}"
SUPABASE_ANON_KEY="{supabase anon key}"
SUPABASE_TABLE_NAME="{table name}"
  • main.ts: Discord APIとBotのメインロジックを処理するファイル。DB関連はいったん抜きで
 import dotenv from 'dotenv';
import { Client, Events, GatewayIntentBits, ActivityType, CommandInteraction, TextChannel, ChatInputCommandInteraction } from 'discord.js';
import fs from 'fs';
import path from 'path';

dotenv.config();

// 環境変数の型安全な取得関数
function getRequiredEnvVar(name: string): string {
    const value = process.env[name];
    if (!value) {
        throw new Error(`${name}が見つかりませんでした。`);
    }
    return value;
}

// 定数の定義
const token = getRequiredEnvVar('DISCORD_TOKEN');
const TABLE_NAME = getRequiredEnvVar('SUPABASE_TABLE_NAME');
const CONSOLE_CHANNEL_ID = getRequiredEnvVar('CONSOLE_CHANNEL_ID');

// コマンドインターフェース
interface Command {
    data: {
        name: string;
        description: string;
    };
    execute: (interaction: CommandInteraction) => Promise<void>;
}

// クライアントの初期化
const client = new Client({ 
    intents: [GatewayIntentBits.Guilds] 
});

// コマンドを保持するMap
const commands: Map<string, Command> = new Map();
client.once(Events.ClientReady, async (c: Client) => {
    if (client.user) {
        client.user.setActivity("ここにBotのstateをかける", { type: ActivityType.Playing });
    }
    console.log(`準備OKです! ${c.user?.tag}がログインしました。`);
});

// コマンド読み込み
async function loadCommands(): Promise<void> {
    const commandsPath = path.join(__dirname, 'commands');
    const commandFiles = fs.readdirSync(commandsPath).filter(file => 
        file.endsWith('.js') || file.endsWith('.ts') || file.endsWith('.cjs')
    );

    for (const file of commandFiles) {
        try {
            const filePath = path.join(commandsPath, file);
            const command = await import(filePath);
            
            if (command.default?.data?.name) {
                console.log(`${command.default.data.name}を登録します。`);
                commands.set(command.default.data.name, command.default);
            }
        } catch (error) {
            console.error(`コマンド読み込み中にエラーが発生しました (${file}):`, error);
        }
    }
}

// コマンド読み込みの実行
loadCommands();

client.on(Events.InteractionCreate, async (interaction) => {
    if (!interaction.isCommand()) return;

    const command = commands.get(interaction.commandName);
    if (!command) {
        console.error(`${interaction.commandName}というコマンドには対応していません。`);
        return;
    }

    try {
        await command.execute(interaction as ChatInputCommandInteraction);
    } catch (error) {
        console.error('コマンド実行中にエラーが発生しました:', error);
        const response = { 
            content: 'コマンド実行時にエラーが発生しました。', 
            ephemeral: true 
        };

        if (interaction.replied || interaction.deferred) {
            await interaction.followUp(response);
        } else {
            await interaction.reply(response);
        }
    }
});

client.login(token).catch(error => {
    console.error('ログインに失敗しました:', error);
    process.exit(1);
});   
  • deploy-commands.ts: スラッシュコマンドをAPI経由でDiscordに登録するためのファイル。
import { REST, Routes, SlashCommandBuilder, CommandInteraction, RESTPostAPIChatInputApplicationCommandsJSONBody } from 'discord.js';
import fs from 'fs';
import path from 'path';
import dotenv from "dotenv";
dotenv.config();

// 環境変数としてapplicationId, guildId, tokenの3つが必要です
const {
    CLIENT_ID: applicationId,
    DISCORD_TOKEN: token,
    GUILD_ID : guildId,
    NODE_ENV
  } = process.env as { [key: string]: string };
  
  // 環境変数が足りない場合のエラーチェック
  if (!applicationId || !token || !(GUILD_DEV_ID || GUILD_MAIN_ID)) {
    throw new Error("Missing environment variables: CLIENT_ID, GUILD_ID, or DISCORD_TOKEN");
  }

// Commandインターフェースの定義
interface Command {
    data: SlashCommandBuilder; // コマンドデータを格納
    execute: (interaction: CommandInteraction) => Promise<void>; // コマンドの実行関数
}

// commands配列はRESTPostAPIChatInputApplicationCommandsJSONBody型の配列
const commands: RESTPostAPIChatInputApplicationCommandsJSONBody[] = [];

// 非同期関数を使用
const registerCommands = async (): Promise<void> => {
    // commandsフォルダ内の全てのコマンドファイルを読み込む
    const commandsPath = path.join(__dirname, 'commands');
    if (!fs.existsSync(commandsPath)) {
        throw new Error(`Commands directory not found: ${commandsPath}`);
    }
    const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js') || file.endsWith('.cjs'));

        for (const file of commandFiles) {
            const commandModule = await import(commandsPath + '/' + file);

            // デフォルトエクスポートされたコマンドを取得
            const command: Command = commandModule.default;
            commands.push(command.data.toJSON() as RESTPostAPIChatInputApplicationCommandsJSONBody); // コマンドをJSON形式で追加
        }

    const rest = new REST({ version: '10' }).setToken(token);

    // Discordサーバーにコマンドを登録
    try {
        await rest.put(
            Routes.applicationGuildCommands(applicationId, guildId),
            { body: commands },
        );
        console.log('サーバー固有のコマンドが登録されました!');
    } catch (error) {
        console.error('コマンドの登録中にエラーが発生しました:', error);
    }
};

registerCommands();    
teloshtelosh

一部開発の中で使っていた変数とか残ってるかもだけど 適宜自分の環境に合わせて削除とかしてください!

teloshtelosh

4. ファイル構成

プロジェクトのファイル構成は以下のように設定します。

root/
│
├── src/
│   ├── commands/
│   ├── main.ts
│   ├── deploy-commands.ts
│   └── DBClient.ts
├── .env
└── package.json
teloshtelosh

5. commandsフォルダーの生成 + コマンドテンプレートの作成

commands フォルダを作成し、各コマンドをモジュールとして管理します。また、コマンドのテンプレートとなるファイルを生成します。テンプレートには、コマンドの名前、説明、実行ロジックを含めます。 以下のテンプレートを参考に○○.tsを生成してコマンドを増やそう!

import { SlashCommandBuilder, EmbedBuilder, CommandInteraction, ChatInputCommandInteraction,  } from 'discord.js';

const command = {
	data: new SlashCommandBuilder()
		.setName('{{commandName}}')
		.setDescription('{{description}}'),
    async execute(interaction : {{ここに反応するときの型が来るので適宜変えてね}}) {
        // ここにコマンドの処理を記述します
        
    },
};

export default command;
teloshtelosh

ここからは割と自分たちでオリジナルのことをすると思うので
一旦基礎はここまでかな

teloshtelosh

続報を記載できればDB周りの整備が終わり次第DBクライアントの作成から
レベル管理のためのDB設計等かけるといいな

teloshtelosh

ついでにCloudRunでこのボットをデプロイしてみようの会

編集中