【Python】Ubuntuを用いたDiscord bot開発【NL①】

概要

今回、Ubuntu OSを搭載したVPSにおいて、24時間365日、高レスポンスで動作するDiscord Botを構築します。 特定のゲームサーバー内の経済データ(価格相場)をCSVから高速検索する機能と、サーバーの稼働状況をリアルタイムでBotのステータスに反映する機能を統合しました。

規約により詳細が公開できないので、ふんわりとした開発内容だけ記載していきます。

開発環境

  • OS: Ubuntu Server 24.04 (LTS)
  • Language: Python 3.8.10
  • Library: discord.py, aiohttp, python-dotenv
  • Database: CSV-based Lightweight DB
  • Infra: Memory 512MB VPS
  • SSH Cliant: Termius

実装方法

Python環境の構築

システム全体のライブラリ汚染を防ぎつつ、軽量な実行環境を作るため venv を使用しました。

# 依存パッケージのインストール
sudo apt update
sudo apt install -y python3-venv python3-pip

# 仮想環境の作成とライブラリインストール
python3 -m venv venv
source venv/bin/activate
pip install discord.py aiohttp python-dotenv

systemdによるサービス永続化

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

[Service]
Type=simple
WorkingDirectory=/home/user/my-bot
ExecStart=/home/user/my-bot/venv/bin/python3 bot.py
Restart=always

[Install]
WantedBy=multi-user.target

外部公開用に軽量化したPythonプログラム

import os
import csv
import unicodedata
import asyncio
import aiohttp

import discord
from discord import app_commands
from dotenv import load_dotenv

# 環境設定の読み込み
load_dotenv(dotenv_path=os.path.join(os.path.dirname(__file__), ".env"), override=True)

# ─── 設定項目(環境変数から取得) ───
TOKEN = os.getenv("DISCORD_TOKEN")
CSV_PATH = os.getenv("CSV_PATH")
GUILD_ID = int(os.getenv("GUILD_ID")) if os.getenv("GUILD_ID") else None

# ブログ掲載用にURLや名前を定数化
SERVICE_NAME = "アイテム価格検索"
LATEST_INFO_URL = "https://example.com/price-list"
GAME_SERVER_ADDR = "mc.example.com"
MC_API_URL = f"https://api.mcsrvstat.us/2/{GAME_SERVER_ADDR}"

# 表示用の装飾
SEP = "────────────────────"

def normalize(text: str) -> str:
    """テキストを検索用に正規化(半角・小文字化)"""
    if not text:
        return ""
    return unicodedata.normalize("NFKC", text).lower()

def load_data_from_csv():
    """CSVからデータをパースしてリストで返す"""
    rows = []
    current_category = ""

    with open(CSV_PATH, newline="", encoding="utf-8") as f:
        reader = csv.reader(f)
        for row in reader:
            if len(row) < 4:
                continue

            # カラムの定義(用途に合わせて調整)
            name, detail_a, detail_b, note = [s.strip() for s in row[:4]]

            # カテゴリ行の判定ロジック(例:名前が空で詳細がある場合)
            if not name and detail_a:
                current_category = detail_a
                continue

            if not name:
                continue

            rows.append({
                "name": name,
                "detail_a": detail_a or "—",
                "detail_b": detail_b or "—",
                "category": current_category or "一般",
                "note": note
            })
    return rows

async def fetch_server_status(session: aiohttp.ClientSession):
    """外部APIからサーバーのオンライン人数を取得"""

# 内部処理により略


class MyBot(discord.Client):
    def __init__(self):
        intents = discord.Intents.default()
        super().__init__(intents=intents)
        self.tree = app_commands.CommandTree(self)

    async def setup_hook(self):
        # スラッシュコマンドの同期
        if GUILD_ID:
            await self.tree.sync(guild=discord.Object(id=GUILD_ID))
        else:
            await self.tree.sync()
        
        # バックグラウンドタスクの開始
        self.loop.create_task(self.update_presence_loop())

    async def update_presence_loop(self):
        """30秒ごとにステータス(ボットの活動)を更新"""

# 内部処理により略

bot = MyBot()

@bot.tree.command(name="search", description="データベースから情報を検索します")
@app_commands.describe(query="検索キーワード")
async def search(interaction: discord.Interaction, query: str):
    await interaction.response.defer(thinking=True)

    query_n = normalize(query)
    data = load_data_from_csv()

    # 全フィールドを結合してあいまい検索
    results = [
        r for r in data 
        if query_n in normalize(" ".join(r.values()))
    ]

    if not results:
        embed = discord.Embed(
            title="結果が見つかりませんでした",
            description=f"最新のリストは以下を確認してください:\n{LATEST_INFO_URL}",
            color=discord.Color.red()
        )
        await interaction.followup.send(embed=embed)
        return

    # 検索結果の構築

# 内部処理により略


if __name__ == "__main__":
    if not TOKEN or not CSV_PATH:
        print("Error: 環境変数が設定されていません。")
    else:
        bot.run(TOKEN)

実行結果

本実装により、以下の成果を得られました。

  • リアルタイム監視: 30秒周期でのサーバー監視を行い、Botの活動ステータスへ常に最新のプレイヤー数を反映。
  • 高速検索: 膨大なアイテムデータの中から、スラッシュコマンド入力後3秒以内に関連情報を抽出・Embed表示。
実際の実装

まとめ

今回は、「ソフトウェアの設計(非同期処理・軽量DB)」と「OS層のチューニング(Swap・systemd)」でを行いました。スペックに依存せず、安定性と利便性を両立させるアプローチは、商用環境におけるコスト最適化にも応用可能な技術スタックです。

Back to top