0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Palworld専用サーバ 起動・停止 遠隔制御用Discord bot

Last updated at Posted at 2025-06-29

概要

WindowsのSteamCMDで動作するPalworldの専用サーバをDiscord上から遠隔操作・監視するためのBotです。
完全身内用の専用サーバを運用しており、下記を対応したいと考えました。

  • Palworldサーバの再起動がホスト主しかできない為、参加メンバーにもサーバの起動・停止を制御できるようにしたい
  • Palworldサーバ内に誰も参加していない場合はサーバを停止しておいてほしい(電気代節約...)

スクリーンショット 2025-06-29 130313.png


開発環境

  • 言語: 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 stop PalServerのプロセス停止(タスクキル)
    • /pal status PalServerのプロセスを監視、サーバが起動しているか確認する

事前準備(前提)

  • 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秒ごとに自動バックアップを設定しているとはいえ、タスクキルするよりかは健全だと思います。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?