LabHub

ブログ

Discord Bot開発完全ガイド:Pycordでスラッシュコマンド、ボタン、モーダルまで

한국어English日本語

Discord Bot開発の準備

この記事はPycord v2.8.1(2026-07-25リリース、Python 3.10以上3.15未満)を基準にしています。バージョンを固定する理由があります。PythonのDiscordライブラリはdiscord.pyとPycordの二系統ですが、インポートパスがどちらもdiscordなので、検索で見つけたコードがどちらのものかは見た目では区別できません。ところがこの二つは、拡張のロードとコールバックの引数順という決定的な地点で異なります。以下で一つずつ見ていきます。

Discord開発者ポータルの設定

  1. Discord Developer PortalでNew Applicationを作成
  2. Botタブでトークンをコピー(絶対に公開しないでください!)
  3. OAuth2タブでボット招待URLを生成:
    • Scopes: bot, applications.commands
    • Permissions: 必要な権限を選択

Scopesでapplications.commandsを入れ忘れることが非常によくあります。botだけをチェックしてもボットはサーバーに入りオンラインに見えますが、スラッシュコマンドを登録する権限がありません。

特権インテントを有効にする場所

Botタブの下にあるPrivileged Gateway Intents項目に、SERVER MEMBERS、PRESENCE、MESSAGE CONTENTの三つのスイッチがあります。それぞれコードのIntents.membersIntents.presencesIntents.message_contentに対応します。

Pycordのドキュメントはここに警告を付けています。ポータルでインテントを有効にしても、コード側でも有効にしなければならないということです。二つのスイッチは独立していて、症状も異なります。コードだけ有効にするとボットはログイン自体ができず例外を投げ、ポータルだけ有効にすると例外もなくイベントだけが届きません。後者のほうがはるかに見つけにくいです。

ユニークユーザー10,000人を超えると、特権インテントはDiscordの審査を通ります。公開するつもりなら、最初からmessage_contentなしで設計しておいてください。

プロジェクト設定

# 仮想環境の作成
python -m venv venv
source venv/bin/activate

# Pycordのインストール
pip install py-cord python-dotenv aiohttp

# プロジェクト構造
# my-discord-bot/
# ├── bot.py              # メインボットファイル
# ├── cogs/
# │   ├── __init__.py
# │   ├── general.py      # 一般コマンド
# │   ├── moderation.py   # 管理コマンド
# │   └── fun.py          # お楽しみコマンド
# ├── utils/
# │   └── helpers.py
# ├── .env
# └── requirements.txt

.envファイル

DISCORD_TOKEN=your_bot_token_here
GUILD_IDS=123456789012345678

基本的なボット構造

# bot.py
import discord
from discord.ext import commands
import os
from dotenv import load_dotenv

load_dotenv()

# Intentsの設定
intents = discord.Intents.default()
intents.message_content = True
intents.members = True

bot = discord.Bot(intents=intents)

@bot.event
async def on_ready():
    print(f"✅ {bot.user} ログイン完了!")
    print(f"📊 {len(bot.guilds)}個のサーバーに接続済み")
    await bot.change_presence(
        activity=discord.Activity(
            type=discord.ActivityType.watching,
            name="サーバーを監視中 👀"
        )
    )

# Cogの読み込み
for filename in os.listdir("./cogs"):
    if filename.endswith(".py") and not filename.startswith("_"):
        bot.load_extension(f"cogs.{filename[:-3]}")

bot.run(os.getenv("DISCORD_TOKEN"))

上のコードの二行だけ見てみましょう。bot = discord.Bot(intents=intents)はスラッシュコマンドだけを使うという宣言であり、bot.load_extension(...)の前にawaitがないのは誤字ではありません。どちらも次の節の主題です。

discord.Botとcommands.Bot、そして拡張ロードの落とし穴

どのBotクラスを選ぶか

ドキュメントのcommands.Botの説明はこう始まります。このクラスはdiscord.Botのサブクラスであり、したがってdiscord.Botでできることはこのボットでも何でもできる、というものです。追加で載せるのはGroupMixin、つまりプレフィックスコマンドの機能です。だから基準は単純です。スラッシュコマンドだけを使うならdiscord.Bot!pingのようなプレフィックスコマンドも使うならcommands.Botです。

隠れたコストがあります。プレフィックスコマンドはメッセージ本文を読む必要があるため、MESSAGE CONTENT特権インテントが要ります。新しく作るボットならdiscord.Botから始めてください。サブクラスの関係なので、あとで差し替えても既存のスラッシュコマンドはそのまま動きます。

awaitのないload_extension

もっとも頻繁に人を捕まえる落とし穴です。Pycordの拡張ロードは同期関数です。

# Pycord v2.8.1 — ドキュメントに掲載されているシグネチャ
def load_extension(self, name, *, package=None, recursive=False, store=False)

async defではなく、ただのdefです。Cogのエントリーポイントも同期関数です。

