🎮

Discord botからEC2で建てたPalWorldサーバーを管理する

2024/01/31に公開

ある程度のスペックだとVPS借りるよりEC2インスタンス上げ下げした方が安いかもしれない(?)ので参加者が自由に制御できるようにしてみた

こんな感じでコマンドでサーバーを立ち上げられるようにしたり、しばらく誰もログインしてなければ勝手に落ちるようにした

環境

PalWorld: Version 0.1.3

BOTサーバー

OS: Amazon Linux 2
rcon-cli: v0.10.3
Python: 3.8.16
boto3: 1.34.29
discord.py: 2.3.2

準備

PalWorldの専用サーバー

PalWorldの専用サーバー自体の立て方は公式サイト参照
なにかしらの方法でPalWorldを動かすサーバーが立ち上がったらPalWorld専用サーバーを起動するようにしておく
自分は専用サーバーの起動スクリプトを叩くsystemdサービスを立ててる

RCONからの通信できるように設定パラメータを

RCONEnabled=True
RCONPort=<RCON用ポート>

に変更しておく

RCON

ゲームサーバを外部からコントロールする機能やツールのことをRCONと呼ぶらしい
https://github.com/gorcon/rcon-cli
このクライアントがPalWorldに対応していたので使っている
適宜設定してコマンドが効く状態にしておく
自分はpalworldという名前で設定していて
./rcon -e palworld ShowPlayersというコマンドが通るようにしているので、適宜読み替えてください
ちなみに今回書いた環境だと、非アスキー文字のユーザーがいる場合にShowPlayersコマンドがタイムアウトするみたいなのでプレイヤーキャラの名前に日本語を使わない方がいいかも

EC2へのアクセス

"ec2:DescribeInstances"
"ec2:StartInstances"
"ec2:StopInstances"

が許可されたIAMの認証情報を置いておくなどして、
boto3からEC2インスタンスの停止/開始/確認ができるようにしておく

Discord BOTの設定

https://discord.com/developers/applications/
このページからBOTを作ってDiscordサーバーに招待する
TOKENとAPP_IDは使うのでメモしておく
招待する時にメッセージの読み書きとチャンネルを読める権限を付与しておく

環境変数

とりあえずコードに直書きしたくないものは全部環境変数にしているので適宜定義する

export AWS_REGION= # PalWorld動かしてるサーバーのリージョン
export AWS_EC2_INSTANCE_ID= # PalWorld動かしてるサーバーのインスタンスID
export DISCORD_BOT_ACCESS_TOKEN= # BOTのTOKEN。BOTの設定ページから生成して持っておく
export DISCORD_BOT_APP_ID= # BOTの設定ページでメモしておく
export DISCORD_USER_ID= # Discordの開発者モードONにしてコピペ
export DISCORD_CHANNEL_ID= # サーバーを自動で落とす場合の通知先。Discordの開発者モードONにしてコピペ

DISCORD_USER_IDは変な振る舞いした時のメンション先で、特に必要というわけではないので
不要なら処理ごと消すといいと思う

BOTサーバーのスクリプト

これをnohupで動かし続けるなりデーモン化するなりする
rconを./rconで実行できるようにrcon,rcon.yamlはBOTスクリプトと同階層に置くorスクリプト調整
no_login_counter.datというファイルをログインユーザーがいなかった場合のカウンターにしてるけどデバッグの都合でこうしてるだけなので変数に入れてメモリ上でやっても平気なはず

|--no_login_counter.dat
|--rcon
|--rcon.yaml
└--serve.py

Pythonほぼ書いたことがないので汚いですが動けばいいのでOK

serve.py
import os
import time
import logging
import discord
from discord.ext import tasks, commands
import subprocess
import boto3
import traceback

SHOW_PLAYERS_HEADER = 'name,playeruid,steamid'

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

def run_subprocess(command):
    result = subprocess.run(command, capture_output=True, text=True)
    return result.stdout

def ec2_client():
    return boto3.client('ec2', region_name=os.environ['AWS_REGION'])

def start_ec2_instance():
    ec2_client().start_instances(InstanceIds=[os.environ['AWS_EC2_INSTANCE_ID']])

def stop_ec2_instance():
    ec2_client().stop_instances(InstanceIds=[os.environ['AWS_EC2_INSTANCE_ID']])

def check_ec2_instance_state():
    response = ec2_client().describe_instances(InstanceIds=[os.environ['AWS_EC2_INSTANCE_ID']])
    state = response['Reservations'][0]['Instances'][0]['State']['Name']
    return state

