Python + uv + discord.py + Renderで暇bot作ってみた(デプロイまで実施)

# はじめに
タイトルにもあるようにPython + discord.pyでDiscord botを作成して、Renderでデプロイまで実施してみました！！


:::note warn
本記事ではDiscord側のトークン取得の設定やbotの招待方法までは説明していません。
当該部分の説明は以下のサイトが分かりやすかったです。
:::

https://www.nocodeai.jp/post/how-to-get-a-token-for-discord-bot-application

# 暇botとは
暇かどうかを定期的するメッセージを特定チャンネルに投下し、ユーザにボタンを押してもらうことで今日、暇なのかどうかを自己申告してもらうようなbotです。


# 開発経緯
私はよく弟とPCゲームを一緒にするのですが、以下のような事がよくありました。

- 弟がDiscordでオンラインになっていることを確認
- 弟にDMでお誘いメッセージを送信する
- 弟「ほかのサーバーで遊んでるわ！！ごめんな笑笑」
- 私 (´・ω・`)

誘ったのに断られ続けるとメンタル的に悲しくなってくるため、メンタルを保つためにbotを作成しました。

Discordの仕様上、botは参加しているサーバーの情報しか参照できないため、私のサーバーでbotを動かして弟に自己申告してもらうことにします。

# 主要機能

主要機能としては以下があります。

- 定期メッセージ投稿機能
    - !himaコマンドでも呼び出せる
- 暇なことを私含む特定メンバーに通知する機能(暇ボタン押下時にメッセージ投稿する機能)
- 暇じゃないことを私含む特定メンバーに通知する機能(暇じゃないボタン押下時にメッセージ投稿する機能)
- ゲーム募集機能(募集メッセージをチャンネル参加者全員に通知する機能)


それぞれ説明していきます。

## 定期メッセージ投稿機能

設定した時間にメッセージ投稿する機能です。キャプチャは開発時のもので19時に投稿されるように設定しています

![スクリーンショット 2025-05-18 153816.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3118286/09d4659d-517b-4050-a1a7-8dbdf65d7e37.png)

また、!himaコマンドでも当該メッセージが投稿されるようにしています。

![スクリーンショット 2025-05-18 154141.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3118286/09b94ff6-ca7c-4c75-af5e-328e661e2e1d.png)

## 暇通知/暇じゃない通知機能

暇な(暇ボタンを押下した)場合は、暇なことを私含むメンバーにメンションが飛びます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3118286/5762cebd-9131-4d59-83ac-230d2957ac07.png)


暇じゃない(暇じゃないボタンを押下した)場合は、忙しいことを私含むメンバーにメンションが飛びます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3118286/d5f196a9-2be9-49d6-894c-2680ad797325.png)

## ゲーム募集機能

ゲームしてくれる人を募集したい(ゲームしてくれる人を募集するボタンを押下した)場合は、ゲームタイトル・ゲームしたい日・開始時間を入力してもらい、全体メンションでメンバー募集をできます。

**ゲームタイトル選択画面**

![スクリーンショット 2025-05-18 160606.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3118286/4258a046-825d-4475-812c-559313cfc6be.png)


**ゲームしたい日の選択画面**

![スクリーンショット 2025-05-18 160622.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3118286/d78acbc9-c58e-4726-b134-9599c58c929e.png)

**開始時間の選択画面**

![スクリーンショット 2025-05-18 160633.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3118286/60c00e75-01b0-412d-a8dd-85f638ab45b3.png)

**募集メッセージ**

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3118286/5d6a1632-c4cb-43ae-ad94-2a9c4e0d679e.png)


# 技術スタック

ここからちょっと技術的な話になります。hima-botは主に、以下の技術を利用して作成しました。

**OS**
Windows

**言語**
Python3.10

**パッケージ管理**
- uv

**コードフォーマッター**
- black

**ソース管理**
- github


# 実装解説
コードを全量記載すると、記事が長くなりすぎてしまうのでかいつまんで解説します。
以下、大枠での概要にります。

```:python
class MainView(View):
    """
    定時実行・!himaコマンドで最初に表示されるビュー
    """

    async def solo_callback(self, interaction: discord.Interaction) -> None:
        """
        暇ボタンが押下された際のコールバック処理

        :param interaction: ユーザ操作をあらわすオInteractionブジェクト
        :return:　None
        """

    async def party_callback(self, interaction: discord.Interaction) -> None:
        """
        暇じゃないボタンが押下された際のコールバック処理

        :param interaction: ユーザ操作をあらわすオInteractionブジェクト
        :return:　None
        """

    async def invite_callback(self, interaction: discord.Interaction) -> None:
        """
        一緒にゲームしてくれる人を募集するボタンが押下された際のコールバック処理

        :param interaction: ユーザ操作をあらわすオInteractionブジェクト
        :return:　None
        """

    async def on_timeout(self) -> None:
        """
        Viewのタイムアウト時に自動実行される処理

        :return:　None
        """

    @classmethod
    async def disable_all_buttons(cls, interaction: discord.Interaction) -> None:
        """
        各ボタンの押下時に複数ボタン押下を防ぐため、当該メッセージのボタンを非活性にする処理

        :param interaction: ユーザ操作をあらわすオInteractionブジェクト
        :return:　None
        """

class GameSelectView(View):
    """
    一緒にゲームしてくれる人を募集するボタン押下時に表示されるゲームタイトルを選択するView
    """


    async def game_callback(self, interaction: discord.Interaction) -> None:
        """
        ゲームタイトル選択時に実行されるコールバック処理

        :param interaction: ユーザ操作をあらわすオInteractionブジェクト
        :return:　None
        """


class DateSelectView(View):
    """
    ゲームタイトル選択後に表示されるプレイ日を選択するView
    """

    async def date_callback(self, interaction: discord.Interaction) -> None:
        """
        日付選択時に実行されるコールバック処理

        :param interaction: ユーザ操作をあらわすオInteractionブジェクト
        :return:　None
        """

class TimeSelectView(View):
    """
    プレイ日の選択後に表示されるプレイ開始時間を選択するView
    """

    async def time_callback(self, interaction: discord.Interaction) -> None:
        """
        日時選択時に実行されるコールバック処理

        :param interaction: ユーザ操作をあらわすオInteractionブジェクト
        :return:　None

@bot.event
async def on_ready() -> None:
    """
    bot起動時に自動実行される処理
    :return: None
    """

    print(f"Logged in as {bot.user}")
    if not scheduled_post.is_running():
        scheduled_post.start()


@bot.command()
async def hima(ctx) -> None:
    """
    !himaコマンド実行時の処理

    :param ctx: コマンド実行時のContextオブジェクト
    :return: None
    """

    if ctx.channel.id not in [int(DISCORD_CHANNER_ID)]:
        await ctx.send(
            "このコマンドは今、暇？？チャンネルでのみ使えます。", delete_after=10
        )
        return

    view: MainView = MainView()
    view.message = await ctx.send("今、暇？？", view=view)


@tasks.loop(minutes=1)
async def scheduled_post() -> None:
    """
    非同期で1分毎に定期実行され、毎日19時にメッセージ投稿をする処理

    :return: None
    """
    now = datetime.now(tz=ZoneInfo("Asia/Tokyo"))
    if now.hour == 19 and now.minute == 00:
        view: MainView = MainView()
        channel = bot.get_channel(int(DISCORD_CHANNER_ID))
        view.message = await channel.send(content="【定時投稿】今日、暇？？", view=view)

# Flask appの設定
app = Flask(__name__)


@app.route("/")
def home() -> str:
    """
    ヘルスチェック利用でBotを起こすためのエンドポイント

    :return:　str
    """
    return "Bot is running!"


# Flask実行用の関数
def run_flask() -> None:
    """
    Flaskサーバー起動処理.サブスレッド実行にするため、関数に分離
    :return: None
    """
    app.run(host="0.0.0.0", port=os.getenv("PORT", 8080), debug=False)


if __name__ == "__main__":
    # Flaskサーバーの起動はブロッキング処理のため、メインスレッドの実行を妨げないようにサブスレッドで実行
    server_thread = Thread(target=run_flask, daemon=True)
    server_thread.start()

    # ボット起動
    bot.run(DISCORD_TOKEN_ID)

```

それぞれ、簡単に説明していきます。


```:python
class MainView(View)
class GameSelectView(View):
class DateSelectView(View):
class TimeSelectView(View):
```

まず、上記のクラス関連ですがbotが出すメッセージの表示を記載しています。
!himaコマンド/定時投稿のメッセージではMainViewを紐づけており、ゲームを一緒にしてくれる人を募集するボタンを押した場合のみGameSlectView => DateSelectView => TimeSelectViewの順番で遷移し最後のVIEWで全体向けにメッセージ送信をしています。


```:python
@bot.event
async def on_ready() -> None:
    """
    bot起動時に自動実行される処理
    :return: None
    """

    print(f"Logged in as {bot.user}")
    if not scheduled_post.is_running():
        scheduled_post.start()
```

ボット起動時の処理です。
バッチの定時投稿が2回送信されないように制御+スケジュール処理の起動をしています


```:python
@bot.command()
async def hima(ctx) -> None:
    """
    !himaコマンド実行時の処理

    :param ctx: コマンド実行時のContextオブジェクト
    :return: None
    """

    if ctx.channel.id not in [int(DISCORD_CHANNER_ID)]:
        await ctx.send(
            "このコマンドは今、暇？？チャンネルでのみ使えます。", delete_after=10
        )
        return

    view: MainView = MainView()
    view.message = await ctx.send("今、暇？？", view=view)
```

!himaコマンドの処理です。特定チャンネルのみで運用しているので、特定チャンネル以外はVIEWを呼び出さないようにしています。

```:python
@tasks.loop(minutes=1)
async def scheduled_post() -> None:
    """
    非同期で1分毎に定期実行され、毎日19時にメッセージ投稿をする処理

    :return: None
    """
    now = datetime.now(tz=ZoneInfo("Asia/Tokyo"))
    if now.hour == 19 and now.minute == 00:
        view: MainView = MainView()
        channel = bot.get_channel(int(DISCORD_CHANNER_ID))
        view.message = await channel.send(content="【定時投稿】今日、暇？？", view=view)
```

バッチの定時投稿の処理です。

この処理をon_ready時に設定することで、discordがバックエンドの非同期処理として１分毎に実行してくれます。

secondまで厳密に条件判定していないのは、０秒丁度での処理の実行が担保されていないためです。

```:python
# Flask appの設定
app = Flask(__name__)


@app.route("/")
def home() -> str:
    """
    ヘルスチェック利用でBotを起こすためのエンドポイント

    :return:　str
    """
    return "Bot is running!"


# Flask実行用の関数
def run_flask() -> None:
    """
    Flaskサーバー起動処理.サブスレッド実行にするため、関数に分離
    :return: None
    """
    app.run(host="0.0.0.0", port=os.getenv("PORT", 8080), debug=False)


if __name__ == "__main__":
    # Flaskサーバーの起動はブロッキング処理のため、メインスレッドの実行を妨げないようにサブスレッドで実行
    server_thread = Thread(target=run_flask, daemon=True)
    server_thread.start()

    # ボット起動
    bot.run(DISCORD_TOKEN_ID)
```

今回、botは無料で24時間稼働させたかったためRenderのヘルスチェック + UptimeRobotというサービスを利用しています。

Renderのヘルスチェック + UptimeRobotで定期的に簡易webサーバーにHTTPリクエストを送り、botのスピンダウン(bot停止)がおこらないように工夫しています。

また、Flaskサーバーの起動とbotの起動はどちらもブロッキング処理のため、片方が実行されるともう片方が実行されなくなってしまうので別スレッドで実行しています。

# デプロイ

デプロイはRendeを利用しています。

Githubリポジトリと連携しています。そのためRender側で設定したブランチにPushがあると、デプロイが自動で行われるようになっています(CI/CD便利！！)

ビルドコマンドはpipでuvをインストールして、ボット起動するようにしています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3118286/88db1c00-a143-4ef7-9e64-3758d13670b4.png)


参考:　Renderについて

https://qiita.com/tks_00/items/73cf04c225e5f6113cd8

# 工夫したこと

工夫したことは以下です。

- botが２４時間稼働できるように技術選定、UptimeRobotをつかうなど工夫した
- VIEWに適切なタイムアウトを設けた。タイムアウト時の処理でボタンを非活性にすることで、過去メッセージのボタンをおされてエラーがでるなどの不具合を事前に塞いだ
- botのサムネイルやメッセージにこだわった

# 苦労したこと

苦労したことは以下です。

- デプロイ完了後の動作確認でメッセージが2回ボットから送られてきた
    - Flaskサーバーを立てていたが、debug=Trueで動いていたらしく、子プロセスが立ち上がっていたことが原因
    - Flaskサーバーのdebug引数は環境依存になっているらしく明示的な指定をしておいたほうが安全
- デプロイ時にuvをインストールできずに苦労した
    - pipでインストールするようにしたところ解消(curlでインストール想定だったがやり方が誤っていたのか断念。。。無念。。)
- タイムゾーン設定をしておらず、朝の４時に定時のメッセージ投稿がきてしまっていた
- ディスコードのbotを初めて作ったこともあり、Viewオブジェクトの理解に苦労した
    - 主に、ボタン活性/非活性周りの表示制御に苦労した


# 課題

課題は主に以下です。

- 弟しか使わない想定で作ったため、複数人が暇じゃないボタンを押せない
    - チャンネル参加者のだれかが定時投稿のボタンを押してしまったら、他の人が自己申告できず、都度!himaコマンドからの自己申告の必要がある

- 単体テストまで書こうと考えていたが、億劫になり書けていない

# 最後に

恥ずかしいので、ソースコードの公開予定はありませんが、気が変わって公開するかもしれません。

この後は時間があれば課題部分の解消や生成AIを使った機能なんかを盛り込めたらなと思っています。

モチベーションになりますので、いいね:thumbsup:やストックいただけるととても嬉しいです。

ここまで読んでいただきありがとうございました:bow:


# 弟の反応

(なんか面白いらしいです)

![image0.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3118286/77473571-7e0c-4daa-bd39-0b49a2ee4819.jpeg)