ゲームサーバの管理にC#でModを作ってGoで管理サーバを作った話

この記事は さいが崖 Advent Calendar 2025 25日目 の記事です。メリークリスマス!

この記事はバカ長い記事です。

工場ゲーを遊ぶつもりがサーバで遊び始めてしまった

こんにちは。ここのえです。

今年の春ごろ、友人とともに Foundry というゲームを買いました。簡単に言うとMinecraft工業Modをアップグレードしてそのまま単体ゲームにしたみたいなゲームです。とても面白いです。

Steamで25% OFF:FOUNDRY
無限に広がるボクセル世界で、完璧に最適化された工場や芸術的な傑作を作り上げましょう。資源を採掘・収穫し、拡大し続ける生産ラインを自動化してください。そして、複雑なシステムの管理をしながら、FOUNDRYの機械化を極めるべく研究を重ねていきま...

さて、マルチプレイヤーゲームをやるのであれば、まず最初にDedicated Serverの構築は欠かせません。誰かがゲームを起動する必要がなく、好きなタイミングで遊び始められるのはやはり大正義です。というか、むしろサーバ構築を楽しむのがゲームの楽しみの一つになっている節があります(?)。

FoundryくんもDedicated Serverを提供してくれています。嬉しいですね。はい。 .exeファイルだけです。

FOUNDRY Dedicated Server Information
FOUNDRY Dedicated Server Information

こういった場合、LinuxでWineを使って動かすのが定石です。以前遊んでいた Enshrouded も同様にWindows版しかDedicated Serverが提供されていないゲームで、Wineを使用していたのですが、安めの非力なCPUのサーバではパフォーマンス面の問題が大きく、サーバ負荷が上がると同時に戦闘がまともにできないぐらいのラグが発生していました。

実は安めのサーバといえど、最近のゲームはメモリを結構食うのでプランが上がっていたりして「そろそろリプレースしよう」という話が持ち上がっていました。具体的には、メインの処理はGCPのComputed Engineのスポットインスタンスで節約しつつ、起動のたびにGCPのコンソールを開くのは虚無なので、Discord経由で行う……といった感じです。構成としてはこんな感じです。

極限までケチすることを目的とした構成です。節約上手とも言う。

GCP周りの構成については、いつかインフラ担当してくれた友人がイイ感じに記事にしてくれるだろう……と思うので、ここでは大雑把な解説にとどめておきます。簡単に言うと常時起動インスタンスを0にすることで極限まで費用を抑えてます。全部使う時だけ動く、という感じです。

何年来の大規模リプレースという感じです。Discordからの操作によって、エンジニアじゃない友人が参加しても大丈夫です。コマンド一発で起動できます。革命ですね。

当然そう簡単にはいかない

GCP周りは業務で触ってるプロに任せるとして、私は管理サーバ側のコーディングを行うことになりました。しかし検証作業を行っていくうちに、完成までにクソデカい山がいくつも聳え立っていることに気づきました。

課題1:Windows ServerとLinuxに両対応する

Wineを使ってしまうとどうしてもパフォーマンスが落ちてしまうので、せっかくクラウドを使うならサーバ用バイナリがWindowsしかないゲームはWindows Serverを使ってしまおう……ということになりました。ただ一般的なゲームの場合Linux用のバイナリが提供されているので、Windows環境が必要ない場合、Linuxのインスタンスを使って極力費用を抑えたいです。要はCompute EngineからCloud Runへの通信プロトコルを統一する必要があります。

課題2:Discord APIの制約がエグい

今回Discordからの操作を簡単に行えるようにするため、Slash Commandを使った形での実装を試みました。ただこのSlash Command、コマンドが送信されてから3秒以内にレスポンスを返さないとタイムアウト扱いになるという厳しい制約があります。ムスカ大佐でも3分間待ってくれるのに……。

ケチるためにCloud Runの最低インスタンスを0にしているため、インスタンスの起動時間まで考慮しないといけません。Cloud Runの起動時間は使用言語によって左右されますが、Nodeを使っていると起動だけで数秒かかってしまうため、当然間に合わずタイムアウトで試合終了です。

