discord.pyとSQLAlchemyでbotにレベリング機能を実装する

# はじめに

Discordのbotにレベリング(ユーザーにレベルを付与する仕組み)を実装したいという需要が結構あるようだったので書いてみました。

この記事では簡単にレベリングと関連するコマンドの実装をするための方法を解説します。

## この記事の対象者

- ある程度Pythonの知識があり、discord.pyでbotにレベリング機能を実装したい。

## 前提知識

- 基本的なPythonの記法と仕様を理解している
- discord.pyの基本的な使い方を知っている

# 実装

## 必要なライブラリのインストール

今回の実装ではSQLAlchemyとdiscord.pyを用いるのでそれらをpipコマンドでインストールします。
```bash
pip install -U discord.py sqlalchemy
```

## ディレクトリ構造を作る

今回は可読性のためファイルを分けて処理を記述します。
```
.
├── main.py
└── database.py
```

## 基本的な実装

以下のコードが基本的な実装です:

```python: database.py
from sqlalchemy import Column, Integer, create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker

DATABASE_URL = 'sqlite:///users.db'  # データベースの種類と名前をここで指定できます
engine = create_engine(DATABASE_URL) # データベースエンジンを作成
Base = declarative_base()            # データベースの親クラスを作成

# ユーザーデータのテーブルを定義
class User(Base):
    __tablename__ = 'users'
    id = Column(Integer, unique=True)
    level = Column(Integer, default=1)
    experience = Column(Integer, default=0)

# データベースを作成
Base.metadata.create_all(engine)

# データベースにアクセスするためのセッションを作成
Session = sessionmaker(bind=engine)
session = Session()
```

```python: main.py
import discord
from discord.ext import commands

from database import User, session # 上記のdatabase.pyからUserとsessionをインポート

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


@bot.event
async def on_ready():
    await tree.sync() # スラッシュコマンドの同期
    print(f"Logged in as {bot.user.name}.")


@bot.event
async def on_message(message):
    # botのレベルはカウントしない
    if message.author.bot:
        return

    # 既存のユーザーデータから検索
    user = session.query(User).filter_by(id=message.author.id).first()

    # 見つからなければ新たに作成
    if not user:
        user = User(id=message.author.id)
        session.add(user)
        session.commit()

    # レベリングロジックをここに実装(次の項目を参照) #

    session.commit() # データの反映

    await bot.process_commands(message) # メッセージ処理の完了(commands.Bot()を利用したときのみ)

bot.run(TOKEN)
```

## レベリングロジックの実装

ここではレベリングロジックを3通り紹介します。組み合わせるなどして参考にしてください。

### 経験値を固定の値で与え、一定の経験値がたまるごとにレベルを上げるロジック

```python
# on_message内での経験値とレベルの処理部分
EXP_PER_MESSAGE = 10  # メッセージごとに与える経験値
LEVEL_UP_EXP = 100    # レベルアップに必要な経験値

user.experience += EXP_PER_MESSAGE

if user.experience >= LEVEL_UP_EXP:
    user.level += 1
    user.experience = 0
```
このコードでは、1メッセージあたり経験値10を獲得し、100経験値が貯まるとレベルが1上がって経験値が0になります。

### メッセージの長さに応じて経験値を与えるロジック

```python
# on_message内での経験値とレベルの処理部分
LEVEL_UP_EXP = 100     # レベルアップに必要な経験値
EXP_PER_CHARACTER = 1  # 1文字ごとに与える経験値

message_length = len(message.content)
user.experience += int(message_length * EXP_PER_CHARACTER)

if user.experience >= LEVEL_UP_EXP:
    user.level += 1
    user.experience = 0
```

このコードでは、メッセージ1文字あたり1の経験値を獲得し、100経験値が貯まるとレベルが1上がって経験値が0になります。

### レベルごとに必要な経験値を増やすロジック

```python
# on_message内での経験値とレベルの処理部分
LEVEL_UP_EXP = 100    # レベルアップに必要な経験値
LEVEL_UP_RATE = 30    # レベルごとの必要経験値増加量
EXP_PER_MESSAGE = 10  # メッセージごとに与える経験値

user.experience += EXP_PER_MESSAGE
if user.experience >= LEVEL_UP_EXP + user.level * LEVEL_UP_RATE:
    user.level += 1
    user.experience = 0
```
このコードでは、1メッセージあたり経験値10を獲得し、`100+現在のレベル*30` 経験値が貯まるとレベルが1上がって経験値が0になります。