# cogs/general.py の末尾 — Pycord のやり方
def setup(bot):
    bot.add_cog(General(bot))

discord.py 2.xはちょうど反対にawait bot.load_extension(...)async def setup(bot)を要求します。Stack Overflowの回答はほとんどがdiscord.py基準なので、そのままコピーすると静かに食い違います。

症状が曖昧なのでさらに悪いです。Pycordはsetupを同期的に呼び出すのですが、async def setupを呼び出すとPythonは本体を実行せずコルーチンオブジェクトだけを返します。つまりbot.add_cog(...)が実行されません。例外もなく、ボットは問題なくログインし、コマンドだけが一覧に現れません。逆にawaitを付ければコルーチンでない値を待つことになりTypeErrorが出ます。

初回起動:ターミナルに実際に表示されるもの

python bot.pyを初めて押したときに出てくる画面は、だいたい四通りです。

成功したとき

✅ MyBot#1234 ログイン完了!
📊 1個のサーバーに接続済み

on_readyの中のprintがそのまま出ます。ここまで来たならトークンとゲートウェイ接続に問題はありません。ただしon_readyがプロセスごとに一度だけ呼ばれる保証はありません。再接続のときにまた呼ばれるので、マイグレーションや起動通知のように一度だけ行うべき処理は別のフラグで止めておいてください。

トークンが間違っているとき

認証が拒否され、ログインの段階で例外が出てプロセスが終了します。トークン文字列、.envのパス、load_dotenv()を呼んでいるかどうか、トークンの前後の引用符と空白を、この順に確認してください。ポータルでトークンをResetすると以前のトークンは即座に無効になります。

インテントを有効にしていないとき

discord.PrivilegedIntentsRequiredが発生します。ドキュメントの説明そのままで、ゲートウェイが特権インテントを要求しているのに開発者ページでまだチェックされていないときに出る例外です。shard_id属性があるので、どのシャードで出たのかが分かります。上のbot.pyはmessage_contentとmembersの両方を有効にするので、ポータルでも二つを有効にする必要があります。

ボットは起動したのにmessage.contentが空のとき

例外が出ないので、もっとも長く迷う場合です。MESSAGE CONTENTインテントがないと、Discordはユーザーが入力した内容の入るフィールドを空の値で送ってきます。エラーも警告もなく、ただの空文字列です。

例外が四つあるのでさらに紛らわしくなります。ボット自身が送ったメッセージ、ボットとのDM、ボットをメンションしたメッセージ、そしてメッセージコンテキストメニューコマンドが対象にしたメッセージは、インテントなしでも内容が届きます。だからDMでテストするとうまくいき、メンションしてもうまくいきます。サーバーチャンネルでメンションなしに送った普通のメッセージでテストして、はじめてこの問題が表に出ます。

スラッシュコマンド

# cogs/general.py
import discord
from discord.ext import commands
from discord import option
import aiohttp
from datetime import datetime

class General(commands.Cog):
    def __init__(self, bot):
        self.bot = bot

    @discord.slash_command(name="ping", description="ボットの応答時間を確認します")
    async def ping(self, ctx: discord.ApplicationContext):
        latency = round(self.bot.latency * 1000)
        embed = discord.Embed(
            title="🏓 Pong!",
            description=f"レイテンシ: **{latency}ms**",
            color=discord.Color.green() if latency < 100 else discord.Color.red()
        )
        await ctx.respond(embed=embed)

    @discord.slash_command(name="userinfo", description="ユーザー情報を表示します")
    @option("user", description="情報を確認するユーザー", type=discord.Member, required=False)
    async def userinfo(self, ctx: discord.ApplicationContext, user: discord.Member = None):
        user = user or ctx.author

        embed = discord.Embed(
            title=f"👤 {user.display_name}",
            color=user.color
        )
        embed.set_thumbnail(url=user.display_avatar.url)
        embed.add_field(name="ID", value=user.id, inline=True)
        embed.add_field(name="参加日", value=user.joined_at.strftime("%Y-%m-%d"), inline=True)
        embed.add_field(name="アカウント作成日", value=user.created_at.strftime("%Y-%m-%d"), inline=True)
        embed.add_field(
            name="ロール",
            value=", ".join([r.mention for r in user.roles[1:]]) or "なし",
            inline=False
        )

        await ctx.respond(embed=embed)

    @discord.slash_command(name="weather", description="天気情報を取得します")
    @option("city", description="都市名", type=str, required=True)
    async def weather(self, ctx: discord.ApplicationContext, city: str):
        await ctx.defer()  # 応答遅延の表示

        async with aiohttp.ClientSession() as session:
            url = f"https://wttr.in/{city}?format=j1"
            async with session.get(url) as resp:
                if resp.status != 200:
                    await ctx.followup.send("❌ 都市が見つかりません。")
                    return

                data = await resp.json()
                current = data["current_condition"][0]

                embed = discord.Embed(
                    title=f"🌤 {city}の天気",
                    color=discord.Color.blue()
                )
                embed.add_field(name="🌡 気温", value=f"{current['temp_C']}°C", inline=True)
                embed.add_field(name="💧 湿度", value=f"{current['humidity']}%", inline=True)
                embed.add_field(name="💨 風速", value=f"{current['windspeedKmph']} km/h", inline=True)
                embed.add_field(name="状態", value=current["weatherDesc"][0]["value"], inline=False)

                await ctx.followup.send(embed=embed)

