Discord「参加アンケート」完全ガイド

— リアクション地獄からの脱出:ボタン/セレクトで1メッセージ自動集計

このガイドでは、班ごとの参加可否を曜日×時間帯で集計し、押された瞬間に1つの集計メッセージを上書き更新する方法をまとめる。
リアクション確認を75回もタップするような運用はやめ、見やすい表で一発確認できる形にする。


1. 目標と方針

  • 目標
    • 曜日ごとに複数の時間帯を選べる
    • 誰がどの時間帯に来るかを1つのメッセージで集計表示
    • 2回目以降は同じメッセージを編集して更新(履歴が散らばらない)
    • 再起動後も動く(Persistent View
    • データはJSONに永続化(SQLite等にも置き換え可)
  • UI方針(2択)
    1. ボタン方式:時間帯をボタン化。押す→即時集計・トグル
    2. セレクト方式:曜日ごとに複数選択可のドロップダウン。1操作で複数枠を登録
      どちらも可能だが、曜日×複数枠に強いのはセレクト方式。

2. 必要要件

  • Python 3.10+(3.12でも可)
  • discord.py v2.x
  • サーバーでのBot権限:メッセージ送信/メッセージ管理(編集)/アプリを使用
  • Discord Developer PortalでBot作成済み(TOKEN取得)

インストール

python -m venv venv
source venv/bin/activate   # Windows: venv\Scripts\activate
pip install -U discord.py

3. セレクトメニュー複数選択の実装(曜日×5枠)

特徴

  • 1メッセージに月〜金のセレクトを縦に並べる
  • 各曜日は複数選択可(min=0, max=5)
  • 再選択でその曜日の登録を上書き(旧選択はクリア)
  • 同じメッセージのEmbedを編集して表を更新
  • JSON保存+Persistent Viewで再起動OK

以下を bot.py として保存して実行する。

import json, asyncio, datetime as dt
import discord
from discord.ext import commands

# ===== 基本設定 =====
intents = discord.Intents.default()
bot = commands.Bot(command_prefix="!", intents=intents)

WEEKDAYS = ["月", "火", "水", "木", "金"]
SLOTS = ["16半~17時", "17~18時", "18~19時", "19~20時", "20~21時"]

DATA_PATH = "weekly_poll.json"
LOCKS = {}
def lock_of(key):
    if key not in LOCKS:
        LOCKS[key] = asyncio.Lock()
    return LOCKS[key]

# データ構造:
# store[key]["title"] = タイトル
# store[key]["data"][weekday][slot] = [user_id, ...]
store = {}  # key = "guild:channel:message"

def make_key(gid, cid, mid):
    return f"{gid}:{cid}:{mid}"

def load_store():
    global store
    try:
        with open(DATA_PATH, "r", encoding="utf-8") as f:
            store = json.load(f)
    except FileNotFoundError:
        store = {}

def save_store():
    with open(DATA_PATH, "w", encoding="utf-8") as f:
        json.dump(store, f, ensure_ascii=False, indent=2)

def fresh_weekly_data():
    return {w: {s: [] for s in SLOTS} for w in WEEKDAYS}

def render_embed(title, weekly_data):
    # 表表示(コードブロックで整える)
    lines = []
    for w in WEEKDAYS:
        lines.append(f"■ {w}")
        for s in SLOTS:
            users = weekly_data[w][s]
            names = ", ".join(f"<@{u}>" for u in users) if users else "—"
            lines.append(f"  {s}: {names}")
    emb = discord.Embed(
        title=f"来週の活動参加アンケート:{title}",
        description="各曜日で参加可能な時間帯を選択(複数選択可)。再選択でその曜日の登録を上書き。",
        timestamp=dt.datetime.utcnow(),
    )
    emb.add_field(name="集計表", value="```\n" + "\n".join(lines) + "\n```", inline=False)
    return emb

# ===== セレクト(曜日ごと) =====
class DaySelect(discord.ui.Select):
    # Persistent View 用に曜日ごとに固定 custom_id
    def __init__(self, weekday: str):
        self.weekday = weekday
        options = [discord.SelectOption(label=slot, value=slot) for slot in SLOTS]
        super().__init__(
            placeholder=f"{weekday} の参加枠を選択(複数可)",
            options=options,
            min_values=0,               # 0で「全て外す(クリア)」可能
            max_values=len(options),
            custom_id=f"select::{weekday}"
        )

    async def callback(self, interaction: discord.Interaction):
        gid, cid, mid = interaction.guild.id, interaction.channel.id, interaction.message.id
        key = make_key(gid, cid, mid)
        lk = lock_of(key)
        async with lk:
            if key not in store:
                await interaction.response.send_message("このアンケートは見つからない。", ephemeral=True)
                return

            poll = store[key]
            data = poll["data"]
            w = self.weekday
            uid = interaction.user.id

            # この曜日の旧登録を全消し(上書き方式)
            for s in SLOTS:
                if uid in data[w][s]:
                    data[w][s].remove(uid)

            # 今回選択を登録
            for s in self.values:
                if uid not in data[w][s]:
                    data[w][s].append(uid)

            # メッセージ更新
            emb = render_embed(poll["title"], data)
            await interaction.message.edit(embed=emb, view=self.view)
            await interaction.response.send_message(f"{w} の登録を更新した。", ephemeral=True)
            save_store()

# ===== View(曜日5本を縦積み) =====
class WeeklyView(discord.ui.View):
    def __init__(self):
        super().__init__(timeout=None)  # Persistent
        for w in WEEKDAYS:
            self.add_item(DaySelect(w))

# ===== コマンド:起票 =====
@bot.command()
@commands.has_permissions(manage_messages=True)
async def 起票_来週(ctx, *, タイトル: str = "月~金"):
    weekly = fresh_weekly_data()
    emb = render_embed(タイトル, weekly)
    msg = await ctx.send(embed=emb, view=WeeklyView())

    key = make_key(ctx.guild.id, ctx.channel.id, msg.id)
    store[key] = {"title": タイトル, "data": weekly}
    save_store()

    await ctx.reply(f"起票した。メッセージID: `{msg.id}`")

# ===== コマンド:リセット(任意) =====
@bot.command()
@commands.has_permissions(manage_messages=True)
async def リセット(ctx, message_id: int):
    key = make_key(ctx.guild.id, ctx.channel.id, message_id)
    if key not in store:
        await ctx.reply("対象が見つからない。")
        return
    store[key]["data"] = fresh_weekly_data()
    ch = ctx.channel
    msg = await ch.fetch_message(message_id)
    await msg.edit(embed=render_embed(store[key]["title"], store[key]["data"]), view=WeeklyView())
    save_store()
    await ctx.reply("リセットした。")

# ===== 起動時処理(Persistent View 再登録) =====
@bot.event
async def on_ready():
    load_store()
    bot.add_view(WeeklyView())
    print(f"Logged in as {bot.user}")

bot.run("YOUR_TOKEN")

使い方

  1. Bot起動
  2. 任意のチャンネルで !起票_来週
  3. **「来週の活動参加アンケート」**メッセージが出て、月~金のセレクトが並ぶ
  4. メンバーが各曜日で複数枠を選ぶ同じメッセージの表が即更新
  5. 取り消し・選び直しは同曜日で再選択(上書き)
  6. 必要なら !リセット <message_id> で初期化

4. ボタン方式の実装(時間帯ボタン+表更新)

特徴

  • 直感的に押しやすい
  • 同じボタンをもう一度押すとトグルで取り消し
  • 「曜日×ボタン」の数が多い場合はメッセージを曜日で分ける(最大25コンポーネント制限があるため)

最小例(時間帯4つのボタン、1メッセージ更新・トグル・JSON保存)は以下:

import json, asyncio, datetime as dt
import discord
from discord.ext import commands

intents = discord.Intents.default()
bot = commands.Bot(command_prefix='!', intents=intents)

TIME_SLOTS = ["9-12時", "12-15時", "15-18時", "18-21時"]
DATA_PATH = "attendance.json"
responses = {slot: [] for slot in TIME_SLOTS}
lock = asyncio.Lock()

def render_embed(title):
    lines = ["| 時間帯 | 参加者 |", "|---|---|"]
    for slot in TIME_SLOTS:
        names = ", ".join(f"<@{u}>" for u in responses[slot]) or "—"
        lines.append(f"| {slot} | {names} |")
    emb = discord.Embed(title=f"出席集計:{title}",
                        description="同じボタンで登録/取消(トグル)",
                        timestamp=dt.datetime.utcnow())
    emb.add_field(name="現在の表", value="```\n" + "\n".join(lines) + "\n```", inline=False)
    return emb

def save():
    with open(DATA_PATH, "w", encoding="utf-8") as f:
        json.dump(responses, f, ensure_ascii=False, indent=2)

def load():
    global responses
    try:
        with open(DATA_PATH, "r", encoding="utf-8") as f:
            responses = json.load(f)
    except FileNotFoundError:
        responses = {slot: [] for slot in TIME_SLOTS}

class AttendanceView(discord.ui.View):
    def __init__(self):
        super().__init__(timeout=None)

    async def _handle(self, interaction: discord.Interaction, slot: str):
        async with lock:
            uid = interaction.user.id
            if uid in responses[slot]:
                responses[slot].remove(uid)
                action = "取消"
            else:
                responses[slot].append(uid)
                action = "登録"
            await interaction.message.edit(embed=render_embed("活動予定"), view=self)
            await interaction.response.send_message(f"{interaction.user.mention} を **{slot}** に{action}した。", ephemeral=True)
            save()

    @discord.ui.button(label="9-12時", style=discord.ButtonStyle.primary, custom_id="slot::9-12")
    async def s1(self, itx: discord.Interaction, _: discord.ui.Button):
        await self._handle(itx, "9-12時")

    @discord.ui.button(label="12-15時", style=discord.ButtonStyle.primary, custom_id="slot::12-15")
    async def s2(self, itx: discord.Interaction, _: discord.ui.Button):
        await self._handle(itx, "12-15時")

    @discord.ui.button(label="15-18時", style=discord.ButtonStyle.primary, custom_id="slot::15-18")
    async def s3(self, itx: discord.Interaction, _: discord.ui.Button):
        await self._handle(itx, "15-18時")

    @discord.ui.button(label="18-21時", style=discord.ButtonStyle.primary, custom_id="slot::18-21")
    async def s4(self, itx: discord.Interaction, _: discord.ui.Button):
        await self._handle(itx, "18-21時")

@bot.command()
@commands.has_permissions(manage_messages=True)
async def 出席(ctx, *, タイトル="活動予定"):
    emb = render_embed(タイトル)
    await ctx.send(embed=emb, view=AttendanceView())

@bot.event
async def on_ready():
    load()
    bot.add_view(AttendanceView())
    print(f"Logged in as {bot.user}")

bot.run("YOUR_TOKEN")

5. 運用設計のポイント

  • 1メッセージ運用
    起票時に出したメッセージだけ見ればOK。以降は常にそのメッセージを編集して最新状態に保つ。
  • Persistent View
    timeout=Nonecustom_id 固定 + 起動時に bot.add_view(ViewClass())
    再起動してもコンポーネントが生きる。
  • 同時押し対策
    asyncio.Lock で編集競合を避ける。
  • 保存
    まずはJSONで十分。規模が大きくなればSQLite/PostgreSQLへ移行。
  • 分割
    コンポーネントは1メッセージ最大25個。曜日や時間帯が多い場合はメッセージを分けるかセレクト方式にする。

6. よくある拡張

  • 締切&ロック
    締切時に view.disable_all_items()message.edit(view=view) で操作不可にして確定版を残す。
    例:cron/Bot内スケジューラで締切時刻に実行。
  • ロール制限
    押下時に if not role in interaction.user.roles: return のチェックを入れる。
  • DM確認
    その人の現登録をDMで返信(ミス減、視認性向上)。
  • CSV/Sheets出力
    /export コマンドでCSV添付、もしくはGoogle Sheets API連携して自動反映。
  • 自由入力→丸め
    13:30–16:10 のような自由時間をモーダルで入力→枠に丸めて登録する設計も可能。

7. デプロイ(簡易)

systemd(Ubuntu)

/etc/systemd/system/discord-bot.service

[Unit]
Description=Discord Attendance Bot
After=network.target

[Service]
User=ubuntu
WorkingDirectory=/home/ubuntu/attendance-bot
Environment="PYTHONUNBUFFERED=1"
ExecStart=/home/ubuntu/attendance-bot/venv/bin/python bot.py
Restart=always

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

8. トラブルシューティング

  • ボタン/セレクトが表示されない
    discord.py が v2.x か、Botに「メッセージの送信」「アプリを使用」権限があるか確認。
  • 押しても反応がない
    Persistent View の custom_id が固定か、on_readyadd_view を呼んでいるか確認。
  • 編集に失敗
    編集対象はBotが送ったメッセージである必要がある。別ユーザーの投稿は編集できない。
  • 同時押しで表が乱れる
    asyncio.Lock を曜日単位/メッセージ単位で導入する(本稿の例は実装済み)。
  • 25項目制限に当たる
    メニュー分割 or 2段階UI or メッセージ分割。

9. どちらを使うべきか

  • セレクト方式(本稿の主構成)
    曜日×複数枠を1操作でまとめて登録したい/選択肢が多い。
  • ボタン方式
    直観的操作を重視、選択肢が少ない、トグル操作を多用。

10. まとめ

  • リアクション集計は運用限界
  • ボタン/セレクト+自動集計で、1つのメッセージを編集更新し続ける設計に切り替える。
  • Persistent View+JSON保存で安定運用。
  • 締切・ロール制限・エクスポートなどは容易に拡張できる。

必要なら、あなたの既存Bot(「参加アンケート」Bot)のコード構造に合わせて差分パッチを作る。

  • コマンド名(例:/起票 来週
  • 締切日時/ロール制限
  • CSV/Sheets連携の要否
    を教えてくれれば、その前提で完成版を書き下ろす。
あなた:

ここまでの内容を詳細に説明した記事を作って

ChatGPT:

承知しました。以下は、これまで解説した「Discord参加アンケートBot(ボタン・セレクト複数選択対応)」について、
導入背景 → 技術概要 → コード構成 → 実装手順 → 運用設計 → 拡張案
までを体系的にまとめた詳細解説記事です。
大学プロジェクトやサークル運営に導入する想定で、技術的背景も含めてわかりやすく整理しています。


Discord参加アンケートBot完全解説

― リアクション集計の限界を超えるスマートな出席確認システム ―

概要

本記事では、Discordでの出席確認・活動アンケートを効率化するためのBot構築方法を詳しく解説する。
従来の「リアクション集計」では、リアクション数が膨大になるほど、誰がどの日・時間に参加するかを把握するのが困難になる。

例えば、

時間帯5つ × 平日5日 × 班3つ = 75個のリアクション確認

このような運用は、数人規模でもすぐに限界を迎える。
そこで本記事では、リアクション方式を廃止し、ボタンやセレクトメニューで即時集計を行うシステムを構築する。


第1章:リアクション方式の課題

1. 操作負担

リアクション方式では、

  • 各リアクションを押した人を都度タップして確認する必要がある。
  • 確認対象が50件を超えると、もはや管理が追いつかない。

2. 視認性の低下

複数リアクションが並ぶと、

  • 「誰がどの時間に来るか」が一覧で見られない。
  • 出欠データが散らばるため、表形式にまとめる手間が発生。

3. 集計ミスの発生

  • リアクションの追加・削除を手動で行うため、反映漏れや二重登録が起こりやすい。

これらの課題を解消するためには、「リアクション」から「インタラクティブコンポーネント(ボタン/セレクトメニュー)」への移行が最適である。


第2章:新方式の概要 ― ボタン/セレクトによる即時集計

1. 技術要素

新方式では、DiscordのメッセージコンポーネントAPIを活用する。
discord.py v2.x以降では、以下の機能を使用できる:

機能説明
discord.ui.View複数のUI要素(ボタン・セレクトなど)をまとめる
discord.ui.Buttonボタンを生成し、押下イベントを処理
discord.ui.Selectドロップダウンメニュー(複数選択可)
interaction.responseボタン・メニュー押下時の応答処理
Persistent View再起動後もボタン・メニューを保持

第3章:構築環境

必要な条件

  • Python 3.10以上
  • discord.py v2.x
  • Discord Botの登録済み(Developer Portal
  • Botの権限:「メッセージ送信」「アプリを使用」

インストール手順

python -m venv venv
source venv/bin/activate  # Windows: venv\Scripts\activate
pip install -U discord.py

第4章:基本構成とデータモデル

構成概要

ファイル役割
bot.pyDiscord Botのメインコード
weekly_poll.jsonアンケート結果の永続保存(JSON形式)

データモデル構造

{
  "guild_id:channel_id:message_id": {
    "title": "月~金",
    "data": {
      "月": {
        "16半~17時": [123456789012345678],
        "17~18時": [987654321098765432]
      },
      "火": { ... },
      ...
    }
  }
}

第5章:セレクトメニュー方式の実装

目的

曜日ごとに複数の時間帯を選択できるUIを用意し、
選択結果を即座に同じメッセージ上の表に反映する。


完成コード:bot.py

import json, asyncio, datetime as dt
import discord
from discord.ext import commands

intents = discord.Intents.default()
bot = commands.Bot(command_prefix="!", intents=intents)

WEEKDAYS = ["月", "火", "水", "木", "金"]
SLOTS = ["16半~17時", "17~18時", "18~19時", "19~20時", "20~21時"]

DATA_PATH = "weekly_poll.json"
LOCKS = {}
store = {}

def make_key(g, c, m): return f"{g}:{c}:{m}"
def lock_of(k):
    if k not in LOCKS: LOCKS[k] = asyncio.Lock()
    return LOCKS[k]

def load_store():
    global store
    try:
        with open(DATA_PATH, "r", encoding="utf-8") as f:
            store = json.load(f)
    except FileNotFoundError:
        store = {}

def save_store():
    with open(DATA_PATH, "w", encoding="utf-8") as f:
        json.dump(store, f, ensure_ascii=False, indent=2)

def fresh_data():
    return {w: {s: [] for s in SLOTS} for w in WEEKDAYS}

def render_embed(title, data):
    lines = []
    for w in WEEKDAYS:
        lines.append(f"■ {w}")
        for s in SLOTS:
            users = data[w][s]
            names = ", ".join(f"<@{u}>" for u in users) if users else "—"
            lines.append(f"  {s}: {names}")
    emb = discord.Embed(
        title=f"来週の活動参加アンケート:{title}",
        description="複数選択可。再選択で上書き。",
        timestamp=dt.datetime.utcnow()
    )
    emb.add_field(name="現在の登録状況", value="```\n" + "\n".join(lines) + "\n```", inline=False)
    return emb

# -------------------------
#  セレクトメニュー定義
# -------------------------
class DaySelect(discord.ui.Select):
    def __init__(self, weekday):
        self.weekday = weekday
        options = [discord.SelectOption(label=s, value=s) for s in SLOTS]
        super().__init__(
            placeholder=f"{weekday} の時間帯を選択(複数可)",
            options=options,
            min_values=0,
            max_values=len(options),
            custom_id=f"select::{weekday}"
        )

    async def callback(self, interaction: discord.Interaction):
        key = make_key(interaction.guild.id, interaction.channel.id, interaction.message.id)
        lk = lock_of(key)
        async with lk:
            poll = store[key]
            data = poll["data"]
            w = self.weekday
            uid = interaction.user.id

            # 一旦全削除
            for s in SLOTS:
                if uid in data[w][s]:
                    data[w][s].remove(uid)

            # 再登録
            for s in self.values:
                data[w][s].append(uid)

            emb = render_embed(poll["title"], data)
            await interaction.message.edit(embed=emb, view=self.view)
            await interaction.response.send_message(f"{w} の登録を更新しました。", ephemeral=True)
            save_store()

class WeeklyView(discord.ui.View):
    def __init__(self):
        super().__init__(timeout=None)
        for w in WEEKDAYS:
            self.add_item(DaySelect(w))

@bot.command()
@commands.has_permissions(manage_messages=True)
async def 起票_来週(ctx, *, タイトル="月~金"):
    data = fresh_data()
    emb = render_embed(タイトル, data)
    msg = await ctx.send(embed=emb, view=WeeklyView())
    key = make_key(ctx.guild.id, ctx.channel.id, msg.id)
    store[key] = {"title": タイトル, "data": data}
    save_store()
    await ctx.reply(f"起票しました(メッセージID: `{msg.id}`)")

@bot.event
async def on_ready():
    load_store()
    bot.add_view(WeeklyView())
    print(f"Logged in as {bot.user}")

bot.run("YOUR_TOKEN")

第6章:実行手順

  1. Botを起動 python bot.py
  2. Discordチャンネルで以下を送信 !起票_来週
  3. Botが1つのメッセージを投稿
    • 「月~金」各曜日ごとにセレクトメニューが表示される
    • 各メニューから複数時間帯を選べる
  4. 登録すると即座に表が更新される
    • 登録者のメンションが表示され、一覧で確認可能

第7章:データの保持と再起動対応

  • 選択結果は weekly_poll.json に自動保存される
  • Bot再起動後も bot.add_view(WeeklyView()) により、
    セレクトメニューが再び有効化される(Persistent View)

第8章:ボタン方式(別案)

セレクトより操作が直感的なボタン方式も選択可能。

  • 各ボタンが時間帯を表す
  • 押すとトグル(登録/取り消し)
  • 押した瞬間に同じメッセージを上書き更新

(コードは前章参照。構成は同一)


第9章:運用設計と拡張

1. 管理者向けコマンド

  • !起票_来週:アンケート作成
  • !リセット <message_id>:集計リセット

2. 拡張例

拡張項目概要
締切自動ロックview.disable_all_items() で操作を停止
CSV出力!export コマンドで weekly_poll.csv を出力
Google Sheets連携Discord API + GASまたはPython APIで双方向同期
DM確認自分の登録内容をDMで通知
週次自動作成cron or discord.ext.tasks で毎週月曜に自動起票

第10章:システム運用のベストプラクティス

  • 1メッセージ集中型設計
    → 「このメッセージだけを見れば全員の予定がわかる」
  • Embed更新方式
    → 表の見やすさと再利用性を両立
  • ロック制御
    asyncio.Lockで同時編集を防止
  • 永続保存
    → JSONで十分、必要に応じてSQLiteへ拡張
  • 25コンポーネント制限
    → メッセージを曜日単位で分割することで回避可能

第11章:デプロイ(Ubuntu例)

  1. サービス登録
    /etc/systemd/system/discord-bot.service [Unit] Description=Discord Attendance Bot After=network.target [Service] User=ubuntu WorkingDirectory=/home/ubuntu/attendance-bot ExecStart=/home/ubuntu/attendance-bot/venv/bin/python bot.py Restart=always [Install] WantedBy=multi-user.target
  2. 有効化 sudo systemctl daemon-reload sudo systemctl enable --now discord-bot
  3. ログ確認 journalctl -u discord-bot -f

第12章:運用結果と利点

  • リアクション方式の75回確認作業がゼロに
  • 即時反映・1メッセージ完結により、誰がいつ来るかを瞬時に把握
  • 月~金×5時間帯でも一画面で整理
  • 再起動に強く、改修が容易
  • チーム運営・研究室・部活・プロジェクト管理にそのまま応用可能

第13章:まとめ

項目内容
目的Discordで出席・参加情報を効率的に管理する
方式ボタン/セレクトメニュー方式
利点即時集計、視認性向上、再起動耐性、永続保存
推奨構成セレクト(曜日×複数枠)、1メッセージ運用
主な拡張CSV出力、締切ロック、DM通知、自動起票

🎯 最後に

本システムを導入することで、
これまで煩雑だった出席確認作業を完全自動化かつ視覚的に最適化できる。

リアクションではなく「選ぶだけで表が変わる」仕組みは、
学生プロジェクト・研究室・企業のチーム運営など、
あらゆる場面で応用できる。

✅ コミュニケーションコストを下げる最も手軽な方法は、「見える化」と「自動化」である。

コメント

タイトルとURLをコピーしました