【Python】自己紹介からExcelデータベースを作成する

概要

MinecraftサーバーのDiscordコミュニティにおいて、新規参加者の自己紹介プロセスを自動化し、データの蓄積と管理を効率化するために開発したBotです。 スラッシュコマンド(/intro)を通じて入力された情報をEmbed(埋め込み形式)で美しく表示すると同時に、Excelデータベースへの自動保存、ロール付与、ニックネーム変更をワンストップで行います。

使用技術

  • Language: Python 3.10
  • Library: discord.py (Nextcord/Pycord互換)
  • Database: Excel (openpyxl)
  • Environment: python-dotenv
  • Logging: エラー監視

機能

  • インタラクティブな自己紹介生成
    • /intro コマンドにより、Minecraft ID、プレイ機種、ログイン頻度などを選択・入力。
    • 入力完了後、Botが色鮮やかなEmbedメッセージを自動生成して専用チャンネルへ投稿。
  • 自動ロール付与・ネーム管理
    • 自己紹介完了時に、サーバーへの参加ロールおよびプレイ機種に応じたロールを自動付与。
    • Discordのニックネームを [ゲーム内ニックネーム] / [MCID] 形式に自動変更し、本人確認の手間を削減。
  • Excelデータベース連携
    • すべての回答情報をExcelファイルに蓄積。
    • 新規・更新(リピート投稿)を自動で判定し、フラグ管理。
  • 高度な検索機能
    • /id コマンドで、過去に投稿された自己紹介をMCIDやニックネームから即座に検索可能。
    • 複数ヒットした場合は、ドロップダウンメニュー(Select Menu)による絞り込み表示に対応。
  • チャンネルの秩序維持
    • 自己紹介チャンネルでの雑談や不要なメッセージを自動削除。
    • 常に最新の「使い方案内」を一番下に表示させるための自動再投稿機能を搭載。

設計要点

  1. UX(ユーザー体験)の向上
    • ユーザーごとに固有のカラーコードを生成(random.seedを使用)し、個々のEmbedにパーソナライズされた外観を与えています。
    • 検索結果が複数ある場合、Discord標準のセレクトメニューを採用することで、直感的で迷わないUIを実現しました。
  2. セキュリティと保守性
    • APIトークンやサーバー固有のID(Role ID等)をコードにハードコードせず、.envファイルで管理。外部公開時や環境移行時の安全性を確保しています。
    • Minecraft IDのバリデーションに正規表現(Regex)を導入し、不正な入力によるエラーを未然に防いでいます。
  3. コミュニティ運営の自動化
    • 運営者が手動で行っていた「ロール付与」や「名前の変更」を自動化したことで、24時間365日、即時の参加承認フローを構築しました。

実際の運用

Discord画面上での運用

Excelファイル上での運用

実装コード(例)

import discord
from discord import app_commands
from discord.ext import commands
import os
import openpyxl
from datetime import datetime
from dotenv import load_dotenv
import random
import re
import logging

# ログ設定
log_path = r"C:\\Project-Sea\\SeaAi_logs\\Intro.log"
os.makedirs(os.path.dirname(log_path), exist_ok=True)
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[
        logging.FileHandler(log_path, encoding="utf-8"),
        logging.StreamHandler()
    ]
)

load_dotenv()
TOKEN = os.getenv("DISCORD_BOT_TOKEN")

# サーバー情報
GUILD_ID = 1361774231565631659
CHANNEL_ID = 1361780612490330162
ROLE_MAIN = 1361777328476393564
ROLES = {
    "JAVA": 1382167841020706856,
    "PC統合版": 1382167940086108222,
    "スマホ・タブレット": 1383112388286349373,
    "Switch・PS5": 1383117098687008819,
    "その他": 1383117265477566484
}

# Excelファイルパス
EXCEL_FILE = r"D:\\SeaAi\\DB\\introductions.xlsx"
BADWORD_FILE = r"D:\\SeaAi\\DB\\discord_badword.xlsx"
HEADERS = ["Discord 表示名", "Discord ユーザー名", "Minecraft ID", "ニックネーム", "プレイ機種", "ログイン頻度", "一言", "送信日時", "更新"]

intents = discord.Intents.default()
intents.message_content = True
intents.guilds = True
intents.members = True
bot = commands.Bot(command_prefix="!", intents=intents)

def is_valid_minecraft_id(mcid):
    return bool(re.fullmatch(r"[A-Za-z0-9_]{3,16}", mcid))

def get_random_color(user_id):
    random.seed(user_id)
    return discord.Color.from_rgb(random.randint(50, 200), random.randint(50, 200), random.randint(50, 200))

