【Discord】DSharpPlus使ってみた

## 要約
Discordボットのための良さげなC#ライブラリを触ってみたよ。
スラッシュコマンドの実装がシンプルでやりやすいね。

## 本文
初めまして、ゲームエンジニアのwrsmAです。Discordとは仲良くさせてもらってます。
突然ですが、私は「[Techxenia](https://disboard.org/ja/server/828522825567961109)」というDiscordサーバーの運営をやっています。  
以前、運営のために簡単なBotを作った時は`Discrod.Net`というライブラリを利用しました。  
今回は`DSharpPlus`というライブラリがあると聞きつけたので使ってみた、という話です。

### DSharpPlusの紹介

DSharpPlusは[DiscordSharp](https://github.com/suicvne/DiscordSharp)からフォークされたプロジェクトです。
DiscordSharpが開発停止したため、C#でボット開発する際に`Discrod.Net`と並ぶ選択肢になりそうです。


## 実装

ここから、以下の流れで実装してみます。

1. 導入
1. ボットを立ち上げる
1. メッセージを受け取る
1. スラッシュコマンドを追加する
1. ボタンを追加する

### 1. 導入

まずボットを作成します。作成経験のある方にはお馴染みの手順です。  
DSharpPlusにも解説記事[【Creating a Bot Account】](https://dsharpplus.github.io/DSharpPlus/articles/basics/bot_account.html)があります。  

テスト用のボットを追加しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2375892/7beaf7da-900d-4781-8ab1-903622e571ed.png)

次にプログラム環境を作成します。  
DSharpPlus公式ドキュメント[【Your First Bot】](https://dsharpplus.github.io/DSharpPlus/articles/basics/first_bot.html)のページ通り進めましょう。  
VisualStudioでなくても大丈夫です。

今回は`5.0.0`のパッケージを使用します。

- [DSharpPlus 5.0.0-nightly-02464 (NuGet)](https://www.nuget.org/packages/DSharpPlus/5.0.0-nightly-02464)


### 2. ボットを立ち上げる

ここからボットを立ち上げていきます。  接続には`DiscordClient`クラスを使用します。
`DiscordClientBuilder`にTokenやIntentのパラメータを渡してclientを取得します。

```cs
// v5.0.0以降
const string Token = "なんからToken";

var builder = DiscordClientBuilder.CreateDefault(Token, DiscordIntents.AllUnprivileged);
var client = builder.Build();
```

なお、最新安定板である`4.5.0`には`DiscordClientBuilder`は存在しません。
`DiscordClient`のコンストラクタを使用しましょう。

```cs
// v4.5.0
var client = new DiscordClient(new DiscordConfiguration
{
    Token = "なんからToken",
    Intents = DiscordIntents.AllUnprivileged
});
```

:::note alert
Tokenの取り扱いには十分注意してください。
誰にでも見える形で公開しないでください。
:::

最後にclientを接続し、コンソールが閉じないようにします。

```cs
await client.ConnectAsync();
await Task.Delay(-1);
```

実行すると以下の通りログが出力され、ボットがオンラインになりました。  

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2375892/732fefdc-a4f7-4ff7-acca-a9950b732111.png)

### 3. メッセージを受け取る

次にメンバーのメッセージを受け取って返事を返してみます。
[Spicing Up Your Bot](https://dsharpplus.github.io/DSharpPlus/articles/basics/first_bot.html#spicing-up-your-bot)の内容通りです。

builderに渡すIntentを追加して、メッセージをハンドルする処理を追加します。

```cs
// DiscordIntents.MessageContentsを追加
var builder = DiscordClientBuilder.CreateDefault(Token, DiscordIntents.AllUnprivileged | DiscordIntents.MessageContents);
```

```cs
builder.ConfigureEventHandlers
(
    b => b.HandleMessageCreated(async (s, e) =>
    {
        if (e.Message.Content.ToLower().StartsWith("ping"))
        {
            await e.Message.RespondAsync("pong!");
        }
    })
);
```

実行、お返事が返ってきました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2375892/09555f34-f610-4ccf-ba07-89ab523a3993.png)

### 4. スラッシュコマンドを追加する

スラッシュコマンドも追加しましょう。
従来のプレフィックスコマンド（?や!などから始まるテキストベースのコマンド）も利用可能ですが、スラッシュコマンドを使いましょう（圧）。

:::note info
スラッシュコマンドの使用には、ボットに`Use Slash Commands`権限が付与されている必要があります。開発者ポータルで設定を確認してください。
:::

スラッシュコマンドを実装するためにパッケージを追加します。

- [DSharpPlus.Commands 5.0.0-nightly-02464 (NuGet)](https://www.nuget.org/packages/DSharpPlus.Commands/5.0.0-nightly-02464)

`DSharpPlus.SlashCommands`というパッケージもありますが、`5.1.0`で非推奨となるようです。

[DSharpPlus.SlashCommands](https://dsharpplus.github.io/DSharpPlus/articles/slash_commands.html)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2375892/6104373f-3396-4fec-8e90-d007c975feb8.png)

基本的な実装は公式の[Commands Introduction](https://dsharpplus.github.io/DSharpPlus/articles/commands/introduction.html?tabs=main-method)を参照します。
コマンドの設定にはbuilderの`UseCommands`関数を使用します。

```cs
builder.UseCommands((IServiceProvider serviceProvider, CommandsExtension extension) =>
{
    // ここでコマンドの登録などを行う
});
```
ドキュメントで使用されている`TextCommandProcessor`クラスは、プレフィックスコマンドを登録するためのクラスになります。
スラッシュコマンドを登録するために、`SlashCommandProcessor`クラスを使用します。

```diff_c_sharp
builder.UseCommands((IServiceProvider serviceProvider, CommandsExtension extension) =>
{
    // ここでコマンドの登録などを行う

+   var slashCommandProcessor = new SlashCommandProcessor(new SlashCommandConfiguration());
+   extension.AddProcessor(slashCommandProcessor);
});
```

次にコマンド本体を実装します。ドキュメントに倣ってpingコマンドにしましょう。
コマンドには`Command`アトリビュートを付けます。
`context.Client.Ping`は無くなっているのでとりあえず「Pong!」とだけ返します。

```cs
public class PingCommand
{
    [Command("ping")]
    public async Task Run(CommandContext context)
    {
        await context.RespondAsync("Pong!");
    }
}
```

最後にPingCommandを登録します。

```diff_c_sharp
builder.UseCommands((IServiceProvider serviceProvider, CommandsExtension extension) =>
{
    // ここでコマンドの登録などを行う
+   extension.AddCommands([typeof(PingCommand)]);    

    var slashCommandProcessor = new SlashCommandProcessor(new SlashCommandConfiguration());
    extension.AddProcessor(slashCommandProcessor);
});
```

実行、コマンドが呼び出せました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2375892/4553e09c-3de8-48a8-83ed-106ee67397ba.png)

オマケでコマンドにパラメータを持たせてみましょう。
パラメータを追加するには、コマンド関数に対応する型の引数を追加するだけです。

```cs
[Command("ping")]
public async Task Execute(CommandContext context, string name)
{
    await context.RespondAsync($"Pong for {name}!");
}
```

実行、`name`パラメータが追加されコマンドで利用できました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2375892/01e64a4c-9710-42e8-9c18-a607fd499cd9.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2375892/2e4e3616-577e-466d-919d-1ea741b5ed8d.png)

<details><summary>【余談】コマンドのタイムアウトについて</summary>

スラッシュコマンドなどは実行後**3**秒以内になんらかの返答を返す必要があります。
そのため、長いタスクを実行する場合は`Defer`と`Followup`を使用します。

```cs
[Command("ping")]
public async Task Run(CommandContext context)
{
    await ctx.DeferResponseAsync();
    await Task.Delay(1000 * 10);
    await ctx.FollowupAsync("10秒待ってPong!");
}
```

`DeferRespondeAsync`で待機中
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2375892/9d693650-5e0f-49f6-952c-bd50723cce28.png)

`FollowupAsync`で完了
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2375892/4be02897-5df6-4b42-8495-ac075920796f.png)

</details>

### 5. ボタンを追加する

少し踏み込んで、ボタンも追加してみます。
ボタンとはメッセージに埋め込めるコンポーネントの1種で、ユーザーが押すことでさらにイベントを実行できるものです。
投票機能などに利用されていたりします。

では、新しいコマンドを作って実装してみましょう。
公式ドキュメントは[Buttons](https://dsharpplus.github.io/DSharpPlus/articles/advanced_topics/buttons.html)です。

ボタンの作成には`DiscordButtonComponent`クラスを使用します。
`DiscordButtonStyle`の種類などは公式ドキュメントに記載されています。
その後、`DiscordMessageBuilder`クラスを使ってメッセージにまとめ、Respondします。

```cs
public class ButtomCommand
{
    [Command("button")]
    public async Task Execute(CommandContext context)
    {
        var button = new DiscordButtonComponent(DiscordButtonStyle.Primary, "customButtonId", "ぼたん");
        var builder = new DiscordMessageBuilder();
        builder.AddComponents(button);
        await context.RespondAsync(builder);
    }
}
```

:::note warn

`ButtonCommand`を`extension`に追加するのを忘れないように気をつけてください。
```cs
extension.AddCommands([typeof(PingCommand), typeof(ButtomCommand)]);
```

:::

実行、ボタンが作られました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2375892/313320e5-3a54-4ef7-9504-5286187b27ce.png)

最後に、ボタンが押された時にメッセージを送信してみます。ボタンは`Id`で識別することができます。
`InteractionResponseType`は`DiscordInteractionResponseType`に名前が変わっているので注意しましょう。

```cs
builder.ConfigureEventHandlers(b => b.HandleComponentInteractionCreated(async (s, e) =>
{
    await e.Interaction.CreateResponseAsync(DiscordInteractionResponseType.DeferredMessageUpdate);
    if (e.Id == "customButtonId")
    {
        await e.Interaction.CreateFollowupMessageAsync(new DiscordFollowupMessageBuilder().WithContent("ボタン押したね！"));
    }
}));
```

実行、ボタンが押されたことが分かりました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2375892/3a45a84f-5e34-4711-b88b-d24b32365434.png)

## 今回のコード全文

<details><summary>コード</summary>

```cs
using DSharpPlus;
using DSharpPlus.Commands;
using DSharpPlus.Commands.Processors.SlashCommands;
using DSharpPlus.Commands.Trees;

public class Program
{
    static async Task Main(string[] args)
    {
        // 適宜、Environmentなどに逃がしてください。
        const string Token = "なんらかToken";

        var builder = DiscordClientBuilder.CreateDefault(Token, DiscordIntents.AllUnprivileged | DiscordIntents.MessageContents);

        // 3. メッセージを受け取る
        builder.ConfigureEventHandlers
        (
            b => b.HandleMessageCreated(async (s, e) =>
            {
                if (e.Message.Content.ToLower().StartsWith("ping"))
                {
                    await e.Message.RespondAsync("pong!");
                }
            })
        );

        // 4. スラッシュコマンドを追加する
        builder.UseCommands((IServiceProvider _, CommandsExtension extension) =>
        {
            extension.AddCommands([typeof(PingCommand)]);

            var slashCommandProcessor = new SlashCommandProcessor(new SlashCommandConfiguration());
            extension.AddProcessor(slashCommandProcessor);
        });
        
        // 5. ボタンを追加する
        builder.ConfigureEventHandlers(b => b.HandleComponentInteractionCreated(async (s, e) =>
        {
            await e.Interaction.CreateResponseAsync(DiscordInteractionResponseType.DeferredMessageUpdate);
            if (e.Id == "customButtonId")
            {
                await e.Interaction.CreateFollowupMessageAsync(new DiscordFollowupMessageBuilder().WithContent("ボタン押したね！"));
            }
        }));

        var client = builder.Build();

        await client.ConnectAsync();
        await Task.Delay(-1);
    }
}

public class PingCommand
{
    [Command("ping")]
    public async Task Execute(CommandContext context, string name)
    {
        await context.RespondAsync($"Pong for {name}!");
    }
}

public class ButtomCommand
{
    [Command("button")]
    public async Task Execute(CommandContext context)
    {
        var button = new DiscordButtonComponent(DiscordButtonStyle.Primary, "customButtonId", "ぼたん");
        var builder = new DiscordMessageBuilder();
        builder.AddComponents(button);
        await context.RespondAsync(builder);
    }
}

```
</details>

## あとがき

今回は`Discord.Net`と比較する意味も込めて基本的な機能を実装してみました。
特にスラッシュコマンドの実装がシンプルで分かりやすいと思いました。
現時点では`5.0.0`はnightlyのため、安定板となる頃に改めて触ってみたいと思います。