def setup(bot):
    bot.add_cog(General(bot))

optionデコレーターとOption型

パラメータを定義する方法は二つです。Pythonの型アノテーションを使うか、@discord.optionデコレーターを使うかです。ドキュメントは後者を、Optionを型ヒントとして使う代わりに使えるデコレーターだと説明しています。

ここでドキュメント自体につまずきやすいです。ガイドページの例は@discord.option("first", type=...)のようにtype=を使いますが、APIリファレンスのシグネチャはoption(name, input_type=None, **kwargs)で、パラメータ名がinput_typeです。二つの表記が混ざった例が一緒に出回っているので、迷ったらinput_type=を使うか、型アノテーションだけを使ってください。

discord.Optionを直接使うときはOption(input_type=str, /, description=None, **kwargs)です。スラッシュの前のinput_typeは位置専用引数なのでキーワードでは渡せません。UIのオプション名とPythonのパラメータ名を分けたいときはparameter_nameを使います。

from discord import optionfrom discord import slash_commandも有効です。discord/commands/__init__.pyを通じて再エクスポートされるため、@discord.slash_command(...)@slash_command(...)は同じものです。例ごとに表記が違うので別のAPIのように見えます。一つに統一しておきましょう。コマンドが増えたらdiscord.SlashCommandGroup(name, description=None, guild_ids=None, parent=None, cooldown=None, max_concurrency=None, **kwargs)で二段階にまとめます。

スラッシュコマンドが一覧に表示されない

ボットはオンラインなのに、スラッシュを押してもコマンドが見えません。原因が四つほどあり、確認する順番が重要です。

1. 招待URLにapplications.commandsが抜けている

圧倒的によくあります。Scopesにbotだけをチェックすると、ボットはサーバーに入りオンラインに見えますが、スラッシュコマンドを登録する権限自体がありません。二つのスコープを両方含んだURLで同じサーバーに再招待してください。すでに入っているボットも再招待でき、既存の設定は維持されます。

2. まだ同期されていない

PycordはBot.auto_sync_commandsがデフォルトTrueで、discord.on_connectの時点でBot.sync_commandsを呼び出します。普段は気にする必要がありませんが、手動で呼ぶときのためにシグネチャを見ておきましょう。

async def sync_commands(
    self,
    commands=None,
    method: Literal["individual", "bulk", "auto"] = "bulk",
    force=False,
    guild_ids=None,
    register_guild_commands=True,
    check_guilds=[],
    delete_existing=True,
)

コルーチンなのでこちらはawaitが必要です。先ほどのload_extensionと逆なので混乱しやすいところです。delete_existing=Trueがデフォルトなので、コードから消したコマンドはDiscordからも消えます。

3. ギルドコマンドとグローバルコマンドを取り違えた

Discordのドキュメントの案内はこうです。ギルドコマンドは即座に更新されるので、素早いテストにはギルドコマンドを、公開の準備が終わったらグローバルコマンドを使えというものです。グローバルコマンドには内部バージョンの確認が付いていて、古い定義で実行するとDiscordがそのコマンドを拒否し、読み込み直すようトリガーします。

開発中はdebug_guildsがもっとも便利です。ドキュメントの説明は、コマンドのテストに使うギルドのIDであり、デバッグギルドIDが渡されるとボットはグローバルコマンドを一つも作らない、というものです。後半が重要です。この値を有効にしたままデプロイすると、そのサーバーの外ではコマンドが一つも見えません。ローカルでは動くのにデプロイすると動かない、という症状の定番の原因です。

4. 1日の登録上限を使い切った

Discordにはギルドあたり1日200回のアプリケーションコマンド作成というグローバルレートリミットがあります。普段は届くことのない数字ですが、クラッシュで再起動ループに陥ったボットが毎回フル同期をすると到達しうる数字です。

3秒ルール:deferとインタラクショントークン

必ず覚えておくべき数字が二つあります。Discordのドキュメントの表現をそのまま移すと、イベントを受け取ってから3秒以内に最初の応答を送らなければならず、3秒のデッドラインを超えるとトークンが無効化されます。そしてインタラクショントークンは15分間有効です。3秒は最初の応答のデッドラインで、超えるとあとで結果が出ても送る先がありません。15分は最初の応答を間に合わせて送ったあと、フォローアップメッセージを送ったり応答を編集したりできる窓です。

