Discord.jsをTypeScriptで書こう

## この記事について
[みすてむず いず みすきーしすてむず (4) Advent Calendar 2023](https://adventar.org/calendars/8652)の12月12日の記事になります。
[discord.js Guide](https://discordjs.guide/)を読みながら書いていてつまづいたところを解説しつつ、TypeScriptならではのポイントとかを書き留めておくものです。

## 前提
- JavaScriptが多少わかる
- Node.jsがインストール済みである
- パッケージマネージャーはnpmを使用するので、それ以外を使用する場合は適宜読み替えてください
- 開発者モードをオンにしてDiscord上のユニークIDが取れる
- Discord BotをDeveloper Potalで作成・設定済みである
- 環境:Ubuntu 22.04 LTS、typescript v5.2.2、discord.js v14.13.0

## プロジェクトを作成する
プロジェクトを作成するディレクトリで以下を実行する。
```shell-session
$ npm init
```
イニシャライズが終わったら以下を実行する。
```shell-session
$ npm install discord.js
```
さらにTypeScript環境をインストールする。
```shell-session
$ npm install typescript
```
開発環境のみにインストールする場合は代わりに以下を実行する。
```shell-session
$ npm install typescript --save-dev
```
npmの型情報をインストールする。
```shell-session
$ npm install @types/node
```
ts-nodeをインストールする。これは開発環境のみでいいと思う。
```shell-session
$ npm install ts-node --save-dev
```
tsconfig.jsonを生成する。
```shell-session
% npx tsc --init
```

## 初期設定
### トークン
Developers Potalのbotを選んで、SETTINGS > Botから取得できる。
トークンをコード中に書くのはセキュリティ的に良くないので、別の形で保存する。
今回は`config.json`を使用する。ほかのやり方については[こちら](https://discordjs.guide/creating-your-bot/#configuration-files "Configuration files | discord.js Guide")を参照のこと。
なお、トークンはおおむねアルファベットの大文字・小文字・数字・記号が混ざった、長いものである。そうでもない場合はトークンじゃなくてクライアントIDか何かと間違っているかもしれないので、Developer Potalでトークンを取得しなおすこと。
```json:config.json
{
	"token": "your-token"
}
```
:::note alert
トークンは決して漏洩させないように注意しなければならない。`config.json`等のファイルはGitやSubversionで管理しないこと。
:::

### .gitignore
上記注釈の通り、Git管理やnpmパッケージ化の際にいらないファイルが紛れ込まないように設定する。
```.gitignore
node_modules/
.env
config.json
build/
```
最低限必要なのはこの辺。[developers | npm Docs](https://docs.npmjs.com/cli/v10/using-npm/developers)を読むと、`.npmignore`はパッケージ化の際さらに追加で無視したいものがなければ`.gitignore`だけでいいらしい。

### tsconfig.json
tsconfig.json内の`compilerOptions`のコメントアウトされている部分から、以下のものを探してコメントアウトを外す。これをしないとconfig.jsonを読み込むコードにエラーが出る。また、`outDir`(コンパイル時の出力先)はデフォルト値が`./`であるかもしれないが、`./build`に分けたほうがいろいろと追いやすい。
```json:tsconfig.json(一部)
"resolveJsonModule": true,
```
```json:tsconfig.json(一部)
"outDir": "./build",
```

### package.json
`package.json`にも書き加えておこう。`scripts`にコマンドを書き加える。
```json:package.json(一部)
    "scripts": {
        "compile": "tsc -p .",
        "start": "node build/index.js"
    },
```
これで`npm run compile`でビルドが、`npm run start`で起動ができるようになった。

### サーバーに招待する
テスト用のサーバーを作って招待しておこう。
Developer PotalのSETTINGS > OAuth2 > URL Generatorから招待リンクが生成できる。
`bot`と`application.commands`にチェックを入れる。下のBot Permissionsは本番リリースの場合は必要十分なものにチェックを入れる必要があるが、テストの間はサーバーでbotにロールをつけてそこで管理しても大丈夫。
- bot
    Discord上でbotとして動作させるために必要
- application.commands
    後述するスラッシュコマンドを使用するために必要

## メインファイルを作る
ここまでの初期設定お疲れさまでした。ここからはTypeScriptを書いていく。
まずは`index.ts`ファイルを作成する。
内容は以下の通りだ。
```typescript:index.ts
import { Client, Events, GatewayIntentBits } from 'discord.js';
// config.jsonの内容が増えたときのことも考えて全部インポートしている
import * as config from './config.json';

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

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

client.login(config.token);
```
ClientクラスはBaseClientクラスを継承している。さらにBaseClientクラスはEventEmitterクラスを継承しているので、そのインスタンスは`once`や`emit`などのメソッドを持っている。EventEmitterクラスに関して詳しくは[こちら](https://nodejs.org/api/events.html "Events | Node.js v21.2.0 documentation")を参照。ざっくり説明すると、`client.once()`の第一引数にイベントを入れると第二引数の関数がそのイベントが発火したときに1回だけ実行される。第一引数の定義済みイベントは[こちら](https://old.discordjs.dev/#/docs/discord.js/14.14.0/typedef/Events "discord.js | Events")を参照。

## 実行
うまくできているかの確認を兼ねてさっそく実行してみよう。
```shell-session
$ npm run compile
$ npm run start
```
うまく行っていれば`Ready! Logged in as (Botのタグ)`と表示されるはずだ。タグとは`bot#0000`のような文字列だ。新しい環境だと後ろの`#0000`のような部分がないかもしれない。
止めたいときは`Ctrl + C`などで止めよう。

## いろいろなメッセージを送る
### チャンネルの取得
まずチャンネルを取得する。今回はサーバーのテキストチャンネル。サーバーのテキストチャンネルでない場合は型の変更が必要。'id'の欄にはstring型でユニークID(数字)を指定。
```typescript
// client.channels.cache.get(<id>)はChannel | undefinedなので
// 派生型で受ける場合はasで型指定が必要
const channel: BaseGuildTextChannel = client.channels.cache.get('id') as BaseGuildTextChannel;
```
これで`channel`変数にチャンネルが入る。

### 文字列の送信
```typescript
const channel: BaseGuildTextChannel = client.channels.cache.get('id') as BaseGuildTextChannel;
// メッセージの送信自体は1行だけ
await channel.send('Hello!');
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3350643/91b37767-1e30-c542-9edd-60961d224813.png)

これで「Hello!」というメッセージがIDで指定したチャンネルに送られる。
`BaseGuildTextChannel#send()`は引数にstringまたはMessagePayload、MessageReplyOptionsが取れる。

### 返信
```typescript
// 返信する投稿のあるチャンネルを取得する
const channel: BaseGuildTextChannel = client.channels.cache.get('channelId') as BaseGuildTextChannel;
// チャンネル内から返信する投稿を取得する
// channel.message.fetch()はMessage|Collection<Snowflake, Message>なので
// as Messageをつけないと型エラーが出る
const message: Message = await channel.messages.fetch('messageId') as Message;
// 返信自体は1行だけ
await message.reply('Hello!');
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3350643/63e191a4-cb54-69ef-c0b4-101e205fe2dc.png)

「Hello!」と返信される。

### 埋め込み
```typescript
const channel: BaseGuildTextChannel = client.channels.cache.get('id') as BaseGuildTextChannel;
const embedObject = new EmbedBuilder()
    .setColor(0xff0000)
    .setTitle('タイトル')
    .setDescription('埋め込み投稿の例です')
await channel.send({embeds: [embedObject]});
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3350643/e20749c0-1fa8-f032-f1d1-56976ff8de4c.png)

埋め込みの内部にはたくさん要素が入れられる。詳細は[こちら](https://discordjs.guide/popular-topics/embeds.html "Embeds | discord.js Guide")を参照。
`channel.send()`に渡す`embeds`パラメーターは配列を取ることに注意。

### ボタン
```typescript
const channel: BaseGuildTextChannel = client.channels.cache.get('id') as BaseGuildTextChannel;
const button = new ButtonBuilder()
    .setCustomId('button1')
    .setLabel('Click it!')
    .setStyle(ButtonStyle.Primary);
const actionRow = new ActionRowBuilder<ButtonBuilder>()
    .addComponents(button);
await channel.send({components: [actionRow]});
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3350643/155486dd-b00f-7a83-5685-6067b202868f.png)

カスタムIDはどのボタンが押されたかを取得するときとかに使う。`ButtonInteraction#customId`でそのインタラクションで押されたボタンのカスタムIDを取得できる。
ラベルはボタンの表示する文字。
ボタンのスタイルについては[こちら](https://discordjs.guide/message-components/buttons.html#button-styles "Button | discord.js Guide")を参照。

:::note warn
[Action rows | discord.js Guide](https://discordjs.guide/message-components/action-rows.html#building-action-rows)にある通り、TypeScriptではActionRowBuilderのジェネリクス型指定が必要です。
:::

## スラッシュコマンド
正直よくわかってない。[ここ](https://discordjs.guide/creating-your-bot/slash-commands "discord.js | Creating slash commands")を参考に改良しつつ書くと大体動く。
TypeScript化のために変更する部分の参考例を以下に示す。もとはリンク先の`commands/utility/ping.js`である。
```diff_typescript:commands/utility/ping.ts
- const { SlashCommandBuilder } = require('discord.js');
+ import { CommandInteraction, SlashCommandBuilder } from "discord.js";

module.exports = {
	data: new SlashCommandBuilder()
		.setName('ping')
		.setDescription('Replies with Pong!'),
-   async execute(interaction) {
+ 	async execute(interaction: CommandInteraction) {
		await interaction.reply('Pong!');
	},
};
```
`interaction`の型を明示することで、`execute`内で`interaction`のメソッドやプロパティを使うときにTypeScriptの恩恵を得られる。型のインポートを忘れずに。

## インタラクション
スラッシュコマンドやボタンの押下、モーダル(後述)の送信などのイベントをインタラクションと呼ぶ。基本的にインタラクションへの応答は3秒以内に行わないといけない。それ以上かかる場合は`interaction.deferReply()`を使用してDiscordに応答に時間がかかることを通知する必要がある。
なお、`interaction.deferReply({ephemeral: true})`とすることで、この応答を一時的なものとすることもできる。

## モーダル
モーダルはインタラクションに対する返答のひとつである。初期応答としてしか使用できないため、`deferReply()`を使用した場合は使用できない。
```typescript
const question = new TextInputBuilder()
    .setCustomId(`question`)
    .setLabel(`好きな色を教えてね`)
    .setStyle(TextInputStyle.Short);
const actionRow = new ActionRow<ModalActionRowComponentBuilder>()
    .addComponents(question);
const modal = new ModalBuilder()
    .setCustomId('modal1')
    .setTitle('モーダルの例です');
modal.addComponents(actionRow);
await interaction.showModal(modal);
```
`ModalBuilder#addComponents()`は残余引数を取るので、複数の引数を渡すことで複数のActionRowを一度にモーダルに追加することもできる。
モーダルにおいて、`ActionRowBuilder`は`TextInputBuilder`を1つだけ持てる。また、`ModalBuilder`は`ActionRowBuilder`を5つまで持てる。

## その他ポイント
### インタラクションを発生させた人にだけ見える返信
ボタンを押した人やモーダルを送信した人、スラッシュコマンドを使用した人にだけ見える返信を送ることができる。以下において、`interaction`は`BaseInteraction`かその派生型である。
```typescript
interaction.reply({content: 'あなたにしか見えない', ephemeral: true});
```
`ephemeral`オプションを`true`にする。

### Collectionを利用しよう
各クラスの`chache()`等のメソッドで返り値として手に入る`Collection`クラスは`Map`を継承している。そのため、`Map`の持っているメソッドはそのまま使えるが、`Collection`クラスのメソッドのほうが早いことが多い。特にサーバーのユーザーに対して`.has()`2回とかするとインタラクションの期限の3秒がすぐ終わる(体感)ので、`.hasAny()`または`.hasAll()`を使うことを推奨する。

## まとめ
discord.js GuideをTypeScript向けに変えつつ軽くまとめたものなので、わからなかったら参考文献のリンクを読もう。

## 参考文献
- 一番強いけど英語 [Introduction | discord.js Guide](https://discordjs.guide/)
- ちょっと古いけど日本語 [discord.js Japan User Group](https://scrapbox.io/discordjs-japan/)