概要
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)による絞り込み表示に対応。
- チャンネルの秩序維持
- 自己紹介チャンネルでの雑談や不要なメッセージを自動削除。
- 常に最新の「使い方案内」を一番下に表示させるための自動再投稿機能を搭載。
設計要点
- UX(ユーザー体験)の向上
- ユーザーごとに固有のカラーコードを生成(
random.seedを使用)し、個々のEmbedにパーソナライズされた外観を与えています。 - 検索結果が複数ある場合、Discord標準のセレクトメニューを採用することで、直感的で迷わないUIを実現しました。
- ユーザーごとに固有のカラーコードを生成(
- セキュリティと保守性
- APIトークンやサーバー固有のID(Role ID等)をコードにハードコードせず、
.envファイルで管理。外部公開時や環境移行時の安全性を確保しています。 - Minecraft IDのバリデーションに正規表現(Regex)を導入し、不正な入力によるエラーを未然に防いでいます。
- APIトークンやサーバー固有のID(Role ID等)をコードにハードコードせず、
- コミュニティ運営の自動化
- 運営者が手動で行っていた「ロール付与」や「名前の変更」を自動化したことで、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)


