discord botでメッセージを転送する

# 初めに

この記事は，discord botでメッセージを転送しようという目的の記事です．また，[discord botでminecraft serverを操作したいアドベントカレンダーの記事](https://qiita.com/advent-calendar/2022/itohal)ですが，~~ネタ切れで~~趣旨とは若干それています

また，
- WSL, Ubuntu22.04.1
- Python 3.10.6
- discord.py             2.1.0

という環境にて実施しています．

# やりたいこと
誰かのメッセージをどこかのチャンネルへと転送したいです．また，そのときに誰がそのメッセージを転送したかを表示させたいです．

# 作る
転送に利用するコマンドは，特定のメッセージに対してコマンドを利用するため[context_menu](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#contextmenu)を利用します．

```python: python
import os
from discord import (
    Interaction,
    Intents,
    Client,
    Message,
)
from discord.app_commands import CommandTree
import pprint


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

    async def setup_hook(self) -> None:
        await super().setup_hook()
        synced_commands = await self.tree.sync()
        pprint.pprint(synced_commands)

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


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


@client.tree.context_menu()
async def message_forward(interaction: Interaction, message: Message):
    await interaction.response.send_message(
        f"this message is: {message.content}", ephemeral=True
    )


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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/6028a451-60f8-f275-dfc8-1e153ee6040e.png)

転送というメッセージに対してコマンドを実行して，中身を送信してもらうようにしました．


## メッセージの埋め込み
次に誰がどんなことを言ったのかを表示させる必要があります．その表示には[`Embed`というクラス](https://discordpy.readthedocs.io/ja/latest/api.html#discord.Embed)を使います．

Embedにはtitleやfooter，authorやtimestampなどを設定できます．ただしtitleは初期化時のみ設定可能です．
今回は転送したいメッセージにおいて，その送信者，内容，時間，および転送する人を埋め込みます．titleは特に必要ないと思ったので入れていません．

画像だけ貼り付けた場合などはメッセージのcontentがありませんので確認しています．[^embed]そしてメッセージの送信時刻を`timestamp`に，メッセージの送信者の名前とアイコンの画像を`set_author`で，誰が転送しようとしているのかを`set_footer`で設定しています．


[^embed]: 画像も転送したい場合は，[メッセージの`embeds`](https://discordpy.readthedocs.io/ja/latest/api.html#discord.Message.embeds)からEmbedを複数取得します．その後取得したものと自身で作成した埋め込みをlistとして，送信の際に`emveds`に指定するとできると思います．[message_content](https://discordpy.readthedocs.io/ja/latest/api.html#discord.Intents.message_content)の権限が必要なので注意してください．

あとはそれを送信時に指定してあげます．


```diff_python: python
import os
from discord import (
    Interaction,
    Intents,
    Client,
    Message,
+   Embed,
)
from discord.app_commands import CommandTree
import pprint


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

    async def setup_hook(self) -> None:
        await super().setup_hook()
        synced_commands = await self.tree.sync()
        pprint.pprint(synced_commands)

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


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


@client.tree.context_menu()
async def message_forward(interaction: Interaction, message: Message):
-   await interaction.response.send_message(
-       f"this message is: {message.content}", ephemeral=True
-   )

+   embed = discord.Embed(title=None)
+   if message.content:
+       embed.description = message.content
+   embed.timestamp = message.created_at
+
+   embed.set_author(
+       name=message.author.display_name, 
+       icon_url=message.author.display_avatar.url,
+   ).set_footer(
+       text=f"forwarded from {interaction.user.display_name}",
+       icon_url=interaction.user.display_avatar.url,
+   )
+
+   await interaction.response.send_message("embed", embed=embed, ephemeral=True)


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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/242d14c1-8e05-7cce-e31a-aa574a7365ef.png)

無事に埋め込めました．


## メッセージの場所に移動

今度はメッセージの場所まで移動します．つまり，どんな文脈からそのメッセージが出てきたのかも見れる感じです．
ボタンを使って飛べるようにしましょう．つまりViewとButtonです．

```diff_python: python
import os
from discord import (
    Interaction,
    Intents,
    Client,
    Message,
    Embed,
+   ui,
+   ButtonStyle
)
from discord.app_commands import CommandTree
import pprint


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

    async def setup_hook(self) -> None:
        await super().setup_hook()
        synced_commands = await self.tree.sync()
        pprint.pprint(synced_commands)

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


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


@client.tree.context_menu()
async def message_forward(interaction: Interaction, message: Message):
    embed = Embed(title=None)
    if message.content:
        embed.description = message.content
    embed.timestamp = message.created_at

    embed.set_author(
        name=message.author.display_name, icon_url=message.author.display_avatar.url
    ).set_footer(
        text=f"forwarded from {interaction.user.display_name}",
        icon_url=interaction.user.display_avatar.url,
    )

+   url_view = ui.View()
+   url_view.add_item(
+       ui.Button(
+           label="Go to Message", style=ButtonStyle.url, url=message.jump_url
+       )
+   )

-   await interaction.response.send_message("embed", embed=embed, ephemeral=True)
+   await interaction.response.send_message("embed", embed=embed, view=url_view)


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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/0bd142bf-c11d-c61b-d133-5b70b6164311.png)

使ってみるとわかりますが，ちゃんとそのメッセージの場所に移動してくれます．

## チャンネルの選択

続いてどのチャンネルに送信するかのドロップダウンメニューを作ります．これにはViewのサブクラスで[`discord.ui.select`デコレータ](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#discord.ui.select)を利用します．これの`cls`に[`ui.ChannelSelect`のクラス](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#channelselect)を指定することでチャンネルのドロップダウンメニューを作ってくれます．しかし，そのままだとチャンネルのカテゴリやボイスチャンネルも含まれるので`channel_types=[ChannelType.text]`としてテキストチャンネルだけ表示させるようにしています．

```diff_python: pyhon
import os
from discord import (
    Interaction,
    Intents,
    Client,
    Message,
    Embed,
    ui,
    ButtonStyle,
+   ChannelType,
)
from discord.app_commands import CommandTree
import pprint


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

    async def setup_hook(self) -> None:
        await super().setup_hook()
        synced_commands = await self.tree.sync()
        pprint.pprint(synced_commands)

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


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


+ class SendChannelView(ui.View):
+     @ui.select(
+         cls=ui.ChannelSelect,
+         placeholder="select channel",
+         channel_types=[ChannelType.text],
+     )
+     async def set_channel(self, interaction: Interaction, select: ui.ChannelSelect):
+         await interaction.response.send_message(f"you select {select.values[0]}")



@client.tree.context_menu()
async def message_forward(interaction: Interaction, message: Message):
    embed = Embed(title=None)
    if message.content:
        embed.description = message.content
    embed.timestamp = message.created_at

    embed.set_author(
        name=message.author.display_name, icon_url=message.author.display_avatar.url
    ).set_footer(
        text=f"forwarded from {interaction.user.display_name}",
        icon_url=interaction.user.display_avatar.url,
    )

    url_view = ui.View()
    url_view.add_item(
        ui.Button(label="Go to Message", style=ButtonStyle.url, url=message.jump_url)
    )

+   send_channel_view = SendChannelView()

    await interaction.response.send_message("embed", embed=embed, view=url_view)
+   await interaction.followup.send(view=send_channel_view)


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

```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/01e8925e-378c-70f8-b22e-d94aab44fba0.png)

## ボタンを押して送信

続いて，ボタンを押したときに選択されたチャンネルに送信するように変更します．先ほど作った`SendChannelView`にボタンを追加する[buttonデコレータ](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#discord.ui.button)を追加します．また，埋め込みはこのときに送信したいので`SendChannelView`の引数に渡しておきます．

```diff_python: pyhon
import os
from discord import (
    Interaction,
    Intents,
    Client,
    Message,
    Embed,
    ui,
    ButtonStyle,
    ChannelType,
)
from discord.app_commands import CommandTree
import pprint


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

    async def setup_hook(self) -> None:
        await super().setup_hook()
        synced_commands = await self.tree.sync()
        pprint.pprint(synced_commands)

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


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


class SendChannelView(ui.View):
+   def __init__(self, embed: Embed, url_view: ui.View):
+       super().__init__()
+       self.send_embed = embed
+       self.send_url_view = url_view

    @ui.select(
        cls=ui.ChannelSelect,
        placeholder="select channel",
        channel_types=[ChannelType.text],
    )
    async def set_channel(self, interaction: Interaction, select: ui.ChannelSelect):
        await interaction.response.send_message(f"you select {select.values[0]}")


+   @ui.button(label="Send")
+   async def send_button(self, interaction: Interaction, button: ui.Button):
+       await interaction.response.send_message("send!")
+       await self.fav_channel.send(embed=self.send_embed, view=self.send_url_view)


@client.tree.context_menu()
async def message_forward(interaction: Interaction, message: Message):
    embed = Embed(title=None)
    if message.content:
        embed.description = message.content
    embed.timestamp = message.created_at

    embed.set_author(
        name=message.author.display_name, icon_url=message.author.display_avatar.url
    ).set_footer(
        text=f"forwarded from {interaction.user.display_name}",
        icon_url=interaction.user.display_avatar.url,
    )

    url_view = ui.View()
    url_view.add_item(
        ui.Button(label="Go to Message", style=ButtonStyle.url, url=message.jump_url)
    )

-   send_channel_view = SendChannelView()
+   send_channel_view = SendChannelView(embed=embed, url_view=url_view)

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


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/6c27e097-f96d-ed48-70ac-fdc2bc66caa0.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/e5693138-c67b-794b-384a-25d2bdaa4a1d.png)