ということで、最速で起動する golang で最速のサーバを書きます。速度を上げるためにライブラリも使いません。Discord API 直叩きです。golangを本格的に書くのはこれが初めてでしたが、根性でなんとかしました。

課題3:Foundryのサーバ管理は難しい

DiscordからCompute Engineを起動するとしても、さすがに全ゲームを同時起動できるほどのパフォーマンスはありません。都度遊びたいゲームのサーバをDiscordからの起動・停止する機能の実装が必要です。

しかしFoundryサーバは困った仕様があります。ウィンドウの右上の×を押したときセーブが行われず、即時にプロセスがkillされてしまうため、オートセーブ以降のセーブデータがロストしてしまいます。ヤバいわよ!

終了時セーブを含める方法として、コンソールに対してCtrl+Cを送ることで正常終了させることが可能……なのですが、サーバとして動かしているとプログラム上から制御する関係でどうしてもウィンドウを閉じるのと同じ挙動になってしまい、Ctrl+Cのシグナルを直接送ることができませんでした。こうなると、ゲームのサーバに直接干渉してシャットダウンさせるしかありません。

……はい、この場合どうすればよいでしょうか?

Modを作ります。

ついでにサーバ内のプレイヤー情報も取得できるようにしておきます。プレイヤー人数が取れればうっかり終了後にサーバを止め忘れても、インスタンスをシャットダウンできるようになるのでもっと嬉しいですよね。

ただゲームごとにCompute EngineのAPIを叩くのはあまりにも虚無ですし、C#はDiscord APIのレスポンス速度に対応できるほど速くありません。各ゲームを統括する管理サーバを別途作っておき、そこの管理サーバでGCP周りの操作を任せるようにします。もちろん高速でないと死ぬので、golangで書きます。エラいことになってきました……。

Modを作ろう

FoundryはModkitが公式にリリースされているため、C#とUnityの知識があれば簡単にModを制作できます。公式のMod開発用ツールがあるゲーム、本当にありがたい……

FOUNDRY Modkit
FOUNDRY Modkit Information

ドキュメントを読んで最低限のお作法を確認しつつ、Modkit内にあるメソッドやイベントを確認しながら実装を進めていきます。ModKitに .asmdef (Assembly Definition) が含まれており、これを参照することで呼び出し可能なメソッドを判別できます。Riderを使って開発する場合、Shift二回押しのショートカットを押し、右上の Include non-solution items にチェックを入れることで表示されます。

Shutdown() と名前の付くメソッドがいくつかあるので、Debug.Log() でログを出しながら挙動を確認していきます。「プログラマが実装するならこういう設計にするよな……」と推測しつつ、ひたすら検証です。往々にしてMod開発は地味な作業の繰り返しです。

頑張って探そう

ちなみにMod開発に集合知はないので、AIアシスタントもほとんど役に立ちません。信じられるのは自分のコーディング技術だけです。結局シャットダウン呼び出しの Shutdown() や 参加プレイヤー情報取得の CharacterManager.getCharactersInWorldCount() だったりして、コーディングする行数としてはかなり少ないのですが、それを見つけるまでの労力はハンパないです。

C#
[AddSystemToGameSimulation]
public class GrimoireSystem : SystemManager.System
{
    // ...
    
    [EventHandler]
    public void HandleOnUpdate(OnUpdate _)
    {
        if (_isShutdown)
        {
            Shutdown();
        }
    }
    
    public int GetPlayersCount()
    {
      // これを見つけるまでが長い
      return CharacterManager.getCharactersInWorldCount();
    }
}

シャットダウン周りの機能実装ができたら、外部通信用のHTTPサーバを立ち上げます。サーバ側のAPIになる部分です。一般的なゲームサーバ管理のプロトコルといえばValveの RCON が有名ですが、実装がかなり辛いのと、デファクトといえるほど採用されてるわけでもないのでクライアントも少ないです。従って無難にHTTP通信を行うことにしました。

C#のHTTP通信なので System.Net.HttpListener を使用します。

