Open3

TypeScriptでdiscord.jsを叩く

蒼百合蒼百合

TypeScriptからdiscord.js(v14)を叩いてDiscord Botを書こうとしたら幾つかの罠に引っかかったので解決法を纏めておきます.

筆者のJavaScript/TypeScript歴は共に0日なので適当なことを言っていたら突っ込んでいただけると嬉しいです.

蒼百合蒼百合

スラッシュコマンド

https://discordjs.guide/creating-your-bot/slash-commands.html#before-you-continue

を参考にスラッシュコマンドを作ってみる.

.
├── node_modules/ (省略)
├── src
│   ├── commands
│   │   └── utility
│   │       ├── commandA.ts
│   │       └── commandB.ts
│   ├── deploy.ts
│   └── index.ts
├── package-lock.json
├── package.json
└── tsconfig.json

のような構成で commandA, commandB, ... を作ると,index.ts でそれらを import する必要がある.
このとき冒頭で

src/index.ts
import * as commandA from"./commands/utility/commandA.ts"
import * as commandB from"./commands/utility/commandB.ts"

のようにそれぞれを読み込んでも良いが,これではコマンドが増えるたびにimportが増えていくので面倒だ.
これについて

https://discordjs.guide/creating-your-bot/command-handling.html#loading-command-files

によると

index.js
client.commands = new Collection();

const foldersPath = path.join(__dirname, "commands");
const commandFolders = fs.readdirSync(foldersPath);

for (const folder of commandFolders) {
	const commandsPath = path.join(foldersPath, folder);
	const commandFiles = fs
		.readdirSync(commandsPath)
		.filter((file) => file.endsWith(".js"));

	for (const file of commandFiles) {
		const filePath = path.join(commandsPath, file);
		const command = require(filePath);

		if ("data" in command && "execute" in command) {
			client.commands.set(command.data.name, command);
		} else {
			console.log(
				`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`,
			);
		}
	}
}

とすると ./commands/xxx/ にあって data, execute のそれぞれが公開されているファイルをコマンドとして読み込んでくれるようだ.これを参考に index.ts を書いてみる.

Client型はcommandsプロパティを持たない

すると

src/index.ts
client.commands = new Collection();

の部分で早速 biome に怒られた.

Client 型は commands プロパティを持たないとのこと.JSでは存在しないプロパティも自動的に追加してくれるようだが,TSではそうはいかない.
そこで次のようなファイルを用意した.

src/@types/client.d.ts
import { Client } from "discord.js";
import type { Collection } from "discord.js";

declare module "discord.js" {
	interface Client {
		commands: Collection<
			string,
			(interaction: CommandInteraction) => Promise<void>
		>;
	}
}

.d.ts の付くファイルは型定義ファイルと呼ばれ,以上のように記述することでClientの型定義を拡張できるらしい.

このファイルをどこかから読み込む必要はなく,ただ書くだけで良い.

require ? import ?

import を使いたいので

index.js
const filePath = path.join(commandsPath, file);
const command = require(filePath);

if ("data" in command && "execute" in command) {
	client.commands.set(command.data.name, command);
} else {
	console.log(
		`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`,
	);
}

の部分を

src/index.ts
const filePath = path.join(commandsPath, file);
const command = import(filePath);

if (command.data && command.execute) {
	client.commands.set(command.data.name, command);
} else {
	console.log(
		`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`,
	);
}

のように書き換えてみた.
すると

client.commands.set(command.data.name, command.execute);

の部分で怒られた.

command.dataunknown 型なので,client.commands に登録できないとのこと.
command.execute についても同様だ.

なるほど.

const command = import(filePath);

の時点で commandPromise<any> 型なので確かにその通りだ.
そこで次のように修正した.

src/index.ts
const filePath = path.join(folderPath, file);

(async () => {
	const command = await import(filePath);

	if (command.data && command.execute) {
		client.commands.set(command.data.name, command.execute);
	} else {
		console.log(
			`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`,
		);
	}
})();

これで import 問題は解決だ.

この時点で index.ts は次のようになっている.

src/index.ts
import { Client, GatewayIntentBits, Collection } from "discord.js";

import "dotenv/config";

import * as fs from "node:fs";
import * as path from "node:path";

const client = new Client({
	intents: [
		GatewayIntentBits.Guilds,
		GatewayIntentBits.GuildMessages,
		GatewayIntentBits.MessageContent,
	],
});

client.commands = new Collection();

const foldersPath = path.join(__dirname, "commands");
const commandFolders = fs.readdirSync(foldersPath);

for (const folder of commandFolders) {
	const folderPath = path.join(foldersPath, folder);
	const files = fs
		.readdirSync(folderPath)
		.filter((file) => file.endsWith(".js"));

	for (const file of files) {
		const filePath = path.join(folderPath, file);

		(async () => {
			const command = await import(filePath);

			if (command.data && command.execute) {
				client.commands.set(command.data.name, command.execute);
			} else {
				console.log(
					`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`,
				);
			}
		})();
	}
}

client.once(Events.ClientReady, (readyClient) => {
	console.log(`Ready! Logged in as ${readyClient.user.tag}`);
});

client.login(process.env.TOKEN);

この時点ではまだコマンドを受け付ける処理を書いていないが,続けてcommandAを実装してみることにする.

蒼百合蒼百合

commandA (ping-pong)

commandA の内容はただの ping-pong にした.

https://discordjs.guide/creating-your-bot/slash-commands.html#individual-command-files

によると

commands/utility/commandA.js
const { SlashCommandBuilder } = require('discord.js');

module.exports = {
	data: new SlashCommandBuilder()
		.setName('ping')
		.setDescription('Replies with Pong!'),
	async execute(interaction) {
		await interaction.reply('Pong!');
	},
};

とするらしい.
これを次のようにTSライクに書いてみる.

commands/utility/commandA.ts
import { SlashCommandBuilder } from "discord.js";
import type { CommandInteraction } from "discord.js";

export const data = new SlashCommandBuilder()
	.setName("ping")
	.setDescription("Replies with Pong!");

export async function execute(interaction: CommandInteraction) {
	await interaction.reply("Pong!");
}

ここでは execute の引数の型を指定するために CommandInteractionimport する必要があるが,この他に困ったことは起きなかった.