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

`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](https://vps.sakura.ad.jp/)(契約プランやリージョンは非公開)
* Ubuntu 22.04 LTS
* Node.js v18.15.0
* Git v2.40.0
* pm2 v5.2.2(`npm i -g pm2`)

# まずレポジトリを開設
ブラウザで[GithHub](https://github.com/)の自分のページを開いて`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をコピー。

```sh: クローンしてディレクトリを移動
# クローン
git clone (コピーしたURL)
# ディレクトリ移動
cd BotStats
```

# パッケージを入れて環境を作る
## npmの設定
```sh: 必要パッケージのインストール
# まずは初期化
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`を実行できるように書き換える。
```diff_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](https://github.com/mtripg6666tdr/Discord-SimpleMusicBot/)の設定を拝借してきて(承諾済み)、必要に応じて書き換えます。
[Discord-SimpleMusicBot/tsconfig.json](https://github.com/mtripg6666tdr/Discord-SimpleMusicBot/blob/8b2e818fd6d6d6734de462a9cf80a4ef9effebc3/tsconfig.json)
```diff_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](https://github.com/mtripg6666tdr/Discord-SimpleMusicBot/)の設定を拝借してきて、必要に応じて書き換えます。
[Discord-SimpleMusicBot/.eslintrc.json](https://github.com/mtripg6666tdr/Discord-SimpleMusicBot/blob/8b2e818fd6d6d6734de462a9cf80a4ef9effebc3/.eslintrc.json)
```diff_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コード
```js: 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人制限を維持する

## リファクタリング後
これを踏まえてTypeScriptへの移植、Discord.js v13⇒v14、Eslint対応を行った結果がこちらです。
```ts: 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)でサーバにログインし、以下の通りコマンドを打ちます。
```sh: TeraTerm
# クローン
git clone (自分のレポジトリのURL)
# ディレクトリ移動
cd BotStats
# 依存関係のインストール
npm i
```

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

いよいよ最後の一段階です。
```sh: TeraTerm
# pm2でプロセスを起動する
pm2 start npm --name botstats -- start
# 自動起動するプログラムの設定の保存
pm2 save
```
長い記事を読んでいただきありがとうございました。
以上になります。

# おわりに
TypeScriptは`"strict": true`にするとかなり厳密なチェックをしてきて、移行にも手間がかかりますが、そうでなければかなりスムーズにJavaScriptから移行できることがわかりました。
また、Discord.jsはv13からv14で特に`Intents`の指定が大きく変わっていることがわかりましたが、その他小さな変更はドキュメンテーションを読んでいけばそんなに難しくはありませんでした。

今回使用したコードは[GitHub](https://github.com/nh-chitose/BotStats)でも公開しておりますし、ホスティングもしているので[サポートサーバ](https://discord.gg/CAP6JJPdaE)からご利用頂けます。

初めてのTypeScriptとEslint、npmを使ったパッケージ管理、GitHubでの公開、そしてDiscord.jsのバージョン変更の記録がどなたかの役に立てば幸いです。

最後に次回予告です。次回は別のボットでDiscord.js v13からoceanic.jsにライブラリを乗り換えてみます。