C#
public partial class HttpServer {
    public void StartServer()
    {
        if (_listener != null)
        {
            GrimoireLog.LogWarning(Tag, "HTTP server is already listening.");
            return;
        }
    
        _listener = new HttpListener();
        _listener.Prefixes.Add(_url);
    
        try
        {
            _listener.Start();
            _cancel = new CancellationTokenSource();
            Task.Run(Listen);
            GrimoireLog.Log(Tag, $"API Server listening: {_url}");
        }
        catch (Exception e)
        {
            GrimoireLog.LogException(Tag, e);
        }
    }
    
    private async Task Listen()
    {
        while (!_cancel.Token.IsCancellationRequested)
        {
            HttpListenerContext context = null;
            try
            {
                context = await _listener.GetContextAsync();
            }
            catch (HttpListenerException e)
            {
                // listenerが閉じられてしまった時
                if (e.ErrorCode == 995 || e.NativeErrorCode == 995)
                {
                    GrimoireLog.LogWarning(Tag, "HTTP listener was closed. Stop listening.");
                }
                else
                {
                    GrimoireLog.LogException(e);
                }
    
                break;
            }
            catch (Exception e)
            {
                Debug.LogException(e);
            }
    
            if (context == null) continue;
            _ = HandleRequest(context);
        }
    
        GrimoireLog.Log(Tag, "Stop listening.");
    }
}

Task.Run() 使ってるけど大丈夫なのか?という話ですが、最近のUnity(2017.x以降?)のC# 6.0環境であれば全然使えます。Unity Coroutineだと外部のライブラリ使いにくいですし。ちなみに別スレッドで処理しないと処理がブロッキングされてゲームが止まります。Mod開発あるある、ゲーム止まりがち。

あとはHandlerを作って各種エンドポイントを実装していきます。レスポンスはJSONで返します。例えばサーバステータスを取得する GET /status はこんな感じ。

C#
public partial class HttpServer
{
    /// <summary>
    /// GET /status
    /// </summary>
    /// <param name="context"></param>
    private static async Task GetStatus(HttpListenerContext context)
    {
        var status = GrimoireSystem.Instance.ServerStatus switch
        {
            ServerStatus.Wakeup => "Wakeup",
            ServerStatus.Running => "Running",
            ServerStatus.Shutdown => "Shutdown",
            _ => "UNKNOWN"
        };
        context.Response.StatusCode = 200;
        context.Response.ContentType = "application/json";
        await SendMessageAsync(context, $"{{\"status\":\"{status}\"}}");
    }
}

ちなみにこれらのコードにも断片がありますが、Modの名前は Grimoire にしています。何故かというとやってることが完全に黒魔術だからです……。

ゲームサーバを統括する管理サーバの実装

次にCompute Engine内の管理サーバの実装です。各種ゲームを統括管理して、ゲームサーバ内のステータスの取得や起動・停止といった操作ができるようにします。正直この工程が一番しんどかったです。

レスポンスを極限まで速くするために、サーバステータスのもろもろを保存しておくstructを用意しておき、定期的に情報を取得・更新します。Cloud Run(Discord API用) からリクエストが飛んで来たら、あらかじめ取得しておいた情報を即時返します。Discordのslash commandが複数人から同時に飛んできたり、情報の更新と取得リクエストタイミングが被ったりする可能性があるので、安全策としてMutexも用意します。

Go
type FoundryServer struct {
	mu          sync.RWMutex
	name        string
	status      ServerStatus
	playerCount int
	players     []FoundryPlayer
}

サーバのステータスチェックについては、プロセスが動いているかどうかによって分岐させる必要があります。ゲームサーバが止まっていればModも動いていないので、プレイヤー情報をリクエストするとコケてしまいます。

Go
type FoundryWatcher struct {
	client     *foundry.Client
	server     *gameserver.FoundryServer
	exePath    string
	stopCh     chan struct{}
	isWatching bool
}

func (w *FoundryWatcher) checkStatus() {
  // .exeが起動されているか確認
	isRunning := w.isProcessRunning()

	if !isRunning {
		w.server.UpdateStatus(gameserver.StatusStopped)
		return
	}

	status, err := w.client.GetStatus()
	if err != nil {
		fmt.Printf("Error getting Foundry status: %s\n", err)
		return
	}

	// Runningだったらプレイヤー一覧を取得
	var players *foundry.PlayersResponse
	if status.Status == "Running" {
		players, err = w.client.GetPlayers()
		if err != nil {
			fmt.Printf("Error getting Foundry players: %s\n", err)
		}
	}

	// サーバステータスの情報を更新
	switch status.Status {
	case "Wakeup":
		w.server.UpdateStatus(gameserver.StatusWakeup)
	case "Running":
		w.server.UpdateStatus(gameserver.StatusRunning)
		if players != nil {
			w.server.UpdatePlayers(players.Players)
		}
	}
}