だから3秒で終わらない処理は、まずdeferで場所を確保しておき、作業が終わってからフォローアップメッセージを送ります。先ほどの/weatherがまさにこのパターンです。外部HTTP呼び出しはいつでも3秒を超えうるのでawait ctx.defer()を先に呼び、結果が来たらctx.followup.send(...)で送ります。逆に/pingはレイテンシの計算だけなのですぐrespondします。

deferのシグネチャはこうです。

async def defer(self, *, ephemeral: bool = False, invisible: bool = True)

invisibleが実質的な違いを作ります。Discordの応答タイプで見ると、一つはDEFERRED_CHANNEL_MESSAGE_WITH_SOURCE(タイプ5)で、ドキュメントの説明はユーザーにローディング状態が見えるというものです。もう一つはDEFERRED_UPDATE_MESSAGE(タイプ6)で、ローディング状態は見えません。ボタンのクリックを静かに裏だけで処理したいときは後者が合っています。

respondがそのまま動く理由

Pycordの例のほとんどがctx.respond一つで終わるのには理由があります。respondself.response.is_done()でなければself.response.send_message(...)を、すでに応答したあとならself.followup.send(...)を呼びます。InteractionResponded例外も一緒に捕まえます。最初の応答なのかフォローアップなのかを自動で振り分けてくれるという意味です。

おかげでdeferのあとでもctx.respondをそのまま使えます。一方でinteraction.response.send_messageを直接二度呼ぶと、こういうエラーが出ます。

Interaction was already issued a response. Try using {type}.send_followup() instead.

二度目の呼び出しからはフォローアップメッセージの経路を使えという意味です。

ボタンインタラクション

# cogs/fun.py
import discord
from discord.ext import commands
import random

class RockPaperScissorsView(discord.ui.View):
    def __init__(self):
        super().__init__(timeout=30)

    @discord.ui.button(label="✊ グー", style=discord.ButtonStyle.primary, custom_id="rock")
    async def rock(self, button: discord.ui.Button, interaction: discord.Interaction):
        await self.play(interaction, "rock")

    @discord.ui.button(label="✋ パー", style=discord.ButtonStyle.success, custom_id="paper")
    async def paper(self, button: discord.ui.Button, interaction: discord.Interaction):
        await self.play(interaction, "paper")

    @discord.ui.button(label="✌️ チョキ", style=discord.ButtonStyle.danger, custom_id="scissors")
    async def scissors(self, button: discord.ui.Button, interaction: discord.Interaction):
        await self.play(interaction, "scissors")

    async def play(self, interaction: discord.Interaction, user_choice: str):
        choices = {"rock": "✊", "paper": "✋", "scissors": "✌️"}
        bot_choice = random.choice(list(choices.keys()))

        if user_choice == bot_choice:
            result = "🤝 引き分け!"
            color = discord.Color.yellow()
        elif (user_choice == "rock" and bot_choice == "scissors") or \
             (user_choice == "paper" and bot_choice == "rock") or \
             (user_choice == "scissors" and bot_choice == "paper"):
            result = "🎉 勝利!"
            color = discord.Color.green()
        else:
            result = "😢 敗北!"
            color = discord.Color.red()

        embed = discord.Embed(title=result, color=color)
        embed.add_field(name="あなた", value=choices[user_choice], inline=True)
        embed.add_field(name="ボット", value=choices[bot_choice], inline=True)

        # ボタンを無効化
        for child in self.children:
            child.disabled = True

        await interaction.response.edit_message(embed=embed, view=self)

class Fun(commands.Cog):
    def __init__(self, bot):
        self.bot = bot

    @discord.slash_command(name="rps", description="じゃんけんゲーム!")
    async def rps(self, ctx: discord.ApplicationContext):
        embed = discord.Embed(
            title="✊✋✌️ じゃんけん!",
            description="ボタンをクリックして選択してください!",
            color=discord.Color.blue()
        )
        await ctx.respond(embed=embed, view=RockPaperScissorsView())

def setup(bot):
    bot.add_cog(Fun(bot))

コールバックの引数順に注意

Pycordのボタンコールバックのシグネチャは(self, button, interaction)で、コンポーネントが先です。ドキュメントの説明をそのまま移すと、デコレートされる関数は三つのパラメータを持たなければならず、それぞれdiscord.ui.Viewを表すself、押されたdiscord.ui.Button、そして受け取るdiscord.Interactionです。Selectも同じ順で(self, select, interaction)です。モーダルだけが(self, interaction)で、コンポーネントの引数がありません。

順序を逆にしてもPythonは何も言いません。型ヒントを反対に書いただけだからです。実行時にはinteractionという変数にボタンオブジェクトが入ったままawait interaction.response.send_message(...)を呼ぶことになり、ボタンにはそんな属性がないのでAttributeErrorが出ます。他のライブラリの例を移してきたときに最初のクリックで落ちる、典型的な姿です。

Viewのtimeoutと永続View

