As of April 9th, 2023
Special thanks to @mtripg6666tdr
Discord.js v13で自作ボットを作っていたのですが、この度GitHubに公開して TypeScript + Discord.js v14にしてEslintできちんと整形しておこうと思い立って、この記事を書くに至りました。
筆者の環境
ローカル側
- Windows 11
- Node.js v18.15.0
- Git for Windows v2.40.0
- Visual Studio Code 1.77.1
(日本語化済み, Eslint, JavaScript and TypeScript Nightly,
Node.js Modules Intellisense (with Coffee)などの拡張機能導入済み) - TeraTerm 5 beta 1
サーバ側
- さくらのVPS(契約プランやリージョンは非公開)
- Ubuntu 22.04 LTS
- Node.js v18.15.0
- Git v2.40.0
- pm2 v5.2.2(
npm i -g pm2
)
まずレポジトリを開設
ブラウザでGithHubの自分のページを開いてRepositories
を選択、右上のNew
のボタンをクリック。
- Owner: 自分なのでそのまま
- Repository name: 今回はボット名の
BotStats
と入力 - Description: 説明。今回は
A Discord bot which shows the status of another music and TTS bots.
と入力 - Public / Private: 今回は
Public
を選択 -
Add a README file
にチェックを入れる - Add .gitignore:
Node
を選択 - Choose a license: 自分の好きなライセンスを選択、今回は
MIT
にしました
Create repository
のボタンをクリック。
これで自分のレポジトリの完成です。
ローカルにクローン
VS Codeのターミナル
を開いて、クローンしたい場所にcd
する。
先ほど作ったレポジトリをブラウザで開いて緑色のCode
ボタンからURLをコピー。
# クローン
git clone (コピーしたURL)
# ディレクトリ移動
cd BotStats
パッケージを入れて環境を作る
npmの設定
# まずは初期化
npm init -y
# 次に必要パッケージのインストール
npm i discord.js dotenv && npm i rimraf @types/node typescript -D
# Eslint系は多いので分けた
npm i eslint eslint-plugin-eslint-comments eslint-plugin-named-import-spacing eslint-plugin-import eslint-plugin-node @typescript-eslint/eslint-plugin @typescript-eslint/parser -D
package.json
を実行できるように書き換える。
{
"name": "botstats",
"version": "1.0.0",
"description": "A Discord bot which shows the status of another music and TTS bots.",
- "main": "index.js",
+ "private": true,
"scripts": {
- "test": "echo \"Error: no test specified\" && exit 1"
+ "build": "rimraf dist/ && tsc",
+ "onlystart": "node ./dist",
+ "start": "npm run build && npm run onlystart",
+ "lint": "tsc && eslint ./src/**/*.ts"
},
"keywords": [],
- "author": "",
- "license": "ISC",
+ "author": "nh-chitose",
+ "license": "MIT",
"dependencies": {
"discord.js": "^14.9.0",
"dotenv": "^16.0.3"
},
"devDependencies": {
"@types/node": "^18.15.11",
"@typescript-eslint/eslint-plugin": "^5.57.1",
"@typescript-eslint/parser": "^5.57.1",
"eslint": "^8.37.0",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-named-import-spacing": "^1.0.3",
"eslint-plugin-node": "^11.1.0",
"rimraf": "^4.4.1",
"typescript": "^5.0.3"
}
}
TypeScriptの設定
次はtsconfig.json
の設定です。
Discord-SimpleMusicBotの設定を拝借してきて(承諾済み)、必要に応じて書き換えます。
Discord-SimpleMusicBot/tsconfig.json
{
"compilerOptions": {
- "target": "ES2019",
+ "target": "ES2021",
"module": "CommonJS",
"noImplicitAny": true,
"noImplicitThis": true,
"noImplicitReturns": true,
"noImplicitOverride": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"sourceMap": true,
"forceConsistentCasingInFileNames": true,
"importHelpers": true,
"outDir": "dist/",
"allowJs": false,
"skipLibCheck": true,
- "lib": [
- "ES2019",
- "ES2020.Promise",
- "ES2020.BigInt",
- "ES2020.String",
- ],
+ "lib": ["ES2021"],
"moduleResolution": "node",
"strictBindCallApply": true,
- "resolveJsonModule": true,
- "noEmit": true
},
"include": [
- "src/**/*.ts",
- "util/lint-i18n.d.ts"
+ "src/**/*.ts"
],
"exclude": [
"**/node_modules",
- "test",
- "./docs",
"./dist",
"**/.*/"
]
}
Eslintの設定
さらに続いてEslintの設定をします。ファイル.eslintrc.json
を作ります。
またDiscord-SimpleMusicBotの設定を拝借してきて、必要に応じて書き換えます。
Discord-SimpleMusicBot/.eslintrc.json
{
"root": true,
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:eslint-comments/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "./tsconfig.json"
},
"plugins": [
"@typescript-eslint",
"import",
"node",
- "license-header",
"named-import-spacing"
],
"env": {
"node": true
},
"rules": {
/* (中略) */
// other plugins
"eslint-comments/no-unused-disable": "error",
"import/order": [1, {
"groups": [
"type",
"builtin",
"external",
[
"parent",
"sibling",
"index"
],
"object"
],
"pathGroups": [
{
- "pattern": "{eris,@mtripg6666tdr/**}",
- "group": "builtin",
+ "pattern": "@/components/common",
+ "group": "internal",
"position": "before"
}
],
"pathGroupsExcludedImportTypes": ["builtin", "type"],
"alphabetize": {
"order": "asc"
},
"newlines-between": "always"
}],
"node/exports-style": "error",
"node/no-deprecated-api": "warn",
"node/no-unpublished-bin": "error",
"node/process-exit-as-throw": "error",
"node/no-unpublished-import": "off",
"node/no-unpublished-require": "off",
"node/no-unsupported-features": "off",
"node/no-missing-import": "off",
"node/no-missing-require": "off",
"node/shebang": "error",
- "license-header/header": ["warn", "./util/license.js"],
"named-import-spacing/named-import-spacing": "warn",
// eslint rules
"eol-last": ["error", "always"],
/* (中略) */
"default-param-last": "off",
"init-declarations": "off",
"no-extra-semi": "off",
"indent": "off"
},
- "overrides": [
- {
- "files": ["./util/exampleDbServer/node/src/**/*.ts"],
- "parserOptions": {
- "project": "./util/exampleDbServer/node/tsconfig.json"
- }
- }
- ]
+ "overrides": []
}
そして.eslintignore
も作成して設定します。
dist/*
いざボットのリファクタリング
私が初めて書いたボットでトークンもハードコーディングしてあり、書き方も統一性がなく、アルゴリズムもぐちゃぐちゃなので設計から考え直します。
移植元のJavaScriptコード
const { Client, Intents, Permissions } = require('discord.js');
let myIntents = new Intents();
myIntents.add(Intents.FLAGS.GUILDS, Intents.FLAGS.GUILD_MESSAGES, Intents.FLAGS.GUILD_VOICE_STATES);
const client = new Client({ intents: myIntents });
client.once('ready', message => {
console.log('Bot準備完了~');
console.log(client.guilds.cache.map(a => a.name));
});
client.on('ready', message => {
client.user.setActivity( 'on ' + client.guilds.cache.size + ' servers', { type: 'PLAYING' });
});
client.on('voiceStateUpdate', async (oldState, newState) => {
console.log(newState.member.displayName);
let flag = 0;
if (newState.guild.me.permissions.has(Permissions.FLAGS.MANAGE_NICKNAMES) && newState.guild.me.permissions.has(Permissions.FLAGS.MANAGE_CHANNELS)) {
console.log("permission: OK");
flag++;
} else {
console.log("permission: Error");
}
if (flag == 1) {
if (oldState.channelId === null && newState.channelId !== null) {
console.log("入室検知");
if (newState.member.user.bot) {
var newDisplayName = newState.member.displayName.replace('🈳', '🈵');
try {
await newState.member.setNickname(newDisplayName);
if (newState.channel.userLimit == 2) {
newState.channel.setUserLimit(3);
}
} catch (e) {
console.log(e);
}
}
}
else if (oldState.channelId !== null && newState.channelId === null) {
console.log("退室検知");
if (oldState.member.user.bot && oldState.member.displayName.includes("🈵")) {
try {
var newDisplayName = oldState.member.displayName.replace('🈵', '🈳');
await newState.member.setNickname(newDisplayName);
console.log(oldState.channel.userLimit);
console.log(oldState.channel.name.indexOf('3'));
if (oldState.channel.userLimit == 3 && oldState.channel.name.indexOf('3') == -1) {
oldState.channel.setUserLimit(2);
}
} catch (e) {
console.log(e);
}
}
}
else if (oldState.channelId !== null && newState.channelId != null) {
if (newState.member.user.bot) {
try {
if (newState.channel.userLimit == 2) {
newState.channel.setUserLimit(3);
}
else if (oldState.channel.userLimit == 3 && oldState.channel.name.indexOf("3") == -1 && oldState.channel.name.indexOf("3") == -1) {
oldState.channel.setUserLimit(2);
}
} catch (e) {
console.log(e);
}
}
}
}
});
client.login("[TOKEN]");
再設計
- 権限があるか確認する
- ボットか確かめる
- VCに接続したのがボットならニックネームの変更を行う
- 2人制限のVCであれば3人に増やす
- 退出時には人数とニックネームを戻す
- メッセージにはいくつのサーバーで利用されているか表示する
- 起動時に参加サーバの一覧をコンソールに出す
というのがこのボットの機能です。
ここで追加要件があります。
- ロールの位置を確認する
- 置き換えのパターンを増やす(🈳🈵だけではなく⏹▶にも対応する)
- ケータイからログインしていることにする
- トークンを
.env
に持たせる
以上のことを考慮に入れて再設計します。
- ケータイでログインしていることにする
- 起動時に参加サーバの一覧をコンソールに出す
- メッセージにいくつのサーバで利用されているのか表示する
- トークンを
.env
に持たせる
以上4つは個別の動作です。
以下がイベントvoiceStateUpdate
の際に行いたい動作です。
- 変数
flag
を定義する。 - 権限があるなら
flag
をインクリメントする。 - ロールの位置でニックネームが変更可能なら
flag
をインクリメントする。 - VCに接続したのがボットだったら
flag
をインクリメントする。 - 変数
flag
=3だったら以下の動作をする。- VC入室時: ニックネームの🈳,⏹を🈵,▶に置き換えて2人制限の場合には3人にする
- VC退出時: ニックネームの🈵,▶をに🈳,⏹置き換えてVCが3人制限の時には2人にする
- VC移動時: 移動先が2人制限であれば3人に、移動元が3人制限であれば2人にする
例外) VCのチャンネル名に「3」(半角)か「3」(全角)が入る場合には3人制限を維持する
リファクタリング後
これを踏まえてTypeScriptへの移植、Discord.js v13⇒v14、Eslint対応を行った結果がこちらです。
import { PermissionFlagsBits, GatewayIntentBits, Client, ActivityType } from "discord.js";
import "dotenv/config";
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildVoiceStates,
],
ws: { properties: { browser: "Discord iOS" } },
});
// "⏹" これ
const stopButton = String.fromCharCode(9209);
client.once("ready", () => {
console.log("Bot is ready as " + client.user.username);
console.log(client.guilds.cache.map(a => a.name));
});
client.on("ready", () => {
client.user.setActivity("on " + client.guilds.cache.size + " servers", { type: ActivityType.Playing });
});
client.on("voiceStateUpdate", async (oldState, newState) => {
console.log(newState.member.displayName);
// flags to changeNick
let flag = 0;
// have permission?
if(newState.guild.members.me.permissions.has(PermissionFlagsBits.ManageNicknames) && newState.guild.members.me.permissions.has(PermissionFlagsBits.ManageChannels)){
console.log("Permission: OK");
flag++;
}else{
console.log("Permission: Error");
}
// have higher role?
if(newState.guild.roles.comparePositions(newState.guild.members.me.roles.highest, newState.member.roles.highest) > 0){
console.log("Role position: OK");
flag++;
}else{
console.log("Role position: Error");
}
// is bot?
if(newState.member.user.bot || oldState.member.user.bot){
console.log("Is bot?: yes");
flag++;
}else{
console.log("Is bot?: no");
}
if(flag === 3){
// When bot enters voice channel
if(!oldState.channelId && newState.channelId){
console.log("Detected bot entry to voice channel.");
const newDisplayName = newState.member.displayName.replace("🈳", "🈵").replace(stopButton, "▶");
try{
await newState.member.setNickname(newDisplayName);
if(newState.channel.userLimit === 2){
await newState.channel.setUserLimit(3);
}
} catch(e){
console.log(e);
}
}
// When bot leaves voice channel
else if(oldState.channelId && !newState.channelId){
console.log("Detected bot leave from voice channel.");
if(oldState.member.displayName.includes("🈵") || oldState.member.displayName.includes("▶")){
try{
const newDisplayName = oldState.member.displayName.replace("🈵", "🈳").replace("▶", stopButton);
await newState.member.setNickname(newDisplayName);
if(oldState.channel.userLimit === 3 && !oldState.channel.name.includes("3") && !oldState.channel.name.includes("3")){
await oldState.channel.setUserLimit(2);
}
} catch(e){
console.log(e);
}
}
}
// When bot moves voice channel
else if(oldState.channelId !== newState.channelId){
console.log("Detected bot moved.");
try{
if(newState.channel.userLimit === 2){
await newState.channel.setUserLimit(3);
}
if(oldState.channel.userLimit === 3 && !oldState.channel.name.includes("3") && !oldState.channel.name.includes("3")){
await oldState.channel.setUserLimit(2);
}
} catch(e){
console.log(e);
}
}
}
});
client.login(process.env.TOKEN).catch(e => console.log(e));
サーバーへの設置と起動
VS CodeからGitHubにコミット
し、変更の同期
をします。
次にSSH(TeraTerm)でサーバにログインし、以下の通りコマンドを打ちます。
# クローン
git clone (自分のレポジトリのURL)
# ディレクトリ移動
cd BotStats
# 依存関係のインストール
npm i
.env
ファイルはvimかなにかでコピペするか、FTPを使ってサーバーにアップロードします。
いよいよ最後の一段階です。
# pm2でプロセスを起動する
pm2 start npm --name botstats -- start
# 自動起動するプログラムの設定の保存
pm2 save
長い記事を読んでいただきありがとうございました。
以上になります。
おわりに
TypeScriptは"strict": true
にするとかなり厳密なチェックをしてきて、移行にも手間がかかりますが、そうでなければかなりスムーズにJavaScriptから移行できることがわかりました。
また、Discord.jsはv13からv14で特にIntents
の指定が大きく変わっていることがわかりましたが、その他小さな変更はドキュメンテーションを読んでいけばそんなに難しくはありませんでした。
今回使用したコードはGitHubでも公開しておりますし、ホスティングもしているのでサポートサーバからご利用頂けます。
初めてのTypeScriptとEslint、npmを使ったパッケージ管理、GitHubでの公開、そしてDiscord.jsのバージョン変更の記録がどなたかの役に立てば幸いです。
最後に次回予告です。次回は別のボットでDiscord.js v13からoceanic.jsにライブラリを乗り換えてみます。