TypeScriptでdiscord.jsを叩く
![蒼百合](https://res.cloudinary.com/zenn/image/fetch/s--ssWDougR--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_70/https://storage.googleapis.com/zenn-user-upload/avatar/1230a00b24.jpeg)
TypeScriptからdiscord.js(v14)を叩いてDiscord Botを書こうとしたら幾つかの罠に引っかかったので解決法を纏めておきます.
筆者のJavaScript/TypeScript歴は共に0日なので適当なことを言っていたら突っ込んでいただけると嬉しいです.
![蒼百合](https://res.cloudinary.com/zenn/image/fetch/s--ssWDougR--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_70/https://storage.googleapis.com/zenn-user-upload/avatar/1230a00b24.jpeg)
スラッシュコマンド
を参考にスラッシュコマンドを作ってみる.
.
├── 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
する必要がある.
このとき冒頭で
import * as commandA from"./commands/utility/commandA.ts"
import * as commandB from"./commands/utility/commandB.ts"
のようにそれぞれを読み込んでも良いが,これではコマンドが増えるたびにimport
が増えていくので面倒だ.
これについて
によると
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プロパティを持たない
すると
client.commands = new Collection();
の部分で早速 biome に怒られた.
Client
型は commands
プロパティを持たないとのこと.JSでは存在しないプロパティも自動的に追加してくれるようだが,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
を使いたいので
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.`,
);
}
の部分を
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.data
は unknown
型なので,client.commands
に登録できないとのこと.
command.execute
についても同様だ.
なるほど.
const command = import(filePath);
の時点で command
は Promise<any>
型なので確かにその通りだ.
そこで次のように修正した.
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
は次のようになっている.
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を実装してみることにする.
![蒼百合](https://res.cloudinary.com/zenn/image/fetch/s--ssWDougR--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_70/https://storage.googleapis.com/zenn-user-upload/avatar/1230a00b24.jpeg)
commandA (ping-pong)
commandA の内容はただの ping-pong にした.
によると
const { SlashCommandBuilder } = require('discord.js');
module.exports = {
data: new SlashCommandBuilder()
.setName('ping')
.setDescription('Replies with Pong!'),
async execute(interaction) {
await interaction.reply('Pong!');
},
};
とするらしい.
これを次のように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
の引数の型を指定するために CommandInteraction
を import
する必要があるが,この他に困ったことは起きなかった.