## データベースにアクセスするコマンドの実装

ここからはアプリケーションコマンド(スラッシュコマンド)を使ってデータベースへアクセスするコマンドを実装していきます。
アプリケーションコマンドの実装については[前回の記事](https://qiita.com/vex12853/items/05b7b7389d6f1b9de11a)に詳細を記述しているのでよければ参考にしてください。

### レベルを確認するコマンドの実装

コマンドの任意引数にユーザーを指定して表示するユーザーを選択できるようにします。
```python
@tree.command(name="level", description="ユーザーのレベルを表示します")
async def level(ctx: discord.Interaction, user: discord.Menber = None):
    user = ctx.author if user is None else user
    if user.bot:
        embed = discord.Embed(title='Error', descrpition='botのレベルは存在しません')
        await ctx.response.send_message(embed=embed)
        return
    user = session.query(User).filter_by(id=message.author.id).first()
    if not user:
        user = User(id=user.id)
        session.add(user)
        session.commit()
    text = f"Level:{user.level} - Exp: {user.experience}"
    embed = discord.Embed(title=f'{user.name}さんのレベル:', description=text)
    await ctx.response.send_message(embed=embed)
```

<details><summary>1行ごとの解説</summary>

```python
user = ctx.author if user is None else user
```
このコードでは三項演算子を用いて「`user`引数が指定されていればそれを、指定が無ければコマンドを打ったユーザーを`user`変数に代入する」ことを表します。
***
```python
user = session.query(User).filter_by(id=message.author.id).first()
```
このコードでは、user.idを使ってデータベースからテーブルを検索します。もし見つからなかった場合は`None`が渡されます。
***
```python
if user.bot:
```
対象ユーザーがbotかどうかを評価します。
***
```python
embed = discord.Embed(title='Error', descrpition='botのレベルは存在しません')
```
エラーを示す埋め込みを作成します。
***
```python
await ctx.response.send_message(embed=embed)
```
エラーを示す埋め込みを送信します。
***
```python
return
```
`if user.bot:`の中の処理だけを行い関数を終了させます。これによりこれ以降の処理は行われません。
***
```python
if not user:
```
ユーザーが見つからなかったかどうかを評価します。
***
```python
session.add(user = User(id=user.id))
```
新しく作成した`User`インスタンスをデータベースに追加します。
***
```python
session.commit()
```
変更を反映します。
***
```python
text = f"Level:{user.level} - Exp: {user.experience}"
```
変数`text`にレベルと経験値をセットします。
***
```python
embed = discord.Embed(title=f'{user.name}さんのレベル:', description=text)
```
ユーザー名と`text`変数を使って埋め込みを作成します。
***
```python
await ctx.response.send_message(embed=embed)
```
埋め込みを返します。

</details>

### レベルの高い順にリーダーボードを表示するコマンドの実装

```python
@tree.command(name="leaderboard", description="レベルの上位10位を表示します")
async def leaderboard(ctx):
    top_users = session.query(User).order_by(desc(User.level)).limit(10).all()
    text = ""
    for index, user in enumerate(top_users, start=1):
        text += f'{index}. <@{user.id}> - Level {user.level} Exp: {user.experience}\n'
    embed = discord.Embed(title='Leaderboard', description=text)
    await ctx.response.send_message(embed=embed)
```

<details><summary>1行ごとの解説</summary>

```python
top_users = session.query(User).order_by(desc(User.level)).limit(10).all()
```
データベースからレベルの高い順にユーザーを最大10項目検索します。
***
```python
text = ""
```
descriptionに指定するテキストを生成するための一時的な定義です。
***
```python
for index, user in enumerate(top_users, start=1):
```
`enumerate()`関数を使ってユーザーとその順位を取り出します。
***
```python
text += f'{index}. <@{user.id}> Level: {user.level} Exp: {user.experience}\n'
```
変数`text`に追記を行います:`'順位. ユーザーへのメンション Level: レベル Exp: 経験値'`
***
```python
embed = discord.Embed(title='Leaderboard', description=text)
```
リーダーボードの埋め込みを作成します。
***
```python
await ctx.response.send_message(embed=embed)
```
埋め込みを返します。

</details>

# 終わりに
最後まで読んで頂きありがとうございます。不備があれば是非コメントで指摘して下さい。