discord.ui.Viewtimeoutのデフォルト値は180.0秒です。指定しなければ3分後にViewが期限切れになり、on_timeoutコルーチンが呼ばれます。ドキュメントの説明は、明示的な停止なしにViewのタイムアウトが経過したときに呼ばれるというものです。上のじゃんけんViewはtimeout=30なので、30秒以内に押さなければ終わります。

問題はロール選択パネルのように生き続けなければならないボタンです。条件が二つあります。ドキュメントの表現そのままで、timeoutをNoneにしておくことと、Viewのすべての子がcustom_id属性を持っていることです。

二つ目だけを守って一つ目を忘れることがよくあります。症状はこうです。デプロイ直後はボタンがちゃんと押せるのに、ボットを再起動すると以前のメッセージのボタンが死んでいます。ボットのプロセスがViewオブジェクトをメモリに持っていて、再起動で失ったのです。永続Viewは起動時点で登録し直す必要があり、そのためにはtimeoutがNoneでなければなりません。

逆にゲームのように一度使って捨てるViewなら、custom_idを固定しないほうがよいです。同じコマンドを複数のユーザーが同時に実行すると、同じcustom_idを持つボタンがチャンネルにいくつも漂うことになります。

discord.ButtonStyleはprimary、secondary、success、danger、linkの五つで、エイリアスが付いています。blurpleはprimary、greyとgrayはsecondary、greenはsuccess、redはdanger、urlはlinkと同じ値です。例ごとに表記が分かれる理由がこれです。

モーダルフォーム

class FeedbackModal(discord.ui.Modal):
    def __init__(self):
        super().__init__(title="📋 フィードバック送信")

        self.add_item(discord.ui.InputText(
            label="タイトル",
            placeholder="フィードバックのタイトルを入力してください",
            style=discord.InputTextStyle.short,
            required=True,
            max_length=100
        ))

        self.add_item(discord.ui.InputText(
            label="内容",
            placeholder="詳細内容を入力してください",
            style=discord.InputTextStyle.long,
            required=True,
            max_length=2000
        ))

        self.add_item(discord.ui.InputText(
            label="スコア (1-5)",
            placeholder="1",
            style=discord.InputTextStyle.short,
            required=False,
            max_length=1
        ))

    async def callback(self, interaction: discord.Interaction):
        title = self.children[0].value
        content = self.children[1].value
        rating = self.children[2].value or "未入力"

        embed = discord.Embed(
            title="📋 新しいフィードバック",
            color=discord.Color.blue()
        )
        embed.add_field(name="タイトル", value=title, inline=False)
        embed.add_field(name="内容", value=content, inline=False)
        embed.add_field(name="スコア", value=f"{'⭐' * int(rating)}" if rating.isdigit() else rating)
        embed.set_footer(text=f"作成者: {interaction.user.display_name}")

        # フィードバックチャンネルに送信
        feedback_channel = interaction.guild.get_channel(FEEDBACK_CHANNEL_ID)
        if feedback_channel:
            await feedback_channel.send(embed=embed)

        await interaction.response.send_message(
            "✅ フィードバックが送信されました!ありがとうございます。", ephemeral=True
        )

# スラッシュコマンドでモーダルを開く
@discord.slash_command(name="feedback", description="フィードバックを送信します")
async def feedback(ctx: discord.ApplicationContext):
    await ctx.send_modal(FeedbackModal())

discord.InputTextStyleにもエイリアスがあります。列挙型の定義を見るとshortsinglelineがどちらも1で、paragraphmultilinelongがすべて2です。上の例のlongparagraphは同じ値です。

モーダルを開く場所と3秒ルール

モーダルはスラッシュコマンドからawait ctx.send_modal(modal)で開くか、ボタンのコールバックからawait interaction.response.send_modal(...)で開きます。重要な制約は、モーダルが最初の応答でなければならないという点です。deferを先に呼んでからモーダルを開くことはできません。最初の応答のスロットをすでに使ってしまっているからです。

だから重い照会を先に行い、その結果を詰めたモーダルを出すという設計は、照会が3秒で終わらなければ成り立ちません。順序をひっくり返してください。モーダルを先に出して入力を受け取り、送信されたあとにdeferをかけて重い作業を行うのです。

上の例のFEEDBACK_CHANNEL_IDは定義されていない定数です。環境変数から読んで整数に変換しておく必要があります。self.childrenのインデックスで値を取り出すのも危険です。入力フィールドを追加したり順序を変えたりするとインデックスが静かにずれます。

discord.ui.Modaldiscord.ui.InputTextのコンストラクタ引数の全体はバージョンによって変わることがあります。labelstyle以外の引数は、使用中のバージョンのドキュメントで確認してください。

セレクトメニュー

