discord.pyのslash commandでhelp表示

# 前提
- WSL, Ubuntu22.04.1
- Python 3.10.6
- discord.py             2.1.0

という環境で行っています．

# やりたいこと
全てのコマンドの名前とその説明を表示してくれるコマンドを作ります．そのbotで使えるコマンドが何かを全体的に表示してほしいってやつですね．

利用するプログラムは以下に載せておきます．

<details><summary>プログラム</summary>

```python: python
import os
import pprint
import discord
from discord import Interaction, app_commands
from discord.app_commands import CommandTree, Group


class MyGroup(Group):
    def __init__(self):
        super().__init__(name="group")

    @app_commands.command(description="subcommand description")
    async def subcommand(self, interaction: Interaction):
        await interaction.response.send_message("this is group subcommand")

    @app_commands.command(description="another command description")
    async def another_command(self, interaction: Interaction):
        await interaction.response.send_message("this is group another command")


class MyClient(discord.Client):
    def __init__(self, *, intents: discord.Intents):
        super().__init__(intents=intents)
        self.tree = CommandTree(self)

    async def setup_hook(self) -> None:
        self.tree.add_command(MyGroup())
        commands = await self.tree.sync()
        pprint.pprint(commands)

    async def on_ready(self):
        print(f"Logged in as {client.user} (ID: {client.user.id})")
        print("------")


intents = discord.Intents.default()
client = MyClient(intents=intents)
client.run(os.getenv("TOKEN"))
```

</details>


# コマンドの取得