def init_excel():
    if not os.path.exists(EXCEL_FILE):
        os.makedirs(os.path.dirname(EXCEL_FILE), exist_ok=True)
        wb = openpyxl.Workbook()
        ws = wb.active
        ws.append(HEADERS)
        wb.save(EXCEL_FILE)

def load_introductions():
    init_excel()
    wb = openpyxl.load_workbook(EXCEL_FILE)
    ws = wb.active
    return [row for row in ws.iter_rows(min_row=2, values_only=True)]

def search_matches(query):
    rows = load_introductions()
    matches = []
    seen_values = set()
    query = query.lower().strip()
    for row in rows:
        key = (row[1], row[2], row[3])
        if key in seen_values:
            continue
        if any(query in str(col).lower().strip() for col in key):
            matches.append(row)
            seen_values.add(key)
    return matches

def load_badword_counts(discord_id):
    if not os.path.exists(BADWORD_FILE):
        return {}

    wb = openpyxl.load_workbook(BADWORD_FILE)
    ws = wb.active
    counts = {}
    for row in ws.iter_rows(min_row=2, values_only=True):
        row_id = str(row[1]) if row[1] else None
        category = str(row[6]) if len(row) > 6 else None
        if row_id == discord_id and category:
            counts[category] = counts.get(category, 0) + 1
    return counts

def format_badword_counts(counts):
    if not counts:
        return ""
    bad = counts.get("BAD", 0)
    mild = counts.get("MILD", 0)
    return f"{bad}/{mild}"

def create_embed(data, user_id, from_search=False, discord_user_id=None):
    title_tag = "【ID検索】" if from_search else ("【更新】" if data[8] == "はい" else "")
    embed = discord.Embed(
        title=f"📝 {data[3]} / {data[2]} {title_tag}",
        color=get_random_color(user_id)
    )
    desc = (
        f"**🎮 Minecraft ID**\n`{data[2]}`\n\n"
        f"**🙋 ニックネーム**\n`{data[3]}`\n\n"
        f"**🕹️ プレイ機種**\n`{data[4]}`\n\n"
        f"**⏰ ログイン頻度**\n`{data[5]}`\n\n"
        f"**💬 一言**\n{data[6]}"
    )
    if discord_user_id:
        desc = f"<@{discord_user_id}>\n\n" + desc

    embed.description = desc

    footer_text = f"{data[0]} ・ {data[7]}【更新: {data[8]}】"

    if from_search:
        discord_user_tag = data[1]
        counts = load_badword_counts(discord_user_tag)
        count_str = format_badword_counts(counts)
        if count_str:
            footer_text += f" | {count_str}"

    embed.set_footer(text=footer_text)
    return embed

@bot.event
async def on_ready():
    await bot.tree.sync()
    await bot.change_presence(activity=discord.Game(name="PROJECT SEA"))
    logging.info(f"✅ Bot起動完了: {bot.user}")
    channel = bot.get_channel(CHANNEL_ID)
    if channel:
        async for msg in channel.history(limit=100):
            if msg.author == bot.user and msg.embeds and "自己紹介のやり方" in msg.embeds[0].title:
                return
        embed = discord.Embed(title="📘 自己紹介のやり方", description="`/intro` コマンドを使ってください。", color=discord.Color.blue())
        await channel.send(embed=embed)

@bot.event
async def on_message(message):
    # 自分自身のメッセージは無視
    if message.author == bot.user:
        return

    # 指定のチャンネル以外では何もしない
    if message.channel.id != CHANNEL_ID:
        return

    # embedが含まれるメッセージは許可
    if message.embeds:
        return

    # embedでないメッセージは削除し、警告を送信
    try:
        await message.delete()
        await message.channel.send(
            f"{message.author.mention} このチャンネルでは `/intro` コマンドのみが使用可能です。",
            delete_after=10
        )
    except Exception as e:
        logging.warning(f"メッセージ削除に失敗: {e}")