プロセスが動いているかどうかは exec Command() を使ってゴリ押しで取得します(いい方法があれば教えてください……)。

Go
func (w *FoundryWatcher) isProcessRunning() bool {
	parts := strings.Split(w.exePath, "\\")
	processName := parts[len(parts)-1]

  // Windowsのコマンドを叩く
	cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("IMAGENAME eq %s", processName), "/NH", "/FO", "CSV")
	output, err := cmd.Output()

	if err != nil {
		return false
	}

	outputStr := string(output)
	// 指定した.exeファイルが動いていれば、帰ってきた文字列に含まれているので判断がつく
	isRunning := strings.Contains(outputStr, processName)
	return isRunning
}

GET /foundry/status に対してアクセスがあった場合に、情報をJSONに整形して返すようにします。

JSON
{
  "status": "Shutdown",
  "timestamp": "2025-12-25T19:00:00.0000000+09:00",
  "player_count": 1,
  "players": [
    {
      "username": "foo",
      "id": 1
    }
  ]
}

他にも認証回りとか、コードの細かい工夫は結構あるのですが、これ以上挙げているとキリがないので解説はこのぐらいに留めておきます……

Cloud Run上にDiscord botを構築

さて、いよいよCloud Run上のDiscord botの構築です。膨大なコードになっているので、掻い摘んでコアな部分を中心に解説していきます。

Discordのslash commandへの対応

Discordのslash commandの仕組みとしては、まずDiscord Developer PortalからInteractions Endpoint URLを指定する必要があります。botからslash commandが発行されたタイミングで、このURLに対してHTTP POSTが行われます。

加えてslash commandはあらかじめDiscord側に対して「どのようなコマンドがあるか」を登録しておく必要があります。これによりdiscord上でコマンドの一覧・補完が可能になります。

Goから登録することも可能ですが、普通にコードがめんどくさいです。登録は discord.jsSlashCommandBuilder を使って雑にやります。/foobar status のような第二引数のあるコマンドを登録する場合、これはdiscordにおいて subcommand に当たります。以下のコードで登録できます。

TypeScript
import { SlashCommandBuilder } from "discord.js";

const command = new SlashCommandBuilder()
  .setName("foundry")
  .setDescription("Foundry Dedicated Server")
  .addSubcommand((sub) =>
    sub.setName("start").setDescription("[Foundry] サーバー起動"),
  )
  .addSubcommand((sub) =>
    sub.setName("stop").setDescription("[Foundry] サーバー停止"),
  )
  .addSubcommand((sub) =>
    sub.setName("status").setDescription("[Foundry] ステータス確認"),
  )
  .addSubcommand((sub) =>
    sub.setName("players").setDescription("[Foundry] ログイン中プレイヤー"),
  );

export default command;

あとは実装するだけなのですが、Discordのslash commandは先に述べた通りリクエスト送信、3秒でタイムアウト扱いになります。基本的にInteractions Endpointが常にListen状態になっていることが前提なのです。

これに対応する方法は一つあって、送信されたリクエストに対して一旦DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE を送信することで「本体のレスポンスは遅延するよ~、ちょっと待ってクレメンス」と本体のレスポンスを遅延させることができます。これでタイムアウトは回避できます。

ただしこれにCloud Runの仕様の問題が絡んできます。Cloud Runはリクエストの完了とともに処理が終了します。お気づきだろうか……

つまり DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE を送信したらリクエストに対してレスポンスが完了します。その後の処理は行われません。本体のレスポンスどこで送るの?

わァ…あ…    泣いちゃった!!!

なので最高速でレスポンスを返す必要があります。起動・停止処理に関しては完了時点でEventarcから通知を受け取ることができるので、いったん「起動・停止処理を開始しました」のメッセージだけ送信し、別途discordのチャンネルIDを決め打ちしてメッセージを送るという小技が必要です。Goなので起動は早いものの、それでも3秒の制約ギリギリです。