class RoleSelectView(discord.ui.View):
    @discord.ui.select(
        placeholder="ロールを選択してください(最大3つ)",
        min_values=1,
        max_values=3,
        options=[
            discord.SelectOption(label="開発者", emoji="💻", value="developer"),
            discord.SelectOption(label="デザイナー", emoji="🎨", value="designer"),
            discord.SelectOption(label="プランナー", emoji="📊", value="planner"),
            discord.SelectOption(label="マーケター", emoji="📢", value="marketer"),
            discord.SelectOption(label="データアナリスト", emoji="📈", value="analyst"),
        ]
    )
    async def select_callback(self, select: discord.ui.Select, interaction: discord.Interaction):
        selected = ", ".join(select.values)
        await interaction.response.send_message(
            f"✅ 選択したロール: {selected}", ephemeral=True
        )

Selectのコールバックもボタンと同じ順、つまり(self, select, interaction)です。select.valuesはリストなので、max_valuesが1でもリストで来ます。要素を取り出すのを忘れると画面に角括弧がそのまま表示されます。

上の例は選択結果を知らせるだけです。実際にロールを付与するには条件がもう二つ必要です。ボットにロール管理の権限があること、そしてボットの最上位ロールが付与しようとするロールよりサーバーのロール一覧で上にあることです。二つ目の条件のせいで、権限は全部与えたのにHTTP 403系のエラーが出ることがよくあります。

エラーハンドリング

# bot.pyにグローバルエラーハンドラーを追加
@bot.event
async def on_application_command_error(ctx: discord.ApplicationContext, error):
    if isinstance(error, commands.MissingPermissions):
        await ctx.respond("❌ 権限が不足しています。", ephemeral=True)
    elif isinstance(error, commands.CommandOnCooldown):
        await ctx.respond(
            f"⏳ クールダウン中です。{error.retry_after:.1f}秒後に再試行してください。",
            ephemeral=True
        )
    elif isinstance(error, commands.MemberNotFound):
        await ctx.respond("❌ ユーザーが見つかりません。", ephemeral=True)
    else:
        # ロギング
        import traceback
        traceback.print_exception(type(error), error, error.__traceback__)
        await ctx.respond("❌ エラーが発生しました。", ephemeral=True)

on_application_command_errorはスラッシュコマンドのコールバックから上がってきた例外だけを受け取ります。ボタン、Select、モーダルのコールバックの例外はここには来ません。Viewには別のエラー処理の場所があるので、インタラクションの多いボットならそちらを別に用意する必要があります。

ctx.respondでエラーを知らせるときにも注意が要ります。3秒をすでに超えてトークンが無効化されたあとなら、何も送れません。ユーザーの画面には失敗だけが残り、痕跡はログにしか残りません。だからエラーハンドラーは必ずロギングを先にして、通知を後で試みなければなりません。上のコードがtraceback.print_exceptionを先に呼ぶ理由がこれです。順序を変えると、通知の送信が失敗したときに元の例外まで消えます。

レートリミット:429が来ると何が起こるか

Discord APIは上限を超えるとHTTP 429を返します。応答にはX-RateLimit-LimitRetry-AfterX-RateLimit-Reset-Afterヘッダーが付きます。ドキュメントが明示する全体の上限はこうです。すべてのボットは1秒あたり50回までAPIリクエストを送れます。

これに加えて無効リクエストの上限が別にあります。10分間で無効リクエスト10,000回を超えるとブロックされます。無効リクエストとは401、403、429の応答のことです。トークンが期限切れになったボットが再試行ループに陥ると、この上限に届きます。

Pycordが代わりにやってくれること

ほとんどの場合、自分で処理する必要はありません。PycordのHTTPClientは各リクエストをfor tries in range(5):ループで包み、429が来たら応答本文から再試行の待ち時間を読み取り、その分だけ眠ってから再試行します。全体の上限なのかバケット別の上限なのかも、応答のglobalフラグで区別します。

つまり短い超過は遅延として吸収されます。コードから見える症状はエラーではなく、コマンドが遅くなることです。大量削除や大量送信のコマンドがやけに遅いなら、たいていここです。エラーログを漁っても出てこないので、この動作を知らないと原因を見つけにくいです。

自分で捕まえる必要があるとき

五回の再試行でも駄目なら例外が上がってきます。ここでよく出る間違いがあります。discord.RateLimitedはPycord v2.8.1にはありません。discord.pyにはあるのでその名前を使う例が出回っていますが、Pycordでインポートすると失敗します。

Pycordではdiscord.HTTPExceptionを捕まえて.statusが429かどうかを確認してください。この例外は.status.code.text.response属性を持ちます。.statusはHTTPステータスコード、.codeはDiscord固有のエラーコードです。

2500ギルドを超えたら

ドキュメントの表現そのままで、各シャードは最大2500個のギルドまで対応でき、2500個以上のギルドに入ったアプリは必ずシャーディングを有効にしなければなりません。どのギルドがどのシャードに行くかは式が決まっています。

shard_id = (guild_id >> 22) % num_shards