コマンドの名前とその説明が欲しいので，まずは全てのコマンドを取得します．そのために，`CommandTree`の[`get_commands`メソッド](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#discord.app_commands.CommandTree.get_commands)か[`walk_commands`メソッド](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#discord.app_commands.CommandTree.walk_commands)を使います．


## get_commands

`get_commands`メソッドはCommandTreeに登録されている，ContextMenu, Command, Groupを全てリストとして取得します．ContextMenu, Commandについてはそのままコマンドの名前とその説明を表示すればいいです．しかし，Groupはグループでしかないので，そのグループに属するコマンドを個別に取得する必要があります．Group内のコマンドを取得するには[Groupの`walk_commands`メソッド](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#discord.app_commands.Group.walk_commands)を利用します．CommandTreeの方と一緒なのでそちらも説明します．


## walk_commands

`walk_commands`メソッドは，CommandTreeやGroup内で登録されているコマンドを全て取得する[イテレータ](https://docs.python.org/ja/3/glossary.html#term-iterator)です．深さ優先探索のようにコマンドを取得する感じです．


# 実装

さて，コマンドを取得する方法はわかったので，helpコマンドを実装していきましょう．
`get_commands`と`walk_commands`の方を使ったhelpの表示を実装していきます．説明しやすいので`walk_commands`の方から説明していきます．


## walk_commands

コマンドの名前は`name`，説明は`description`で取得できます．ここで`description`は何も設定していない場合`...`が入りますが，これはピリオド3つではなく一文字のものです．[Unicodeでいうと`U+20E8`らしい](https://tools.m-bsys.com/ex/unicode_table.php)です．初期状態の場合，何も表示しないようにしています．


プログラムは以下の通りです．
```diff_python: python
import os
import pprint
import discord
from discord import Interaction, app_commands
from discord.app_commands import CommandTree, Group


class MyGroup(Group):
    def __init__(self):
        super().__init__(name="group")

    @app_commands.command(description="subcommand description")
    async def subcommand(self, interaction: Interaction):
        await interaction.response.send_message("this is group subcommand")

    @app_commands.command(description="another command description")
    async def another_command(self, interaction: Interaction):
        await interaction.response.send_message("this is group another command")


class MyClient(discord.Client):
    def __init__(self, *, intents: discord.Intents):
        super().__init__(intents=intents)
        self.tree = CommandTree(self)

    async def setup_hook(self) -> None:
        self.tree.add_command(MyGroup())
        commands = await self.tree.sync()
        pprint.pprint(commands)

    async def on_ready(self):
        print(f"Logged in as {client.user} (ID: {client.user.id})")
        print("------")


intents = discord.Intents.default()
client = MyClient(intents=intents)


+ @client.tree.command(description="help: use walk_commands")
+ async def help_walk(interaction: Interaction):
+     help_text = ""
+     for cmd in client.tree.walk_commands():
+         help_text += f"{cmd.name}\n"
+         if cmd.description != "…":
+             help_text += f"\t{cmd.description}\n"
+     await interaction.response.send_message(help_text)


client.run(os.getenv("TOKEN"))
```

実行結果は以下の通りです．

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/77d70a2c-f904-3997-f0d3-afaf09c9c8f3.png)

コマンド名とその名前が表示されましたね．ただし，グループのコマンドかそうでないかの区別が難しいです．Groupには[`parent`](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#discord.app_commands.Group.parent)という，親のGroupを判定するプロパティがあります．そのため，コマンドがGroupであるとき，その後のコマンドのparentがそのGroupであるかを判定することで，Groupのコマンドであるかどうかが判別できます．しかしそれをやるには労力がかかると思います．GroupはGroupで全部のコマンドを出力してくれれば楽ですが，walk_commandsではそれを考えず全てを取得します．そこでget_commandsの方を使います．

## get_commands
get_commandsは先ほども記述した通り，ContextMenu, Command, Groupだけを取得します．つまりGroupだけ別で，Group内のコマンドを出力する，といった具合に分割できます．こうすることでGroup内のコマンドであることをわかりやすくすることができます．
また，Group内のコマンド表示，およびCommandTree事態に登録されているコマンドの表示はおおむね一致しているため，関数を作成してみました．両者の違いとして，Group内のコマンドはより一層ネストが深いので，コマンドの表示のテキストを返す関数において，引数に文字列の先頭に文字を指定できるようにし，そこにタブ文字を入れました．ここでは，Group内にさらにGroupはないという想定ですし，そもそもwalk_commandsしか使えないのでさらなるネストにするのは面倒です．そのため，このような関数に，コマンドの名前と詳細を表示するようにしました．

:::note warn

context menuにはdescriptionがありませんので注意してください

:::

```diff_python: python
import os
import pprint
import discord
from discord import Interaction, app_commands
from discord.app_commands import CommandTree, Group, Command


class MyGroup(Group):
    def __init__(self):
        super().__init__(name="group")

    @app_commands.command(description="subcommand description")
    async def subcommand(self, interaction: Interaction):
        await interaction.response.send_message("this is group subcommand")

    @app_commands.command(description="another command description")
    async def another_command(self, interaction: Interaction):
        await interaction.response.send_message("this is group another command")


class MyClient(discord.Client):
    def __init__(self, *, intents: discord.Intents):
        super().__init__(intents=intents)
        self.tree = CommandTree(self)

    async def setup_hook(self) -> None:
        self.tree.add_command(MyGroup())
        commands = await self.tree.sync()
        pprint.pprint(commands)

    async def on_ready(self):
        print(f"Logged in as {client.user} (ID: {client.user.id})")
        print("------")


intents = discord.Intents.default()
client = MyClient(intents=intents)


@client.tree.command(description="help: use walk_commands")
async def help_walk(interaction: Interaction):
    help_text = ""
    for cmd in client.tree.walk_commands():
        help_text += f"{cmd.name}\n"
        if cmd.description != "…":
            help_text += f"\t{cmd.description}\n"
    await interaction.response.send_message(help_text)


+ @client.tree.command(description="help: use get_commnads")
+ async def help_getcmd(interaction: Interaction):
+     def get_group_command_help_text(group: Group):
+         help_text = get_command_help_text(group)
+         for cmd in group.walk_commands():
+             help_text += get_command_help_text(cmd, head="\t")
+         return help_text
+ 
+     def get_command_help_text(cmd: Group | Command, head=""):
+         help_text = f"{head}{cmd.name}\n"
+         if cmd.description != "…":
+             help_text += f"{head}\t{cmd.description}\n"
+         return help_text
+ 
+     help_text = ""
+     commands = client.tree.get_commands()
+     for cmd in commands:
+         if isinstance(cmd, Group):
+             help_text += get_group_command_help_text(cmd)
+         else:
+             help_text += get_command_help_text(cmd)
+ 
+     await interaction.response.send_message(help_text)


client.run(os.getenv("TOKEN"))
```


実行してみると，

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/b6968857-428e-9301-8e7b-6ef1608f7df4.png)

このようになりました．Groupないでさらにネストしているので，見やすくなった，と思います．


以上となります．discord.pyのslash commandでコマンドhelpの表示を見ていきました．
お疲れ様でした．