2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【初投稿】discordjs v13@jsのボットをdiscordjs v14@tsに移行した話

Last updated at Posted at 2023-04-09

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を実行できるように書き換える。

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

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

.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も作成して設定します。

.eslintignore
dist/*

いざボットのリファクタリング

私が初めて書いたボットでトークンもハードコーディングしてあり、書き方も統一性がなく、アルゴリズムもぐちゃぐちゃなので設計から考え直します。

移植元のJavaScriptコード

botstats.js
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("") == -1) {
                        oldState.channel.setUserLimit(2);
                    }
                } catch (e) {
                    console.log(e);
                }
            }

        }
    }
});

client.login("[TOKEN]");

再設計

  • 権限があるか確認する
  • ボットか確かめる
  • VCに接続したのがボットならニックネームの変更を行う
  • 2人制限のVCであれば3人に増やす
  • 退出時には人数とニックネームを戻す
  • メッセージにはいくつのサーバーで利用されているか表示する
  • 起動時に参加サーバの一覧をコンソールに出す
    というのがこのボットの機能です。

ここで追加要件があります。

  • ロールの位置を確認する
  • 置き換えのパターンを増やす(🈳🈵だけではなく⏹▶にも対応する)
  • ケータイからログインしていることにする
  • トークンを.envに持たせる

以上のことを考慮に入れて再設計します。

  • ケータイでログインしていることにする
  • 起動時に参加サーバの一覧をコンソールに出す
  • メッセージにいくつのサーバで利用されているのか表示する
  • トークンを.envに持たせる
    以上4つは個別の動作です。

以下がイベントvoiceStateUpdateの際に行いたい動作です。

  1. 変数flagを定義する。
  2. 権限があるならflagをインクリメントする。
  3. ロールの位置でニックネームが変更可能ならflagをインクリメントする。
  4. VCに接続したのがボットだったらflagをインクリメントする。
  5. 変数flag=3だったら以下の動作をする。
    • VC入室時: ニックネームの🈳,⏹を🈵,▶に置き換えて2人制限の場合には3人にする
    • VC退出時: ニックネームの🈵,▶をに🈳,⏹置き換えてVCが3人制限の時には2人にする
    • VC移動時: 移動先が2人制限であれば3人に、移動元が3人制限であれば2人にする
      例外) VCのチャンネル名に「3」(半角)か「3」(全角)が入る場合には3人制限を維持する

リファクタリング後

これを踏まえてTypeScriptへの移植、Discord.js v13⇒v14、Eslint対応を行った結果がこちらです。

index.ts
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("")){
            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("")){
          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)でサーバにログインし、以下の通りコマンドを打ちます。

TeraTerm
# クローン
git clone (自分のレポジトリのURL)
# ディレクトリ移動
cd BotStats
# 依存関係のインストール
npm i

.envファイルはvimかなにかでコピペするか、FTPを使ってサーバーにアップロードします。

いよいよ最後の一段階です。

TeraTerm
# 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にライブラリを乗り換えてみます。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?