@bot.tree.command(name="intro", description="自己紹介を送信します")
@app_commands.describe(minecraft_id="MinecraftのID", nickname="ニックネーム", platform="プレイ機種", frequency="ログイン頻度", comment="一言")
@app_commands.choices(
    platform=[
        app_commands.Choice(name="JAVA", value="JAVA"),
        app_commands.Choice(name="PC統合版", value="PC統合版"),
        app_commands.Choice(name="スマホ・タブレット", value="スマホ・タブレット"),
        app_commands.Choice(name="Switch・PS5", value="Switch・PS5"),
        app_commands.Choice(name="その他", value="その他")
    ],
    frequency=[
        app_commands.Choice(name="毎日", value="毎日"),
        app_commands.Choice(name="高頻度", value="高頻度"),
        app_commands.Choice(name="そこそこ", value="そこそこ"),
        app_commands.Choice(name="不定期", value="不定期"),
        app_commands.Choice(name="未定", value="未定")
    ]
)
async def intro(interaction: discord.Interaction, minecraft_id: str, nickname: str, platform: app_commands.Choice[str], frequency: app_commands.Choice[str], comment: str):
    await interaction.response.defer(thinking=True, ephemeral=True)
    if not is_valid_minecraft_id(minecraft_id):
        await interaction.followup.send("❌ 無効なMinecraft IDです。", ephemeral=True)
        return

    timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    display_name = interaction.user.display_name
    user_tag = f"{interaction.user.name}#{interaction.user.discriminator}"

    # Excel初期化
    init_excel()
    wb = openpyxl.load_workbook(EXCEL_FILE)
    ws = wb.active

    # 既存か判定
    rows = list(ws.iter_rows(min_row=2, values_only=True))
    is_update = False

    for row in rows:
        if row[1] == user_tag:
            is_update = True
            break

    # 常に新規行に追加
    ws.append([display_name, user_tag, minecraft_id, nickname, platform.value, frequency.value, comment, timestamp, "はい" if is_update else "いいえ"])
    wb.save(EXCEL_FILE)

    embed = create_embed(
        [display_name, user_tag, minecraft_id, nickname, platform.value, frequency.value, comment, timestamp, "はい" if is_update else "いいえ"],
        interaction.user.id
    )
    channel = bot.get_channel(CHANNEL_ID)
    if channel:
        async for msg in channel.history(limit=100):
            if msg.author == bot.user and msg.embeds and "自己紹介のやり方" in msg.embeds[0].title:
                await msg.delete()
        await channel.send(f"{interaction.user.mention} さんが自己紹介しました!", embed=embed)
        await channel.send(embed=discord.Embed(title="📘 自己紹介のやり方", description="`/intro` コマンドを使ってください。", color=discord.Color.blue()))

    try:
        role = interaction.guild.get_role(ROLE_MAIN)
        if role:
            await interaction.user.add_roles(role)
        if platform.value in ROLES:
            platform_role = interaction.guild.get_role(ROLES[platform.value])
            if platform_role:
                await interaction.user.add_roles(platform_role)
        await interaction.user.edit(nick=f"{nickname} / {minecraft_id}")
    except Exception as e:
        logging.warning(f"ロールまたはニックネーム設定失敗: {e}")

    await interaction.followup.send("✅ 自己紹介ありがとうございます!", ephemeral=True)

@bot.tree.command(name="id", description="IDまたはMCIDで自己紹介を検索")
@app_commands.describe(query="検索語(Minecraft ID、ニックネーム、ユーザー名)")
async def id(interaction: discord.Interaction, query: str):
    matches = search_matches(query)
    if not matches:
        await interaction.response.send_message("❌ 該当する自己紹介は見つかりませんでした。", ephemeral=True)
        return

    if len(matches) == 1:
        user_tag = matches[0][1]
        member = discord.utils.get(interaction.guild.members, name=user_tag.split("#")[0])
        discord_user_id = member.id if member else None

        embed = create_embed(matches[0], interaction.user.id, from_search=True, discord_user_id=discord_user_id)
        await interaction.response.send_message(embed=embed, ephemeral=True)
    else:
        options = []
        used_values = set()
        for i, row in enumerate(matches):
            label = f"{row[3]} / {row[2]}"
            value = f"opt_{i}"
            if value in used_values:
                continue
            desc = f"Discord: {row[1]}"
            options.append(discord.SelectOption(label=label, description=desc, value=value))
            used_values.add(value)

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

            @discord.ui.select(placeholder="表示する自己紹介を選択", options=options)
            async def select_callback(self, select, select_interaction):
                index = int(select.values[0].split("_")[1])
                user_tag = matches[index][1]
                member = discord.utils.get(select_interaction.guild.members, name=user_tag.split("#")[0])
                discord_user_id = member.id if member else None

                embed = create_embed(matches[index], interaction.user.id, from_search=True, discord_user_id=discord_user_id)
                await select_interaction.response.send_message(embed=embed, ephemeral=True)

        await interaction.response.send_message("🔍 複数候補が見つかりました。選択してください:", view=SelectView(), ephemeral=True)

bot.run(TOKEN)

著者

Back to top