シャード数を間違えると、ゲートウェイがclose code 4010 Invalid Shardで接続を切ります。Pycordではdiscord.AutoShardedBotまたはcommands.AutoShardedBotを使えば大丈夫です。ほとんどのボットには無縁の問題ですが、公開ボットを計画しているなら、プロセスが結局いくつにも割れるという事実を最初から前提にしておいてください。プロセスのメモリに状態を置く設計は、その時点で全部書き直しになります。

管理コマンド

# cogs/moderation.py
class Moderation(commands.Cog):
    def __init__(self, bot):
        self.bot = bot

    @discord.slash_command(name="clear", description="メッセージを削除します")
    @commands.has_permissions(manage_messages=True)
    @option("amount", description="削除するメッセージ数", type=int, min_value=1, max_value=100)
    async def clear(self, ctx: discord.ApplicationContext, amount: int):
        deleted = await ctx.channel.purge(limit=amount)
        await ctx.respond(f"🗑️ {len(deleted)}件のメッセージを削除しました", ephemeral=True)

    @discord.slash_command(name="slowmode", description="スローモードを設定します")
    @commands.has_permissions(manage_channels=True)
    @option("seconds", description="秒数(0=解除)", type=int, min_value=0, max_value=21600)
    async def slowmode(self, ctx: discord.ApplicationContext, seconds: int):
        await ctx.channel.edit(slowmode_delay=seconds)
        if seconds == 0:
            await ctx.respond("✅ スローモードが解除されました。")
        else:
            await ctx.respond(f"✅ スローモード: {seconds}秒に設定されました")

def setup(bot):
    bot.add_cog(Moderation(bot))

purgeの落とし穴

ctx.channel.purge(limit=amount)は便利に見えますが制約があります。Discordの一括削除エンドポイントは古いメッセージを消せません。大きな数字を入れても最近のものだけが消え、残りは静かに残ります。正確な基準の日数はDiscord APIのドキュメントで確認してください。

応答の順序も問題です。上のコードはpurgeを先にしてからctx.respondを呼びますが、削除に時間がかかると3秒を超えることがあります。すると削除はできているのにユーザーには失敗に見えます。await ctx.defer(ephemeral=True)を先にかけてフォローアップメッセージを送るほうが安全です。

権限チェックが実際に検査するもの

@commands.has_permissions(manage_messages=True)はコマンドを実行したユーザーの権限を見ます。ボット自身の権限は見ません。ユーザーには権限があるのにボットにない場合、チェックは通過して実際の削除の段階で403が出ます。するとMissingPermissionsの分岐ではなく最後のelseに落ちるので、ユーザーは権限の案内ではなく一般的なエラーメッセージを見ることになります。

スラッシュコマンドのデコレーターのdefault_member_permissionsを一緒に使えば、Discord UIの段階で権限のないユーザーからコマンドを丸ごと隠せます。ただしUIのフィルターでしかないので、コードの権限チェックの代わりにはなりません。両方置いてください。

デプロイ

systemdサービス

# /etc/systemd/system/discord-bot.service
[Unit]
Description=Discord Bot
After=network.target

[Service]
Type=simple
User=bot
WorkingDirectory=/opt/discord-bot
ExecStart=/opt/discord-bot/venv/bin/python bot.py
Restart=always
RestartSec=10
EnvironmentFile=/opt/discord-bot/.env

[Install]
WantedBy=multi-user.target
sudo systemctl enable discord-bot
sudo systemctl start discord-bot
sudo journalctl -u discord-bot -f

Restart=alwaysRestartSec=10が付いているのには理由があります。ゲートウェイ接続は状態を持つWebSocketなので、ネットワークが切れたりDiscordが接続を閉じたりするとプロセスが終了することがあります。

注意すべきは再起動ループです。トークンが間違っていたりインテントが切れていたりすると、ボットは起動した途端に死んで10秒後にまた立ち上がります。一晩放置すると無効リクエストの上限に届きます。デプロイ直後の数分はjournalctl -u discord-bot -fで見守ってください。ログインメッセージが一度出て静かになれば正常で、同じログが10秒周期で繰り返されるならループです。

WorkingDirectoryも重要です。上のbot.py./cogsを相対パスでたどるので、作業ディレクトリが違うとCogを一つも見つけられません。ローカルでは動いていたものがサービスとして立ち上げるとコマンドが消える場合、これも候補です。.envはトークンが平文で入っているファイルなので所有者だけが読めるようにしておき、うっかりコミットしてしまったならファイルを消して終わりにせず、ポータルでトークンをResetしてください。

失敗事例:症状から原因へ

ここまでに出てきた落とし穴を、症状から出発して並べ直しました。ボットがおかしいときに上から順になぞれば、たいていはここで終わります。

Discordボットを使わないのはどんなときか

Discordボットは、状態を持つWebSocket接続を常時維持するプロセスです。この構造が合わない場合がはっきりとあります。

通知を送るだけならWebhook