def rcon_show_players():
    return run_subprocess(['./rcon', '-e', 'palworld', 'ShowPlayers'])

def get_login_users():
    res = rcon_show_players()
    if SHOW_PLAYERS_HEADER not in res:
        return []
    return res.splitlines()[1:] # => ['<name>,<playeruid>,<steamid>',...]

def get_no_login_users_count():
    try:
        with open('no_login_counter.dat', 'r') as file:
            return int(file.read())
    except:
        return 0

def set_no_login_users_count(count):
    with open('no_login_counter.dat', 'w') as file:
        file.write(str(count))

def check_running_pal_world():
    return(SHOW_PLAYERS_HEADER in rcon_show_players())

def mention_marker():
    return f"<@{os.environ['DISCORD_USER_ID']}>"

async def shutdown_with_notify(channel):
    await channel.send(':construction: ゲームサーバーを停止します。30秒後にPalWorldが停止します')
    run_subprocess(['./rcon', '-e', 'palworld', 'Shutdown', '30'])
    for _ in range(10):
        time.sleep(10)
        res = run_subprocess(['./rcon', '-e', 'palworld', 'ShowPlayers'])
        if check_running_pal_world():
            await channel.send(':construction: PalWorldの停止を待機中...')
            continue
        else:
            await channel.send(':construction: PalWorldを停止を確認しました')
            time.sleep(5)
            stop_ec2_instance()
            await channel.send(':end: サーバーを停止しました')
            break
    else:
        await channel.send(f":warning: PalWorldの停止が遅すぎます!サーバーを停止します! {mention_marker()}")
        stop_ec2_instance()
        await channel.send(':warning: サーバーを停止しました')
    await channel.send(f"check_ec2_instance_state: {check_ec2_instance_state()}")
    

@tasks.loop(minutes=1)
async def shutdown_server_if_long_idle():
    """ゲームサーバーの状態を確認し、誰もログインしていなければサーバーを停止する"""
    ec2_state = check_ec2_instance_state()
    if ec2_state!='running':
        set_no_login_users_count(0)
        return
    login_users = get_login_users()
    if len(login_users)==0:
        count = get_no_login_users_count()
        if count>=10:
            channel = bot.get_channel(int(os.environ['DISCORD_CHANNEL_ID']))
            await channel.send(':warning: 10分以上ログインユーザーがいないため、サーバーを停止します')
            await shutdown_with_notify(channel)
        else:
            set_no_login_users_count(count+1)
    else:
        set_no_login_users_count(0)

@bot.command()
async def up(ctx):
    """サーバーを起動する"""
    await ctx.send(':muscle: サーバーを起動します')
    start_ec2_instance()
    await ctx.send(':construction: サーバーの開始コマンドを実行しました')
    for _ in range(10):
        time.sleep(5)
        if check_running_pal_world():
            await ctx.send(':ok: PalWorldを起動を確認しました!')
            break
        else:
            await ctx.send(':construction: PalWorldの起動を待機中...')
            continue
    else:
        await ctx.send(f":warning: PalWorldの開始が遅すぎます! {mention_marker()}")
        stop_ec2_instance()
        await ctx.send(':warning: サーバーを停止しました')


@bot.command()
async def down(ctx):
    """サーバーを停止する"""
    await shutdown_with_notify(ctx.channel)

@bot.command()
async def check(ctx):
    """サーバーの稼働状況/ログインユーザーを確認する"""
    ec2_state = check_ec2_instance_state()
    if(ec2_state == 'running'):
        await ctx.send(f":heartbeat: サーバー稼働中...")
    else:
        await ctx.send(f":sleeping: サーバーは起動していません(State:{ec2_state})")
        return

    res = run_subprocess(['./rcon', '-e', 'palworld', 'ShowPlayers'])
    if('name,playeruid,steamid' in res):
        await ctx.send(f":video_game: 起動中\n{res}")
    else:
        await ctx.send(':warning: PalWorldは起動していません!停止コマンドを実行してください!')

@bot.event
async def on_ready():
    print(f'Logged in as {bot.user.name}')
    shutdown_server_if_long_idle.start()

@bot.event
async def on_command_error(ctx, error):
    if isinstance(error, commands.CommandNotFound):
        await ctx.send_help()
    else:
        await ctx.send(f"エラーが発生しました。処理を中断します。 {mention_marker()}")
        raise error
        
handler = logging.FileHandler(filename='logs/discord_bot.log', encoding='utf-8', mode='w')
bot.run(os.environ['DISCORD_BOT_ACCESS_TOKEN'], log_handler=handler)

Discussion