転送されましたね．


## 細かいところ

転送自体は現段階でできています．しかし問題があります．
- チャンネルが指定されていないときにボタンを押すとエラーが発生する
- 何回でも転送できる
- 邪魔

というわけでこれらを片付けます．

### チャンネルの指定を必須化，および一度のみの転送
チャンネルの指定を必須にするためには，そもそも送信できなくしてしまえばいいです．Buttonには[`disabled`というプロパティ](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#discord.ui.Button.disabled)があります．このプロパティを最初はTrueにして，一度でも選択されたらFalseに変更させます．このとき，見た目を変えると押せるようになった感が出ると思いますので見た目の変更もします．

また，転送したらButtonやSelectを使えなくしましょう．Viewには[`children`というプロパティ](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#discord.ui.View.children)がありますので，Buttonが押されたときの処理でfor文を回してdisabledにしましょう．ただし，childreの返り値は[Item](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#discord.ui.Item)のリストです．これはButtonやSelectなどのスーパークラスですが，disabledというプロパティを持っていません．そのため，vscodeなどを使って入力補完が効かないです．まあButtonやSelectは持っていますから，一応大丈夫です．[^textinput]

[^textinput]: TextInputはdisabledを持っていませんが，なんかViewでは使えなかったです．Modalだと使えるんですけどね．要検証


```diff_python: python
import os
from discord import (
    Interaction,
    Intents,
    Client,
    Message,
    Embed,
    ui,
    ButtonStyle,
    ChannelType,
)
from discord.app_commands import CommandTree
import pprint


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

    async def setup_hook(self) -> None:
        await super().setup_hook()
        synced_commands = await self.tree.sync()
        pprint.pprint(synced_commands)

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


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


class SendChannelView(ui.View):
    def __init__(self, embed: Embed, url_view: ui.View):
        super().__init__()
        self.send_embed = embed
        self.send_url_view = url_view

    @ui.select(
        cls=ui.ChannelSelect,
        placeholder="select channel",
        channel_types=[ChannelType.text],
    )
    async def set_channel(self, interaction: Interaction, select: ui.ChannelSelect):
-       await interaction.response.send_message(f"you select {select.values[0]}")

+       self.send_button.disabled = False
+       self.send_button.style = ButtonStyle.green
+
+       await interaction.response.edit_message(view=self)

        self.fav_channel = await select.values[0].fetch()

    @ui.button(label="Send", disabled=True, style=ButtonStyle.gray)
    async def send_button(self, interaction: Interaction, button: ui.Button):
-       await interaction.response.send_message("send!")

+       for item in self.children:
+           item.disabled = True
+       self.send_button.style = ButtonStyle.gray
+       await interaction.response.edit_message(view=self)

        await self.fav_channel.send(embed=self.send_embed, view=self.send_url_view)


@client.tree.context_menu()
async def message_forward(interaction: Interaction, message: Message):
    embed = Embed(title=None)
    if message.content:
        embed.description = message.content
    embed.timestamp = message.created_at

    embed.set_author(
        name=message.author.display_name, icon_url=message.author.display_avatar.url
    ).set_footer(
        text=f"forwarded from {interaction.user.display_name}",
        icon_url=interaction.user.display_avatar.url,
    )

    url_view = ui.View()
    url_view.add_item(
        ui.Button(label="Go to Message", style=ButtonStyle.url, url=message.jump_url)
    )

    send_channel_view = SendChannelView(embed=embed, url_view=url_view)

    await interaction.response.send_message("where?", view=send_channel_view)


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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/50c0208a-1634-aa5a-8e0d-efe89ae7576f.png)


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/7de549dd-06fa-94db-7075-90cd4a9de5e1.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/a6bf4b5e-7b5f-04e7-5ce4-085d20a9c6a7.png)

こんな感じでボタンの色が変わったり，使えなくなったりしました．使えなくなる話の詳しいことは，[書いた記事で触れている](https://qiita.com/halglobe0108/items/190c4fd19db280c30e06#disable)ので見ていただけるとありがたいです．

### 邪魔
さて，次の問題です．
履歴に残るのが邪魔です．残す理由もないですし消しましょう．また，そのままメッセージを送信すると通知も来るので自分だけにしか見えなくしましょう．

削除するメッセージはチャンネルの選択などをするメッセージですから，ボタンを押したときのインタラクションにあります．このインタラクションに対して[`delete_original_response()`メソッド](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#discord.Interaction.delete_original_response)を利用します．

```diff_python: python
import os
from discord import (
    Interaction,
    Intents,
    Client,
    Message,
    Embed,
    ui,
    ButtonStyle,
    ChannelType,
)
from discord.app_commands import CommandTree
import pprint


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

    async def setup_hook(self) -> None:
        await super().setup_hook()
        synced_commands = await self.tree.sync()
        pprint.pprint(synced_commands)

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


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


class SendChannelView(ui.View):
    def __init__(self, embed: Embed, url_view: ui.View):
        super().__init__()
        self.send_embed = embed
        self.send_url_view = url_view

    @ui.select(
        cls=ui.ChannelSelect,
        placeholder="select channel",
        channel_types=[ChannelType.text],
    )
    async def set_channel(self, interaction: Interaction, select: ui.ChannelSelect):
        self.send_button.disabled = False
        self.send_button.style = ButtonStyle.green

        await interaction.response.edit_message(view=self)

        self.fav_channel = await select.values[0].fetch()

    @ui.button(label="Send", disabled=True, style=ButtonStyle.gray)
    async def send_button(self, interaction: Interaction, button: ui.Button):
        for item in self.children:
            item.disabled = True
        self.send_button.style = ButtonStyle.gray
        await interaction.response.edit_message(view=self)

        await self.fav_channel.send(embed=self.send_embed, view=self.send_url_view)
+       await interaction.delete_original_response()


@client.tree.context_menu()
async def message_forward(interaction: Interaction, message: Message):
    embed = Embed(title=None)
    if message.content:
        embed.description = message.content
    embed.timestamp = message.created_at

    embed.set_author(
        name=message.author.display_name, icon_url=message.author.display_avatar.url
    ).set_footer(
        text=f"forwarded from {interaction.user.display_name}",
        icon_url=interaction.user.display_avatar.url,
    )

    url_view = ui.View()
    url_view.add_item(
        ui.Button(label="Go to Message", style=ButtonStyle.url, url=message.jump_url)
    )

    send_channel_view = SendChannelView(embed=embed, url_view=url_view)

-   await interaction.response.send_message("where?", view=send_channel_view)
+   await interaction.response.send_message(
+       "where?", view=send_channel_view, ephemeral=True
+   )


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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/b25c1b1f-6ab5-051e-7dfc-77bf57d1bae8.png)

自分だけ見えているやつですね．送信した後は消えちゃうので載せられません~~し，gifを載せるにしてもgifにするとき毎回謎画質になる[^git]~~ので，ご自身で試してみてください．


[^git]: 多分軽量化のためにいろいろしてくれてるんでしょうけど，そのせいで変になります．いい感じの動画の載せ方ありますかね．．．


以上となります．discord botでメッセージを転送することができました．
お疲れ様でした．