ちなみにNodeを使ったら起動に3秒以上かかるので、全部のメッセージがタイムアウトになります……。

一旦slash commandのリクエストはこんな感じでやり過ごします。

Go
type FoundryStartAsyncResponse struct {
	Status FoundryStartAsyncStatus `json:"status"`
}

// Foundryの起動リクエストを行う 起動結果は別途callbackで受け取る
func FoundryStartAsync() (*FoundryStartAsyncResponse, error) {
	// リクエストの作成
	url, err := GetBaseUrl()
	if err != nil {
		log.Println("Compute EngineのInternal IPを取得できませんでした: ", err)
		return nil, err
	}

	req := &Request{
		Method: "POST",
		Url:    fmt.Sprintf("%s/foundry/start_async", url),
	}

	// 送信
	res, err := req.send()
	if err != nil {
		log.Println("管理サーバのリクエストの送信に失敗しました: ", err)
		return nil, err
	}
	defer func(Body io.ReadCloser) {
		err := Body.Close()
		if err != nil {
			log.Println("管理サーバの通信のクローズに失敗しました:", err)
		}
	}(res.Body)

	// レスポンスの整形
	var foundryStartAsyncResponse FoundryStartAsyncResponse
	if err := json.NewDecoder(res.Body).Decode(&foundryStartAsyncResponse); err != nil {
		log.Println("管理サーバのレスポンスのデコードに失敗しました: ", err)
		return nil, err
	}

	return &foundryStartAsyncResponse, nil
}

別途管理サーバからCloud Runのcallbackエンドポイントに対して起動結果を返し、discordに再度通知を行います。

Go
// FoundryStartRequest Foundryの起動結果のリクエストボディ
type FoundryStartRequest struct {
	Status FoundryStartStatus `json:"status"`
}

// Foundryの起動結果をサーバから受け取る
func FoundryStart(w http.ResponseWriter, r *http.Request) {
	// JSONのパース
	var req FoundryStartRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "Bad Request", http.StatusBadRequest)
		return
	}
	defer func(Body io.ReadCloser) {
		err := Body.Close()
		if err != nil {
			log.Println("管理サーバの通信クローズに失敗しました:", err)
		}
	}(r.Body)

	// Discordに通知
	var msg string
	switch req.Status {
	case FoundryStartStatusSuccess:
		msg = "Foundryの起動が完了しました。"
	case FoundryStartStatusFailed:
		msg = "Foundryの起動に失敗しました。"
	case FoundryStartStatusAlreadyRunning:
		msg = "Foundryはすでに起動しています。"
	case FoundryStartStatusTimeout:
		msg = "Foundryの起動にタイムアウトしました。"
	}

	message := &discord.CreateMessageRequest{
		Content: msg,
	}
	if err := message.Send(config.DiscordBotTargetChannelID); err != nil {
		log.Println("Discordのメッセージ送信に失敗しました: ", err)
	}
}

これで完成です。通信があっちこっち行ったり来たりで実装が大変ですね……。

Eventarcの処理

GCPのEventarcはCloudEventの形式で飛んできます。Compute Engine側のログと同じ形でJSONが飛んでくるので、それを参考にしてstructを用意しておきます。

CloudEvents - JSON イベントの形式  |  Eventarc  |  Google Cloud Documentation
Go
type logEntryData struct {
	ProtoPayload *struct {
		ResourceName string `json:"resourceName"`
	} `json:"protoPayload,omitempty"`

	Operation *struct {
		Id       string `json:"id"`
		Producer string `json:"producer"`
		First    bool   `json:"first,omitempty"`
		Last     bool   `json:"last,omitempty"`
	} `json:"operation,omitempty"`
}

