Dartで実用Discord Bot（nyxx）

# はじめに

この記事は[Dart](https://dart.dev/)と[nyxx](https://pub.dev/packages/nyxx)を利用した Discord Bot 開発のチュートリアルです。
私は[discord.py](https://discordpy.readthedocs.io/ja/latest/index.html)の開発終了と共にDiscord BotをDartに切り替えました。
しかし、Dartで書かれたDiscord Botに関する記事はまだ少なく、ライブラリのサンプルコードも簡潔であるため、初めて触る方は苦労するでしょう。

この記事ではnyxxの内容を簡単に噛み砕き、
[Shun Tannai (@1ntegrale9)](https://qiita.com/1ntegrale9)さんの[Pythonで実用Discord Bot(discordpy解説)](https://qiita.com/1ntegrale9/items/9d570ef8175cf178468f)に倣って、
Botを作成する手順とよく使う機能の実装方法を紹介します。

## nyxxについて

[nyxx](https://pub.dev/packages/nyxx)はDartでDiscordボットを作成するためのシンプルで堅牢なフレームワークです。公式によると特徴は以下の通り。

- **スラッシュコマンドのサポート**  
  スラッシュコマンドを作成および処理するための簡単なAPIをサポートおよび提供する
- **コマンドフレームワークが含まれている**  
  コマンドをサポートするボットをすばやく作成する方法がある。フレームワークの実装はシンプルで、なおかつすべてが自動的に行われる
- **クロスプラットフォーム**  
  コマンドライン、ブラウザ、およびモバイルデバイスで動作する
- **素晴らしいコントロール**  
  すべての送信HTTPリクエストまたはWebSocketメッセージを制御できる
- **完成したライブラリ**  
  ほぼすべてのDiscordAPIエンドポイントをサポートしている

# 困ったときは

- Dartの言語仕様とコミュニティ
    - [Dart documentation](https://dart.dev/guides)（英語）  
  公式サイトのドキュメント。Javaなどに比べてシンプルに書かれています。日本語版はありません。
    - [Dart 2 Language Guide](https://www.cresc.co.jp/tech/java/Google_Dart2/language/overview/overview.html)（日本語）  
  言語概説書（A Tour of the Dart Language）の和訳をベースとし、一部を補足して解説したサイト。
- Discord公式APIの仕様とコミュニティ
    - [Discord Developer Portal](https://discord.com/developers/docs/intro)（英語）
    - [Discord サーバー](https://discord.gg/discord-developers)（英語）
- nyxxの仕様とコミュニティ
    - [nyxx - Dart API docs](https://pub.dev/documentation/nyxx/latest/)（英語）
    - [Discord サーバー](https://discord.com/invite/nyxx)（英語）

# Discord API Keyの取得

まずは[Discord Developer Portal](https://discordapp.com/developers/applications/)でBotのアカウントを作成し、
Discordサーバーに登録しましょう。
アクセストークンも必要なので取得してください。

<details><summary>アクセストークンってなーんだ</summary>アクセストークンとは、Discordボットとやり取りするときに必要な合言葉です。アクセストークンを知っていればボットを操ることができます。
</details>

:::note alert
<b>アクセストークンは絶対に公開してはいけません</b>
アクセストークンさえ分かればボットを乗っ取ることができます。
:::

詳細な手順は以下の[Shun Tannai (@1ntegrale9)](https://qiita.com/1ntegrale9)さんの記事を参照してください。
[Discord Botアカウント初期設定ガイド for Developer](https://qiita.com/1ntegrale9/items/cb285053f2fa5d0cccdf)

# 環境構築

Windowsでは[scoop](https://scoop.sh/)で簡単に`dart`コマンドが使えるようになります。

```console
PS> scoop install dart
PS> dart --version
Dart SDK version: 2.14.4 (stable) (Wed Oct 13 11:11:32 2021 +0200) on "windows_x64"
```

以下のコマンドを実行してDiscordボットを作るためのリポジトリを作ります。
`your-repository-name`はリポジトリの名前です。

```console
PS> dart create your-repository-name
PS> dart run # 動作確認
Hello, world!
```

`nyxx`をインストールします。

```console
PS> dart pub add nyxx
```

さて、これから実際にコードを書くことになりますが、
**アクセストークンを絶対に公開しないようにする**ために、
環境変数を設定します。

```bat:PowerShellの場合
rem 自分のBotのアクセストークンに置き換えてください
setx DISCORD_BOT_TOKEN "THi5IsDuMMyaCCesSTOK3n00.Cl2FMQ.ThIsi5DUMMyAcc3s5ToKen0000"
```

```shell:Bashの場合
# 自分のBotのアクセストークンに置き換えてください
export DISCORD_BOT_TOKEN="THi5IsDuMMyaCCesSTOK3n00.Cl2FMQ.ThIsi5DUMMyAcc3s5ToKen0000"
```

**アクセストークンを絶対に公開しないようにする**ためには、
**ソースコードにトークンをベタ書きしない**ことなんですが、
そのために環境変数を設定します。

とはいえ、
「デバッグ中だから、今だけ……」
「今は開発中だから、ちょっとだけ……」
と思って**ソースコードにトークンをベタ書きするのが人間の性**です。
そして、**うっかりコミットしてしまい、気づかぬままプッシュして、トークンお漏らしするまでが人間の性**です。

まあトークンお漏らししても、
[GitHubは優秀なのですぐに教えてくれる](https://github.co.jp/features/security)し、
[Discordもそれを知ってかトークンを自動で無効化してくれます](https://twitter.com/love_kobato_all/status/1315939697906970624)けどね。
しかし、あなたがGitHubを使っているとは限りませんし、
そもそもコミットしたりプッシュするなというわけで……

そこで、`git-secrets`を使います。
`git-secrets`は機密情報が含まれたファイルのコミットを機械的にリジェクトします。

`git-secrets`をインストールします。
インストールの仕方の詳細は[こちら](https://github.com/awslabs/git-secrets#installing-git-secrets)をご覧ください（OSによって異なります）。

```console:Windowsの場合
PS> cd .. # 任意のリポジトリに移動
PS> git clone https://github.com/awslabs/git-secrets
PS> cd ./git-secrets/
PS> ./install.ps1
Checking to see if installation directory already exists...
Creating installation directory.
Copying files.
Checking if directory already exists in Path...
Adding to path.
Adding to user session.
Done.
```

さて、Discordボットのリポジトリに再び戻って、設定を行います。

```console
PS> cd ../your-repository-name
PS> git secrets --add '[A-z0-9_]{24}\.[A-z0-9_]{6}\.[A-z0-9_]{27}\.'
PS> git secrets --install
```

# 事始め

何事もやってみることが大切です。
nyxxではいくつかの方法をサポートしています。

- **[nyxx](https://github.com/nyxx-discord/nyxx)**  
  ボットを作るときの必須ライブラリです  
  これだけでも基本的なボットを作ることができます
- **[nyxx_interactions](https://github.com/nyxx-discord/nyxx)**  
  スラッシュコマンドを使いたい場合はこれを使いましょう
- **[nyxx_commander](https://github.com/nyxx-discord/nyxx_commander)**  
  簡単にコマンドで反応するボットが作れます  
  しかし、サンプルコードがちょっと動かなかったので、これについては後日書きます
- **[nyxx_extensions](https://github.com/nyxx-discord/nyxx_extensions)**  
  拡張機能を作ることができますが、上級者向けなので本記事では割愛します  
- **[nyxx_lavalink](https://github.com/nyxx-discord/nyxx_lavalink)**  
  Lavalink APIにサポートを追加することで音楽ボットを作成できますが、本記事では割愛します  
- **[nyxx_pagination](https://github.com/nyxx-discord/nyxx_pagination)**  
  ページネーションを使いたい場合はこれを使いましょう

## 基本的な使用法（nyxx）

まずはサンプルコードを書いて、きちんと動くか確認してみましょう。
`Ping!`というメッセージを送信すると、
それに反応して、`Pong!`というメッセージを送信するボットを作ります。

```dart
import 'dart:io';

import "package:nyxx/nyxx.dart";

// メイン関数
void main() {
  // アクセストークンの取得
  final Map<String, String> envVars = Platform.environment;
  final String? token = envVars["DISCORD_BOT_TOKEN"];
  if (token == null) {
    throw "Token is not difined. Please set DISCORD_BOT_TOKEN.";
  }
  // 新しいボットのインスタンスを作成します
  final bot =
      NyxxFactory.createNyxxWebsocket(token, GatewayIntents.allUnprivileged)
        ..registerPlugin(Logging()) // デフォルトのロギングプラグイン
        ..registerPlugin(CliIntegration()) // SIGTERMおよびSIGKILLを介してアプリケーションを停止できるプラグイン
        ..registerPlugin(IgnoreExceptions()) // 発生する可能性のあるキャッチされない例外を処理するプラグイン
        ..connect();

  // ボットが起動すると発生するイベントです
  // キャッシュは空である場合もあれば、完全でない場合もあることに注意してください
  bot.eventsWs.onReady.listen((e) {
    print("Ready!");
  });

  // メッセージを受信するとイベントが発火します
  bot.eventsWs.onMessageReceived.listen((e) {
    // メッセージの内容が「Ping!」と等しいかどうかを確認します
    if (e.message.content == "Ping!") {
      // メッセージが受信されたチャンネルへ「Pong!」を送ります
      e.message.channel.sendMessage(MessageBuilder.content("Pong!"));
    }
  });
}
```

説明はコメントアウトに書いてる通りです。
`dart run`をして動かしてみましょう。
うまく動けばこのようになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/196882/1b9b3660-0602-9a90-733a-b3558c8be7f3.png)
うまく動きましたか？

:::note info
<b>環境変数を設定したのに、うまくトークンが読み込めない場合は……</b>
Windowsの場合、再起動するとうまくいく場合があります。
:::

## スラッシュコマンドを使う（nyxx_interactions）

スラッシュコマンドを使いたいときは、`nyxx_interactions`をインストールします。

```console
PS> dart pub add nyxx_interactions
```

サンプルコードを書いて、きちんと動くか確認してみましょう。
`ping`コマンドを送信すると、
それに反応して、`Pong!`というメッセージを送信するボットを作ります。

```dart
import 'dart:io';

import "package:nyxx/nyxx.dart";
import "package:nyxx_interactions/nyxx_interactions.dart";

// 名前（name）、説明（description）、およびサブオプション（sub options）を使用してスラッシュコマンドビルダーのインスタンスを作成します
// コマンドをDiscordと同期させ、それらに応答できるようにするために使用されます
// SlashCommandBuilderを使用すると、コマンドイベントに応答できるスラッシュコマンドのハンドラを登録できます
final singleCommand = SlashCommandBuilder("ping", "\"Pong!\"を返します", [],
    // スラッシュコマンドが登録されているギルドです
    // グローバルコマンドの場合は指定しません
    guild: 302360552993456135.toSnowflake())
  // ハンドラは、相互作用に応答するために必要なすべてのものを含むSlashCommandInteractionのパラメータを持つ関数を受け入れます
  // そこから2つのルートがあります。ACKしてから後で応答するか、ACKなしですぐに応答します
  // ACKを送信すると、ボットが考えていることを示すインジケータが表示され、そこから15分以内にそのインタラクションに応答します
  ..registerHandler((event) async {
    await event.respond(MessageBuilder.content("Pong!"));
  });

void main() {
  final Map<String, String> envVars = Platform.environment;
  final String? token = envVars["DISCORD_BOT_TOKEN"];
  if (token == null) {
    throw "Token is not difined. Please set DISCORD_BOT_TOKEN.";
  }
  final bot =
      NyxxFactory.createNyxxWebsocket(token, GatewayIntents.allUnprivileged)
        ..registerPlugin(Logging())
        ..registerPlugin(CliIntegration())
        ..registerPlugin(IgnoreExceptions())
        ..connect();

  IInteractions.create(WebsocketInteractionBackend(bot))
    ..registerSlashCommand(singleCommand)
    ..syncOnReady();
}
```
説明はコメントアウトに書いてる通りです。

:::note info
<b>ギルド（Guild）について</b>
Discordサーバーのことを、Discord APIでは「ギルド（Guild）」と呼びます。なんでUIとAPIで名前が違うかというと、これはサーバーが本来のサーバーと紛らわしいからだと推測します。じゃあなんで最初からギルドっていう名前にしなかったんだ……
:::

`dart run`をして動かしてみましょう。
うまく動けばこのような表示が出てきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/196882/36fbe74f-eb79-b91f-6bb7-8cf294ef14b0.png)
コマンドを使用すると、Pong!と返事します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/196882/11a17d0d-8db5-7969-b2f9-15d3fdbbefb0.png)
うまく動きましたか？

## ページネーションを使う（nyxx_pagination）

```dart
import "dart:async";

import "package:nyxx/nyxx.dart";
import 'package:nyxx_interactions/nyxx_interactions.dart';
import "package:nyxx_pagination/nyxx_pagination.dart";

FutureOr<void> paginationExampleInteraction(ISlashCommandInteractionEvent event) {
  final paginator = EmbedComponentPagination(event.interactions, [
    EmbedBuilder()..description = "最初のページ",
    EmbedBuilder()..description = "2番目のページ",
  ]);

  event.respond(paginator.initMessageBuilder());
}

void main() {
  final Map<String, String> envVars = Platform.environment;
  final String? token = envVars["DISCORD_BOT_TOKEN"];
  if (token == null) {
    throw "Token is not difined. Please set DISCORD_BOT_TOKEN.";
  }
  final bot =
      NyxxFactory.createNyxxWebsocket(token, GatewayIntents.allUnprivileged)
        ..registerPlugin(Logging())
        ..registerPlugin(CliIntegration())
        ..registerPlugin(IgnoreExceptions())
        ..connect();

  IInteractions.create(WebsocketInteractionBackend(bot))
    ..registerSlashCommand(SlashCommandBuilder("paginated", "これはページネーションの例です", [], guild: 302360552993456135.toSnowflake())
      ..registerHandler(paginationExampleInteraction))
    ..syncOnReady();
}
```

`dart run`をして動かしてみましょう。
うまく動けばこのような表示が出てきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/196882/8c3ea268-47aa-56b7-51c2-db362b0043bc.png)
コマンドを使用すると、ページネーションが表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/196882/72924cd9-70f6-6c0b-0e57-d08e997770cf.png)
`>`を押すと次のページに移動します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/196882/df9aef4c-8655-374a-1490-782fb3a9ba48.png)
うまく動きましたか？

# 実用編

さて、事始めを終えたら、実用編です。

## よくある機能

Discordボットで使いそうな具体的な機能の例をいくつか紹介します。

### ボットが起動したときにメッセージを送信する

```dart
  // ボットが起動したときにイベントが発火します
  bot.eventsWs.onReady.listen((event) async {
    // ボットから非同期でチャンネルを取得します（302360552993456135は送信先のチャンネルID）
    final channel = await bot.fetchChannel(302360552993456135.toSnowflake());
    // チャンネルの種類がテキストチャンネルであることを確認します
    if (channel.channelType == ChannelType.text) {
      // テキストチャンネルにキャストします
      final textChannel = channel as ITextChannel;
      // テキストチャンネルに「起動しました！」というメッセージを送信します
      await textChannel.sendMessage(MessageBuilder.content("起動しました！"));
    } else {
      // テキストチャンネルでなければ、チャンネルIDを間違えています
      // プログラムを修正する必要があるので、Errorを投げます
      throw Error();
    }
  });
```

`main`関数にこれを追加すれば、ボットが起動したときにメッセージを送信します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/196882/a837265b-6c92-5e25-38ee-b7962d1485a2.png)
こんな感じでお友達も喜びます。

### インタラクションで省略可能な引数を扱う

`/neko`で「にゃーん」と返信し、`/neko text:テキスト`でテキストに含まれるすべての「な」を「にゃ」に置換し、語尾に「にゃ」を付けたメッセージを返信するコマンドを実装します。

```dart
  IInteractions.create(WebsocketInteractionBackend(bot))
    ..registerSlashCommand(SlashCommandBuilder("neko", "「にゃーん」と反応します",
        [CommandOptionBuilder(CommandOptionType.string, "text", "メッセージ")],
        guild: 302360552993456135.toSnowflake())
      ..registerHandler((event) {
        // コマンドに対して応答する
        event.respond(MessageBuilder.content(
            // 引数に"text"という名前の要素があるか確認する
            event.args.any((element) => element.name == "text")
                // "text"がある場合
                ? event // イベントから
                    .getArg("text") // 引数"text"を取得して
                    .value // その引数の値を取得して
                    .toString() // 文字列に変換して
                    .replaceAll("な", "にゃ") // 「な」を「にゃ」に置換して
                    .replaceAll(RegExp(r"$"), "にゃ") // 末尾に「にゃ」を追加
                // "text"がない場合
                : "にゃーん")); // 「にゃーん」と返信する
      }))
    ..syncOnReady();
```

`main`関数にこれを追加すれば、ボットが起動したときにメッセージを送信します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/196882/ef82c67e-8260-d8d7-a618-24c08a12f6ac.png)
上が`/neko`を実行したとき、
下が`/neko text:ななめななじゅうななどのならびでなくなくいななくななはんななだいなんなくながめてながながめ`を実行したときの反応です。

:::note info
<b>インタラクションで省略できない引数を扱うとき</b>
逆にインタラクションで省略できない引数を扱いたいときは<code>CommandOptionBuilder</code>に<code>required: true</code>を追加します。
:::

## イベントの一覧

イベントの一覧を紹介します。

```dart
bot.eventsWs.'ここにイベント名を書きます'.listen((event) {});
```

この表を見れば、何ができるか色々と想像することができるでしょう。

|イベント名|説明|
|:-:|:-:|
|`onDisconnect`|切断されたときに発行されるイベント|
|`onReady`|起動できたときに発行されるイベント|
|`onMessageReceived`|メッセージを受信したときに発行されるイベント|
|`onDmReceived`|プライベートメッセージを受信したときに発行されるイベント|
|`onChannelPinsUpdate`|チャンネルのピンが更新されたときに発行されるイベント|
|`onGuildEmojisUpdate`|サーバーの絵文字が変更されたときに発行されるイベント|
|`onMessageUpdate`|メッセージが更新されたときに発行されるイベント|
|`onMessageDelete`|メッセージが削除されたときに発行されるイベント|
|`onChannelCreate`|チャンネルが作成されたときに発行されるイベント|
|`onChannelUpdate`|チャンネルが更新されたときに発行されるイベント|
|`onChannelDelete`|チャンネルが削除されたときに発行されるイベント|
|`onGuildBanAdd`|メンバーがバンされたときに発行されるイベント|
|`onGuildBanRemove`|バンを取り消したときに発行されるイベント|
|`onGuildCreate`|サーバーにボットが参加したときに発行されるイベント|
|`onGuildUpdate`|サーバーが更新されたときに発行されるイベント|
|`onGuildDelete`|サーバーからボットが去るときに発行されるイベント|
|`onGuildMemberAdd`|メンバーがサーバーに参加したときに発行されるイベント|
|`onGuildMemberUpdate`|メンバーが更新したときに発行されるイベント|
|`onGuildMemberRemove`|ユーザーがサーバーから去るときに発行されるイベント|
|`onPresenceUpdate`||
|`onTyping`|ユーザーが入力を始めたときに発行されるイベント|
|`onRoleCreate`|ロールが作成されたときに発行されるイベント|
|`onRoleUpdate`|ロールが更新されたときに発行されるイベント|
|`onRoleDelete`|ロールが削除されたときに発行されるイベント|
|`onMessageDeleteBulk`||
|`onMessageReactionAdded`|ユーザーがメッセージにリアクションを追加したときに発行されるイベント|
|`onMessageReactionRemove`|ユーザーがメーセージからリアクションを削除したときに発行されるイベント|
|`onMessageReactionsRemoved`|ユーザーがメーセージからすべてのリアクションを削除したときに発行されるイベント|
|`onVoiceStateUpdate`|ボイスチャットの入退室・移動時に発行されるイベント|
|`onVoiceServerUpdate`|ユーザーが更新されたときに発行されるイベント|
|`onUserUpdate`|ユーザーが更新されたときに発行されるイベント|
|`onSelfMention`|ボットがメンションされたときに発行されるイベント|
|`onInviteCreated`|招待リンクが作成されたときに発行されるイベント|
|`onInviteDeleted`|招待リンクが削除されたときに発行されるイベント|
|`onMessageReactionRemoveEmoji`||
|`onThreadCreated`|スレッドが作成されたときに発行されるイベント|
|`onThreadMembersUpdate`|スレッドにメンバーが追加・削除されたときに発行されるイベント|
|`onThreadDelete`|スレッドが削除されたときに発行されるイベント|
|`onStageInstanceCreate`|ステージチャンネルが作成されたときに発行されるイベント|
|`onStageInstanceUpdate`|ステージチャンネルが更新されたときに発行されるイベント|
|`onStageInstanceDelete`|ステージチャンネルが削除されたときに発行されるイベント|
|`onGuildStickersUpdate`||
|`onGuildEventCreate`||
|`onGuildEventUpdate`||
|`onGuildEventDelete`||

空白部分は複雑な説明のため省いているものと、ライブラリのドキュメントが明らかに誤っているため書いていないものがあります。

# 終わりに

Dartは知名度が致命的ですが、良い言語だと思います。
みんな好きな言語でDiscordボットを書くといいですよ。
それではみなさん、いいDiscordボットライフを！