チャンネルにメッセージを一つ投げるだけがすべてなら、ボットは要りません。チャンネル設定でWebhook URLを作ってPOSTを一度すれば終わりです。プロセスも、トークンの寿命管理も、再起動ポリシーも、インテントの審査もありません。CIの結果、デプロイ通知、監視アラートが全部ここに当たります。ボットで作ると常時プロセス一つとゲートウェイ接続一つが維持費として付き、そのプロセスが死ぬと通知も一緒に死にます。Webhookには死ぬプロセスがありません。

スラッシュコマンドだけならゲートウェイは要らない

DiscordはインタラクションをHTTPで受け取る方式もサポートしています。公開エンドポイントを登録しておくとDiscordがそこにリクエストを送るので、ボットはゲートウェイ接続なしでサーバーレス関数としても動けます。その代わり、ゲートウェイイベントを前提とするライブラリの便利さは諦めることになります。メッセージの受信、メンバーの入退室、プレゼンスはそもそも受け取れません。どちらにしても3秒ルールは同じように適用され、サーバーレスならコールドスタートがその3秒を削ります。

配信保証が必要な仕事には向かない

ボットが再起動している間に入ってきたメッセージは消えます。ゲートウェイが再接続時に一部のイベントを送り直してはくれますが、キューのように信じてはいけません。必ず処理しなければならないイベントがあるなら、ボットは受付窓口までを担当し、実際の処理は別のキューとワーカーに渡してください。

ボットの中で長い作業を回すのも危険です。イベントループ一つがすべてのインタラクションを処理するので、一つのコマンドが長く掴んでいると他のユーザーの3秒の予算まで削ります。同じ理由で、状態をプロセスのメモリに置いてはいけません。Viewオブジェクト、進行中のゲーム、ユーザーごとの設定が全部再起動で消えます。先に見た永続Viewの問題が、この原則の小さな一例です。

業務ワークフローには過剰かもしれない

フォームを受け取り、承認の段階を経て、記録を残すという仕事は、モーダルとボタンで作ることはできます。ただし監査ログ、権限委譲、データ保持を全部自分で実装しなければなりません。判断基準は一つです。人々がすでにDiscordに常駐しているか。コミュニティがそこに住んでいるならボットは強力な選択肢で、一日に一度入るチャンネルなら作っても誰も使いません。

参考資料

この記事のコードとシグネチャはPycord v2.8.1(2026-07-25リリース)を基準にしています。ここにないAPIは、使用中のバージョンのドキュメントで確認してください。


📝 確認クイズ(6問)

Q1. Discord BotのIntentsとは?

ボットが受信するイベントの種類を指定する設定です。Privileged Intents(message_content、members)はDeveloper Portalで別途有効化が必要です。

Q2. スラッシュコマンドでctx.defer()はいつ使いますか?

応答に3秒以上かかる場合に使用します。defer()の後、ctx.followup.send()で実際の応答を送信します。

Q3. ephemeral=Trueの意味は?

そのメッセージがコマンドを実行したユーザーにのみ表示されるようにします。他のユーザーには見えません。

Q4. Cogの利点は?

コマンドをモジュール別に分離して管理でき、動的にロード/アンロードできます。コード構造化とメンテナンスに有利です。

Q5. Viewのtimeoutパラメータは何を制御しますか?

ボタン/セレクトメニューが無効化されるまでの時間(秒)です。Noneに設定するとタイムアウトなしになります。

Q6. Modalと通常のメッセージの違いは?

Modalはユーザーに入力フォームを表示して構造化されたデータを受け取ることができます。通常のメッセージはテキストのみのやり取りです。

クイズ

Q1: 「Discord Bot開発完全ガイド:Pycordでスラッシュコマンド、ボタン、モーダルまで」の主なトピックは何ですか?

Pycordを使ったDiscord Bot開発の全プロセスをハンズオンで学びます。スラッシュコマンド、ボタンインタラクション、モーダルフォーム、Embedメッセージ、Cog構造化までプロダクションレベルで実装します。

Q2: Discord Bot開発の準備とは何ですか? Discord開発者ポータルの設定 Discord Developer PortalでNew Applicationを作成 Botタブでトークンをコピー(絶対に公開しないでください!) OAuth2タブでボット招待URLを生成: Scopes: bot, applications.commands Permissions: 必要な権限を選択 プロジェクト設定 .envファイル

Q3: デプロイの核心的な概念を説明してください。 systemdサービス Q1. Discord BotのIntentsとは? ボットが受信するイベントの種類を指定する設定です。Privileged Intents(message_content、members)はDeveloper Portalで別途有効化が必要です。 Q2. スラッシュコマンドでctx.defer()はいつ使いますか? 応答に3秒以上かかる場合に使用します。defer()の後、ctx.followup.send()で実際の応答を送信します。 Q3. ephemeral=Trueの意味は?

コメント

まだコメントはありません。

ログインするとコメントできます