// ComputeEngineStarted EventarcのCompute Engineの起動完了イベント
func ComputeEngineStarted(w http.ResponseWriter, r *http.Request) {
	// Pub/SubにOKを返す
	w.WriteHeader(http.StatusOK)

	// ログのパース
	entryData, err := parseLogEntryData(r)
	if err != nil {
		log.Println("CloudEventのログ解析に失敗しました: ", err)
		return
	}

	// 重複表示をはじくため、二回目に出てきたログの時だけ処理を続ける
	if !entryData.Operation.Last {
		return
	}

	// ResourceNameをパースしてInstanceRefに変換する
	ref := parseResourceName(entryData.ProtoPayload.ResourceName)

	var msg string
	// 起動したサーバのIPを取得
	info, err := gcp.GetComputeEngineInfo(ref)
	if err != nil {
		msg = fmt.Sprintf("サーバ %s は起動しましたが、情報が取得できませんでした。", ref.Name)
	} else if info.ExternalIP == "N/A" {
		msg = fmt.Sprintf("サーバは %s 起動していますが、IPが取得できませんでした。", ref.Name)
	} else {
		msg = fmt.Sprintf("サーバ %s を起動しました!\n```%s```", ref.Name, info.ExternalIP)
	}

	// discordにメッセージ送信
	req := discord.CreateMessageRequest{
		Content: msg,
	}
	if err := req.Send(config.DiscordBotTargetChannelID); err != nil {
		fmt.Println("Discordのメッセージ送信に失敗しました: ", err)
	}
}

2点ほど厄介な点があります。第一にEventarcはGCPのPub/Subとして送信が行われます。受け取った場合、すぐに 200 OK を返す必要があります。レスポンスを返さないとエラーになります。

Go
// Pub/SubにOKを返す
w.WriteHeader(http.StatusOK)

第二にイベントが2回発行されてしまうということです。ログの仕様上?なのか分かりませんが、FirstLast のオペレーションで起動に関するイベントが2回発行されるという仕様になっています。幸い送られてきたJSONをパースすれば判断はつきます。Lastのログでない場合は無視するように実装しました。

Go
	// 重複表示をはじくため、二回目に出てきたログの時だけ処理を続ける
	if !entryData.Operation.Last {
		return
	}

Spot VMの場合、サーバ側の都合でインスタンスがシャットダウンされる可能性があります。これも通知したい場合は "type.googleapis.com/compute.instances.preempted" に対して対応する必要がある……のですが、なぜかEventarcに該当のメソッドが見つからず、指定できません😢
もし詳しい人がいたら教えてください……

ライブラリを使わずにDiscord APIを叩く

Discordはライブラリが充実しているので、それを使えばそんなに苦労しないでしょう。ですが今回は1msでも高速化したいので、いらない機能はすべて廃して自分で最低限実装します。所詮HTTPリクエスト送るだけでしょ?といえばそうなのですが、これが結構ややこしいです。

一旦ベースになる部分を実装していきます。Authorization は取得したBotのトークンを流し込みます。 Content-Type は送信するデータによって変動するので、送信する形式によって変更できる形で実装しておきます。

Go
type apiRequest struct {
	Method      string
	Url         string
	Body        []byte
	ContentType string
}

// send Discord APIへの送信
func (apiReq *apiRequest) send() error {
	// リクエストの作成
	req, err := http.NewRequest(apiReq.Method, apiReq.Url, bytes.NewBuffer(apiReq.Body))
	if err != nil {
		log.Println("Discord APIのリクエストの作成に失敗しました: ", err)
		return err
	}

	req.Header.Set("Authorization", fmt.Sprintf("Bot %s", config.DiscordBotToken))
	req.Header.Set("Content-Type", apiReq.ContentType)
	
	httpClient := &http.Client{}
	resp, err := httpClient.Do(req)
	if err != nil {
		log.Println("Discord APIのリクエストの送信に失敗しました: ", err)
		return err
	}
	
	defer func(Closer io.ReadCloser) {
		err := Closer.Close()
		if err != nil {
			log.Println("Discord APIの通信のクローズに失敗しました:", err)
		}
	}(resp.Body)

	return nil
}

Discord APIはかなり整えられているので、理屈が分かればとても納得がいく構造になっています。ただ全貌が見えにくいので、初めて触る場合はかなりとっつきづらいです……。例えばSlash Commandは、大きな枠組みとして Intearaction の一種であり、その小分類として Interaction Type = APPLICATION_COMMAND として定義されています。JSONがこの形で飛んでくるので、パースできるようにstructを作っておきます。

