概要
WindowsのSteamCMDで動作するPalworldの専用サーバをDiscord上から遠隔操作・監視するためのBotです。
完全身内用の専用サーバを運用しており、下記を対応したいと考えました。
- Palworldサーバの再起動がホスト主しかできない為、参加メンバーにもサーバの起動・停止を制御できるようにしたい
- Palworldサーバ内に誰も参加していない場合はサーバを停止しておいてほしい(電気代節約...)
開発環境
- 言語: Go 1.24.4
- プラットフォーム: Windows11 Pro
-
主要ライブラリ:
-
github.com/bwmarrin/discordgo v0.29.0- Discord API クライアント -
github.com/joho/godotenv v1.5.1- 環境変数管理
-
- Palworldサーバ: Windows版SteamCMDで起動
- bot常駐先: SteamCMDのPalworldサーバが動作しているPC
処理概要
Windows11で動くサーバの為、起動・停止にはわりと荒技な方法を使っています。
- Discord botのアプリケーションコマンドを利用して起動・停止を制御
-
/pal start特定のディレクトリにあるPalServer.exeを起動 -
/pal stopPalServerのプロセス停止(タスクキル) -
/pal statusPalServerのプロセスを監視、サーバが起動しているか確認する
-
事前準備(前提)
-
Palworld専用サーバの構築済み(ポート開放諸々)
-
Windows11にGo環境導入済み、ライブラリインストール済み
-
Discord Botをサーバに参加させておく
-
Discord Botの権限設定(アプリケーションコマンドとチャンネルへのメッセージ送信への権限があればOKのはず)
-
.envファイルを作成し、以下の環境変数を設定:
DISCORD_BOT_TOKEN=your_discord_bot_token_here
GUILD_ID=your_discord_guild_id_here
- 変数定義
var (
// サーバプロセス管理
serverProcess *exec.Cmd
serverMutex sync.Mutex
isServerRunning bool
// Discord bot token (環境変数から取得)
botToken string
// guildID コマンドを登録するサーバID
guildID string
// Discord セッションとチャンネル管理
discordSession *discordgo.Session
lastChannelID string
// プロセス監視用
isMonitoring bool
lastProcessState bool
monitoringMutex sync.Mutex
// Palworld サーバ実行ファイルのパス
palServerExePath = "C:/SteamCMDにて作成したサーバディレクトリ/PalServer.exe"
// Palworldサーバー起動時の引数
serverArgs = []string{
"-port=8211", // サーバーポート
"-useperfthreads", // 必要に応じて(引数についてはPalServerの専用サーバ構築方法のドキュメントを確認してください)
"-NoAsyncLoadingThread", // 必要に応じて
"-UseMultithreadForDS", // 必要に応じて
}
// アプリケーションコマンド定義
commands = []*discordgo.ApplicationCommand{
{
Name: "pal",
Description: "Palworldのサーバ関連のコマンド",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "start",
Description: "サーバ起動 /pal statusでサーバ状態を確認してから実行してください",
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "stop",
Description: "サーバストップします",
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "status",
Description: "サーバの状態を確認します",
},
},
},
}
)
- Bot起動、アプリケーションコマンド登録
func main() {
// .envを用意してそれぞれ↓の値を定義しておく
godotenv.Load()
botToken = os.Getenv("DISCORD_BOT_TOKEN")
guildID = os.Getenv("GUILD_ID")
if botToken == "" {
log.Fatal("DISCORD_BOT_TOKEN environment variable is required")
}
// Discord セッション作成
dg, err := discordgo.New("Bot " + botToken)
if err != nil {
log.Fatal("Error creating Discord session: ", err)
}
// グローバル変数にセッションを保存
discordSession = dg
// イベントハンドラー登録
dg.AddHandler(ready)
dg.AddHandler(interactionCreate)
// Intentを設定
dg.Identify.Intents = discordgo.IntentsGuildMessages
// Bot起動
err = dg.Open()
if err != nil {
log.Fatal("Error opening connection: ", err)
}
defer dg.Close()
fmt.Println("Palworld Discord Bot is running. Press CTRL+C to exit.")
// シグナル待機
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
fmt.Println("Gracefully shutting down...")
// 必要に応じてここにサーバプロセス停止の処理
}
- イベントハンドラ受け取り
// botが起動した時にアプリケーションコマンドを登録する
func ready(s *discordgo.Session, event *discordgo.Ready) {
fmt.Printf("Logged in as %v#%v\n", s.State.User.Username, s.State.User.Discriminator)
// アプリケーションコマンドを登録
for _, command := range commands {
_, err := s.ApplicationCommandCreate(s.State.User.ID, guildID, command)
if err != nil {
log.Printf("Cannot create '%v' command: %v", command.Name, err)
}
}
}
// アプリーケーションコマンドの処理をここで引き受ける
// サブコマンドを取得して対応した処理を行う
func interactionCreate(s *discordgo.Session, i *discordgo.InteractionCreate) {
if i.ApplicationCommandData().Name != "pal" {
return
}
options := i.ApplicationCommandData().Options
if len(options) == 0 {
return
}
subCommand := options[0].Name
// チャンネルIDを保存
lastChannelID = i.ChannelID
// サブコマンドに対応した処理を実行し、レスポンスを返す
var response string
switch subCommand {
case "start":
response = handleStartCommand()
case "stop":
response = handleStopCommand()
case "status":
response = handleStatusCommand()
default:
response = "❓ 不明なコマンドです"
}
// レスポンス
err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: response,
},
})
if err != nil {
log.Printf("Error responding to interaction: %v", err)
}
}
各コマンド、プロセス監視概要
1. /pal start サーバ起動
func handleStartCommand() string {
serverMutex.Lock()
defer serverMutex.Unlock()
if isServerRunning {
return "🔴 Palworldサーバーは既に起動中だよ"
}
// exeファイルの存在確認
if _, err := os.Stat(palServerExePath); os.IsNotExist(err) {
return fmt.Sprintf("❌ サーバー実行ファイルが見つかりません: %s", palServerExePath)
}
// プロセス監視を開始(サーバが起動した時、停止した時にメッセージを別で送信する)
startProcessMonitoring()
// サーバ起動
err := startServer()
if err != nil {
return fmt.Sprintf("❌ サーバー起動に失敗しました: %v", err)
}
return "🚀 Palworldサーバーを起動中..." // アプリケーションコマンド実行時に返却するメッセージ
}
サーバ起動用関数
execでWindowsコマンドにより「PalServer.exe」を実行します
func startServer() error {
// exeファイルを引数付きで実行
args := append([]string{}, serverArgs...)
serverProcess = exec.Command(palServerExePath, args...)
// プロセスの出力をキャプチャ(デバッグ用)
serverProcess.Stdout = os.Stdout
serverProcess.Stderr = os.Stderr
err := serverProcess.Start()
if err != nil {
return fmt.Errorf("failed to start server process: %v", err)
}
isServerRunning = true
// バックグラウンドでプロセス終了を監視(プロセス監視とは別の簡易監視)
// PalServerのプロセスが終了すればフラグを落とす
go func(proc *exec.Cmd) {
if proc != nil {
proc.Wait()
}
serverMutex.Lock()
isServerRunning = false
serverProcess = nil
serverMutex.Unlock()
fmt.Println("Server process has ended")
}(serverProcess)
return nil
}
サーバが起動しているかのフラグを管理しています。このフラグを利用して起動・停止を確認します。
複数のgoroutineが同じリソースにアクセスする際の競合状態を防ぐため、Mutexを使用しています。
var (
serverMutex sync.Mutex // サーバープロセス保護
monitoringMutex sync.Mutex // 監視状態保護
isServerRunning bool // Palworldサーバが起動しているか?
)
2. リアルタイムプロセス監視
3秒ごとにPalServer.exeのプロセスを監視しています。
起動、停止を検知した時にDiscordでメッセージを送信します。
// プロセス監視を開始
func startProcessMonitoring() {
monitoringMutex.Lock()
if isMonitoring {
monitoringMutex.Unlock()
return
}
isMonitoring = true
lastProcessState = isPalServerProcessRunning()
monitoringMutex.Unlock()
go func() {
for {
monitoringMutex.Lock()
if !isMonitoring {
monitoringMutex.Unlock()
break
}
monitoringMutex.Unlock()
currentState := isPalServerProcessRunning()
monitoringMutex.Lock()
if currentState != lastProcessState {
if currentState {
// プロセスが開始された
fmt.Println("PalServer-Win64-Shipping-Cmd.exe process started")
sendDiscordMessage("✅ Palworld サーバを起動したよ!")
} else {
// プロセスが終了した
fmt.Println("PalServer-Win64-Shipping-Cmd.exe process ended")
sendDiscordMessage("🔴 Palworld サーバが停止したよ。")
// サーバー状態も更新
serverMutex.Lock()
isServerRunning = false
serverProcess = nil
serverMutex.Unlock()
}
lastProcessState = currentState
}
monitoringMutex.Unlock()
time.Sleep(3 * time.Second) // 3秒ごとにチェック
}
}()
}
PalServer.exeのプロセスが存在するかのチェック処理です
PalServer.exeが起動した際、「PalServer-Win64-Shipping-Cmd.exe」のプロセスによってPalworldの専用サーバが動作しているため、
Windowsのtasklistコマンドによってプロセスをチェックします。
func isPalServerProcessRunning() bool {
cmd := exec.Command("tasklist", "/FI", "IMAGENAME eq PalServer-Win64-Shipping-Cmd.exe", "/FO", "CSV")
output, err := cmd.Output()
if err != nil {
return false
}
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if strings.Contains(line, "PalServer-Win64-Shipping-Cmd.exe") {
return true
}
}
return false
}
3. /pal stop サーバ停止
func handleStopCommand() string {
serverMutex.Lock()
defer serverMutex.Unlock()
if !isServerRunning {
return "🔴 Palworldサーバーは起動していないよ"
}
err := stopServer()
if err != nil {
return fmt.Sprintf("❌ サーバー停止に失敗しました: %v", err)
}
return "🛑 Palworldサーバーを停止中..."
}
「PalServer-Win64-Shipping-Cmd.exe」をタスクキルすることによってPalworldサーバを停止させています。本当に荒技です...
func stopServer() error {
if serverProcess == nil {
isServerRunning = false
return nil
}
// 実際に動作しているPalServer-Win64-Shipping-Cmd.exeプロセスをキル
err := killPalServerProcess()
if err != nil {
return fmt.Errorf("failed to kill PalServer process: %v", err)
}
// 親プロセスも終了
if serverProcess.Process != nil {
serverProcess.Process.Kill()
}
// プロセス終了を待機
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
done := make(chan error, 1)
go func() {
done <- serverProcess.Wait()
}()
select {
case <-ctx.Done():
return fmt.Errorf("timeout waiting for server to stop")
case err := <-done:
if err != nil && !strings.Contains(err.Error(), "exit status") {
return fmt.Errorf("error waiting for server process: %v", err)
}
}
isServerRunning = false
serverProcess = nil
return nil
}
// PalServer-Win64-Shipping-Cmd.exeプロセスを特定してキルする
func killPalServerProcess() error {
// tasklist コマンドでPalServer-Win64-Shipping-Cmd.exeを検索
cmd := exec.Command("tasklist", "/FI", "IMAGENAME eq PalServer-Win64-Shipping-Cmd.exe", "/FO", "CSV")
output, err := cmd.Output()
if err != nil {
return fmt.Errorf("failed to list tasks: %v", err)
}
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if strings.Contains(line, "PalServer-Win64-Shipping-Cmd.exe") {
// CSVフォーマットから PID を抽出
fields := strings.Split(line, ",")
if len(fields) >= 2 {
pid := strings.Trim(fields[1], "\"")
// taskkill でプロセスをキル
killCmd := exec.Command("taskkill", "/PID", pid, "/F")
err := killCmd.Run()
if err != nil {
return fmt.Errorf("failed to kill process with PID %s: %v", pid, err)
}
fmt.Printf("Killed PalServer-Win64-Shipping-Cmd.exe process with PID: %s\n", pid)
}
}
}
return nil
}
4. /pal status サーバの状態確認
これは単純にフラグを確認してレスポンスしています。
func handleStatusCommand() string {
serverMutex.Lock()
defer serverMutex.Unlock()
if isServerRunning {
return "🟢 Palworldサーバーは起動中だよ"
}
return "🔴 Palworldサーバーは停止中だよ"
}
ビルドと実行
exe化してワンクリックでbot起動できるようにしておきます。
# 依存関係のインストール
go mod tidy
# ビルド
go build -o palworld-bot.exe
# 実行
./palworld-bot.exe
まとめ
他のメンバーでもサーバの起動・停止ができるようになったので、サーバの調子がおかしくなった時の再起動や、
誰も参加していない中のサーバ動作の制御が楽になりました。
専用サーバを再起動しないと復活しないPalworld特有の要素があるようなので、それも関連して便利になったと思います。(ゾーイとか...)
荒技とはいえやっていることは単純なのでPalworld以外のサーバにも応用可能です。
Windowsで動く他のゲームサーバを対応する場合に利用する予定です。
実装後に気づいたこと
専用サーバにはREST APIが提供されており、そこで保存やサーバ停止を実行できるようです。
30秒ごとに自動バックアップを設定しているとはいえ、タスクキルするよりかは健全だと思います。