Go
type Interaction struct {
	ID            string                 `json:"id"`
	ApplicationID string                 `json:"application_id"`
	Type          InteractionType        `json:"type"`
	Data          json.RawMessage        `json:"data,omitempty"`
	GuildID       string                 `json:"guild_id,omitempty"`
	ChannelID     string                 `json:"channel_id,omitempty"`
	User          *User                  `json:"user,omitempty"`
	Token         string                 `json:"token"`
	Version       int                    `json:"version"`
	Message       *Message               `json:"message,omitempty"`
	Context       InteractionContextType `json:"context,omitempty"`
}

type InteractionType int

const (
	InteractionTypePing                           InteractionType = 1
	InteractionTypeApplicationCommand             InteractionType = 2
	InteractionTypeMessageComponent               InteractionType = 3
	InteractionTypeApplicationCommandAutocomplete InteractionType = 4
	InteractionTypeModalSubmit                    InteractionType = 5
)

Interaction Type = 1の PING ですが、Interaction Endpoint URLの検証に用いられます。WebのDiscord Developer PortalでURLを設定したときに PING タイプでリクエストが飛んできます。これを返せないとエラーになり、URLをエンドポイントとして使用できません。要注意です。

Go
// Response
type InteractionResponse struct {
	Type InteractionCallbackType     `json:"type"`
	Data *InteractionCallbackMessage `json:"data,omitempty"`
}

// InteractionタイプがPINGの時の応答
func (i *Interaction) Pong(w http.ResponseWriter) error {
	res := &InteractionResponse{
		Type: InteractionCallbackTypePong,
	}
	return i.Response(w, res)
}

コマンドのレスポンスとしてメッセージを送る場合、Data に対して内容を流し込みます。

Go
// レスポンスとしてメッセージを送信する
func (i *Interaction) SendMessage(w http.ResponseWriter, msg string) error {
	res := &InteractionResponse{
		Type: InteractionCallbackTypeChannelMessageWithSource,
		Data: &InteractionCallbackMessage{
			Content: msg,
		},
	}
	return i.Response(w, res)
}

あとはコマンドに対応するhandlerから、SendMessage を呼び出してレスポンスを返します。

Go
// KyleHi	/kyle hi
func KyleHi(w http.ResponseWriter, interaction *discord.Interaction, data *discord.ApplicationCommand) error {
	if err := interaction.SendMessage(w, "こんにちは! 何について調べますか?"); err != nil {
		return err
	}
	return nil
}

// KyleDisappear	/kyle disappear
func KyleDisappear(w http.ResponseWriter, interaction *discord.Interaction, data *discord.ApplicationCommand) error {
	if err := interaction.SendMessage(w, "# (◞‸◟)"); err != nil {
		return err
	}
	return nil
}

まとめ

大変な分量の記事になってしまい、大変申し訳ございませんでした……。主にDiscord API周りとGCP関係の知見まとめメモという感じになってしまいました。最後まで読んでくださった皆様に圧倒的感謝……。

全体でとんでもない量のコードを書いたのですが、いい感じに管理サーバに纏めることができたので満足度は高いです。ゲームごとに対応するためにコードをちょっと書けば動くような状態なので、だいぶ快適です。なんかもうゲームサーバ業者とかできそうな気がする。知らんけど。

そんな感じで2週間ぐらいかけてサーバのリプレースを無事終えて、Foundryを遊び始めました。MinecraftのBC, IC, RPみたいな感じでボクセルベースの工場建築はやっぱり楽しいですね。私のプレイ時間はこんな感じです。

ゲーム遊んでるよりコーディングしてた時間のほうが長いです。解散!

Advent Calendar 2025終了に寄せて

さいが崖 Advent Calendar 2025 に参加してくださった皆様、本当にありがとうございました!以前に比べて参加も難しくなっていると思いますが、そんな中でも寄稿して頂き、感謝の念に堪えません。

今のところ来年の開催は未定ですが、年1ぐらいで何かしら集まってイベントが出来たら嬉しいな……と思ってます。もし参加者がいたらLTとかやりたいです。ネタ溜まりまくってます。他にも何か企画とかあったら教えてください。

本年もありがとうございました!来年もよろしくお願い申し上げます🙇

コメント

タイトルとURLをコピーしました