From dbd542b167d4d75dce807fe9314328c11f2b4476 Mon Sep 17 00:00:00 2001 From: Fang-Kaui <145177672+FangkuaiYa@users.noreply.github.com> Date: Sat, 6 Jun 2026 16:29:55 +0800 Subject: [PATCH] Webhook & Kick Reason update & Fix --- .github/workflows/ci.yml | 43 ++++ Dockerfile | 88 ++++++-- compose.yml | 21 +- src/Empostor.Server/Http/AdminController.cs | 197 +++++++++++++++--- src/Empostor.Server/Http/TokenController.cs | 23 +- .../Net/Manager/CompatibilityManager.cs | 2 +- src/Empostor.Server/Pages/AdminStrings.json | 17 +- src/Empostor.Server/Program.cs | 1 + .../Service/Api/DiscordWebhookListener.cs | 8 +- .../Service/Api/DiscordWebhookStore.cs | 107 ++++++++++ 10 files changed, 445 insertions(+), 62 deletions(-) create mode 100644 src/Empostor.Server/Service/Api/DiscordWebhookStore.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63e382d..5fc1045 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,3 +41,46 @@ jobs: with: files: build/* generate_release_notes: true + + docker: + needs: build + runs-on: ubuntu-latest + if: github.event_name != 'pull_request' + + strategy: + matrix: + arch: + - { docker: linux/amd64, rid: amd64 } + - { docker: linux/arm64, rid: arm64 } + - { docker: linux/arm/v7, rid: arm } + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Build Docker image (${{ matrix.arch.rid }}) + uses: docker/build-push-action@v6 + with: + context: . + platforms: ${{ matrix.arch.docker }} + load: true + tags: empostor-server:${{ matrix.arch.rid }} + build-args: | + VERSIONSUFFIX=docker + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Export Docker image to tar + run: docker save empostor-server:${{ matrix.arch.rid }} -o empostor-server-docker-${{ matrix.arch.rid }}.tar + + - name: Upload Docker image + uses: actions/upload-artifact@v4 + with: + name: empostor-docker-${{ matrix.arch.rid }} + path: empostor-server-docker-${{ matrix.arch.rid }}.tar diff --git a/Dockerfile b/Dockerfile index 7da2602..d46a75f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,36 +1,86 @@ # ============================================ -# Usage: -# 1. Publish the server: -# dotnet publish src/Empostor.Server -c Release -o publish -# 2. Publish plugins: -# dotnet publish src/Empostor.Plugins.Welcome -c Release -o publish/plugins -# (repeat for each plugin) -# 3. Build and run: -# docker compose up -d +# Empostor Server Docker Image # ============================================ +# Multi-stage build: compiles from source, then produces a minimal runtime image. +# +# Build (local): +# docker build -t empostor-server . +# +# Build (multi-arch): +# docker buildx build --platform linux/amd64,linux/arm64 -t empostor-server . +# +# Run: +# docker compose up -d +# ============================================ + +# ── Build stage ──────────────────────────── +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build + +ARG TARGETARCH +ARG VERSIONSUFFIX=docker + +WORKDIR /source + +# Restore dependencies (layer caching) +COPY src/Empostor.Server/Empostor.Server.csproj ./src/Empostor.Server/ +COPY src/Empostor.Api/Empostor.Api.csproj ./src/Empostor.Api/ +COPY src/Empostor.Api.Innersloth.Generator/Empostor.Api.Innersloth.Generator.csproj ./src/Empostor.Api.Innersloth.Generator/ +COPY src/NextFast.Hazel-New/Next.Hazel/Next.Hazel.csproj ./src/NextFast.Hazel-New/Next.Hazel/ +COPY src/NextFast.Hazel-New/Next.Hazel.Abstractions/Next.Hazel.Abstractions.csproj ./src/NextFast.Hazel-New/Next.Hazel.Abstractions/ +COPY src/Directory.Build.props ./src/ + +RUN case "$TARGETARCH" in \ + amd64) RID=linux-x64 ;; \ + arm64) RID=linux-arm64 ;; \ + arm) RID=linux-arm ;; \ + *) echo "Unsupported arch: $TARGETARCH"; exit 1 ;; \ + esac && \ + dotnet restore -r "$RID" ./src/Empostor.Server/Empostor.Server.csproj + +# Copy sources and publish +COPY src/. ./src/ + +RUN case "$TARGETARCH" in \ + amd64) RID=linux-x64 ;; \ + arm64) RID=linux-arm64 ;; \ + arm) RID=linux-arm ;; \ + *) echo "Unsupported arch: $TARGETARCH"; exit 1 ;; \ + esac && \ + [ "$VERSIONSUFFIX" = "none" ] && VERSIONSUFFIX="" ; \ + dotnet publish ./src/Empostor.Server/Empostor.Server.csproj \ + -c Release \ + -r "$RID" \ + --no-restore \ + --self-contained false \ + -p:PublishSingleFile=true \ + -p:PublishTrimmed=false \ + -p:VersionSuffix="$VERSIONSUFFIX" \ + -o /app + +# Copy runtime assets +RUN cp ./src/Empostor.Server/config.json /app/config.json 2>/dev/null || true + +# ── Runtime stage ────────────────────────── FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/aspnet:8.0 WORKDIR /app -# Copy pre-published server output -COPY ./publish/ ./ +# Copy published output from build stage +COPY --from=build /app ./ -# Create runtime directories -RUN mkdir -p /app/plugins /app/libraries /app/Languages /app/Log /app/config /app/Data - -# Copy plugins from build output -COPY ./plugins/ ./plugins/ 2>/dev/null || true - -# Copy marketplace data -COPY ./marketplace/ ./marketplace/ 2>/dev/null || true +# Create writable runtime directories +RUN mkdir -p /app/plugins /app/libraries /app/Languages /app/Log /app/Data /app/marketplace \ + && chmod 777 /app/plugins /app/libraries /app/Languages /app/Log /app/Data /app/marketplace +# Environment ENV ASPNETCORE_URLS=http://0.0.0.0:80 ENV EMPOSTOR_Server__PublicIp=0.0.0.0 +ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 EXPOSE 80/tcp EXPOSE 22023/tcp EXPOSE 22023/udp -VOLUME ["/app/config", "/app/plugins", "/app/Languages", "/app/Log", "/app/Data"] +VOLUME ["/app/config", "/app/plugins", "/app/libraries", "/app/Languages", "/app/Log", "/app/Data", "/app/marketplace"] ENTRYPOINT ["./Empostor.Server"] diff --git a/compose.yml b/compose.yml index 9227831..f862d7f 100644 --- a/compose.yml +++ b/compose.yml @@ -5,16 +5,31 @@ services: dockerfile: Dockerfile container_name: empostor-server restart: unless-stopped + ports: - "22023:22023/tcp" - "22023:22023/udp" + # Uncomment to expose admin HTTP panel: + # - "80:80/tcp" + volumes: + # Server config (can be read-only if you only edit via file) - ./config.json:/app/config.json:ro + # Writable runtime data + - empostor_plugins:/app/plugins + - empostor_libraries:/app/libraries + - empostor_languages:/app/Languages - ./Log:/app/Log - - ./plugins:/app/plugins - - ./Languages:/app/Languages - - ./marketplace:/app/marketplace - ./Data:/app/Data + - ./marketplace:/app/marketplace + # Per-component configs (writable from admin panel) + - ./discord_webhook.json:/app/discord_webhook.json + environment: - ASPNETCORE_URLS=http://0.0.0.0:80 - EMPOSTOR_Server__PublicIp=0.0.0.0 + +volumes: + empostor_plugins: + empostor_libraries: + empostor_languages: diff --git a/src/Empostor.Server/Http/AdminController.cs b/src/Empostor.Server/Http/AdminController.cs index 804c669..e23d00b 100644 --- a/src/Empostor.Server/Http/AdminController.cs +++ b/src/Empostor.Server/Http/AdminController.cs @@ -16,6 +16,7 @@ using Empostor.Server.Service.Admin.Chat; using Empostor.Server.Service.Admin.Reactor; using Empostor.Server.Service.Admin.Report; using Empostor.Server.Service.Stat; +using Empostor.Server.Service.Api; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -40,6 +41,7 @@ namespace Empostor.Server.Http private readonly PlayerStatsStore _playerStats; private readonly PlayerStatsConfig _statsConfig; private readonly ChatFilterStore _chatFilter; + private readonly DiscordWebhookStore _discordWebhook; public AdminController( ILogger logger, @@ -51,7 +53,8 @@ namespace Empostor.Server.Http PlayerLogStore playerLogs, PlayerStatsStore playerStats, IOptions statsConfig, - ChatFilterStore chatFilter) + ChatFilterStore chatFilter, + DiscordWebhookStore discordWebhook) { _logger = logger; _gameManager = gameManager; @@ -63,6 +66,7 @@ namespace Empostor.Server.Http _playerStats = playerStats; _statsConfig = statsConfig.Value; _chatFilter = chatFilter; + _discordWebhook = discordWebhook; } private static Dictionary LoadStrings() @@ -260,10 +264,8 @@ namespace Empostor.Server.Http { await c.Player.KickAsync(); } - else - { - await c.DisconnectAsync(DisconnectReason.Kicked, "Kicked by admin"); - } + + await c.DisconnectAsync(DisconnectReason.Custom, req.Reason ?? "Kicked by admin"); return Ok(new { kicked = true, name = c.Name }); } @@ -292,20 +294,17 @@ namespace Empostor.Server.Http { await c.Player.BanAsync(); } - else - { - await c.DisconnectAsync(DisconnectReason.Banned, "Banned by admin"); - } + await c.DisconnectAsync(DisconnectReason.Custom, req.Reason ?? "Banned by admin"); kicked++; } - } - - return Ok(new { banned = entry.Value, disconnected = kicked }); } - [HttpPost("/api/admin/ban/fc")] - public async Task BanFc([FromBody] BanFcReq req) + return Ok(new { banned = entry.Value, disconnected = kicked }); + } + + [HttpPost("/api/admin/ban/fc")] + public async Task BanFc([FromBody] BanFcReq req) { if (!IsAuthenticated()) { @@ -327,20 +326,17 @@ namespace Empostor.Server.Http { await c.Player.BanAsync(); } - else - { - await c.DisconnectAsync(DisconnectReason.Banned, "Banned by admin"); - } + await c.DisconnectAsync(DisconnectReason.Custom, req.Reason ?? "Banned by admin"); kicked++; } - } - - return Ok(new { banned = entry.Value, disconnected = kicked }); } - [HttpPost("/api/admin/unban/ip")] - public IActionResult UnbanIp([FromBody] UnbanReq req) + return Ok(new { banned = entry.Value, disconnected = kicked }); + } + + [HttpPost("/api/admin/unban/ip")] + public IActionResult UnbanIp([FromBody] UnbanReq req) { if (!IsAuthenticated()) { @@ -646,6 +642,83 @@ namespace Empostor.Server.Http }); } + [HttpGet("/api/admin/discord")] + public IActionResult GetDiscordWebhook() + { + if (!IsAuthenticated()) + { + return Unauthorized(); + } + + return Ok(new + { + enabled = _discordWebhook.Enabled, + webhookUrl = _discordWebhook.WebhookUrl, + notifyOnGameCreated = _discordWebhook.NotifyOnGameCreated, + notifyOnBan = _discordWebhook.NotifyOnBan, + notifyOnReport = _discordWebhook.NotifyOnReport, + notifyOnPlayerJoin = _discordWebhook.NotifyOnPlayerJoin, + notifyOnGameEnded = _discordWebhook.NotifyOnGameEnded, + }); + } + + [HttpPost("/api/admin/discord")] + public async Task UpdateDiscordWebhook([FromBody] DiscordWebhookSettingsReq req) + { + if (!IsAuthenticated()) + { + return Unauthorized(); + } + + if (req.Enabled.HasValue) + { + _discordWebhook.Enabled = req.Enabled.Value; + } + + if (req.WebhookUrl != null) + { + _discordWebhook.WebhookUrl = req.WebhookUrl; + } + + if (req.NotifyOnGameCreated.HasValue) + { + _discordWebhook.NotifyOnGameCreated = req.NotifyOnGameCreated.Value; + } + + if (req.NotifyOnBan.HasValue) + { + _discordWebhook.NotifyOnBan = req.NotifyOnBan.Value; + } + + if (req.NotifyOnReport.HasValue) + { + _discordWebhook.NotifyOnReport = req.NotifyOnReport.Value; + } + + if (req.NotifyOnPlayerJoin.HasValue) + { + _discordWebhook.NotifyOnPlayerJoin = req.NotifyOnPlayerJoin.Value; + } + + if (req.NotifyOnGameEnded.HasValue) + { + _discordWebhook.NotifyOnGameEnded = req.NotifyOnGameEnded.Value; + } + + await _discordWebhook.SaveAsync(); + + return Ok(new + { + enabled = _discordWebhook.Enabled, + webhookUrl = _discordWebhook.WebhookUrl, + notifyOnGameCreated = _discordWebhook.NotifyOnGameCreated, + notifyOnBan = _discordWebhook.NotifyOnBan, + notifyOnReport = _discordWebhook.NotifyOnReport, + notifyOnPlayerJoin = _discordWebhook.NotifyOnPlayerJoin, + notifyOnGameEnded = _discordWebhook.NotifyOnGameEnded, + }); + } + private IGame? FindGame(string code) { try { return _gameManager.Find(new GameCode(code.ToUpperInvariant())); } @@ -726,7 +799,7 @@ namespace Empostor.Server.Http public sealed record GameMsgReq(string GameCode, string Message); - public sealed record ClientIdReq(int ClientId); + public sealed record ClientIdReq(int ClientId, string? Reason = null); public sealed record BanIpReq(string Ip, string? Reason); @@ -742,6 +815,8 @@ namespace Empostor.Server.Http public sealed record ChatFilterSettingsReq(bool? Enabled, bool? BlockMessage, int? SpamThreshold, int? SpamWindowSeconds); + public sealed record DiscordWebhookSettingsReq(bool? Enabled, string? WebhookUrl, bool? NotifyOnGameCreated, bool? NotifyOnBan, bool? NotifyOnReport, bool? NotifyOnPlayerJoin, bool? NotifyOnGameEnded); + private const string LoginHtml = """ @@ -1402,6 +1477,7 @@ namespace Empostor.Server.Http
Player Logs
Statistics
Chat Filter
+
Discord
Actions
Broadcast
@@ -1516,6 +1592,7 @@ namespace Empostor.Server.Http

Kick by Client ID

+
@@ -1745,6 +1822,46 @@ namespace Empostor.Server.Http
+
+
+

Discord Webhook

+ +
+ +
+
+ + +
+

Notifications

+
+ + + + + +
+
+
+
@@ -2082,6 +2199,34 @@ namespace Empostor.Server.Http } } + async function fDiscordWebhook() { + const { data } = await api('GET', '/api/admin/discord'); + document.getElementById('dw-enabled').checked = data.enabled; + document.getElementById('dw-url').value = data.webhookUrl || ''; + document.getElementById('dw-game-created').checked = data.notifyOnGameCreated; + document.getElementById('dw-ban').checked = data.notifyOnBan; + document.getElementById('dw-report').checked = data.notifyOnReport; + document.getElementById('dw-player-join').checked = data.notifyOnPlayerJoin; + document.getElementById('dw-game-ended').checked = data.notifyOnGameEnded; + } + + async function saveDwSettings() { + const { ok, data } = await api('POST', '/api/admin/discord', { + enabled: document.getElementById('dw-enabled').checked, + webhookUrl: document.getElementById('dw-url').value.trim(), + notifyOnGameCreated: document.getElementById('dw-game-created').checked, + notifyOnBan: document.getElementById('dw-ban').checked, + notifyOnReport: document.getElementById('dw-report').checked, + notifyOnPlayerJoin: document.getElementById('dw-player-join').checked, + notifyOnGameEnded: document.getElementById('dw-game-ended').checked + }); + if (ok) { + msg('dw-r', true, _('discord.saved', 'Discord webhook settings saved.')); + } else { + msg('dw-r', false, data.error ?? 'Error'); + } + } + function refreshTab() { if (cur === 'ov') fGames('ov-t', true); if (cur === 'gm') fGames('gm-t', false); @@ -2092,6 +2237,7 @@ namespace Empostor.Server.Http if (cur === 'pl') fPlayerLogs(); if (cur === 'st') fStats(); if (cur === 'cf') fChatFilter(); + if (cur === 'dw') fDiscordWebhook(); } async function doBc() { @@ -2104,7 +2250,8 @@ namespace Empostor.Server.Http async function doKick() { const id = parseInt(document.getElementById('ki-id').value); if (!id) return msg('ki-r', false, _('kick.placeholder', 'Enter client ID')); - const { ok, data } = await api('POST', '/api/admin/kick', { clientId: id }); + const reason = document.getElementById('ki-reason').value.trim(); + const { ok, data } = await api('POST', '/api/admin/kick', { clientId: id, reason: reason || undefined }); msg('ki-r', ok, ok ? _('alert.kicked', 'Kicked {0}').replace('{0}', data.name) : (data.error ?? 'Error')); if (ok) fKickList(); } diff --git a/src/Empostor.Server/Http/TokenController.cs b/src/Empostor.Server/Http/TokenController.cs index 957eec9..38cbba5 100644 --- a/src/Empostor.Server/Http/TokenController.cs +++ b/src/Empostor.Server/Http/TokenController.cs @@ -251,20 +251,27 @@ public sealed class TokenController : ControllerBase { lock (FileLock) { - Directory.CreateDirectory(CacheFileDir); - - if (System.IO.File.Exists(CacheFilePath)) + try { - foreach (var line in System.IO.File.ReadLines(CacheFilePath)) + Directory.CreateDirectory(CacheFileDir); + + if (System.IO.File.Exists(CacheFilePath)) { - if (line.StartsWith(productUserId + "=", StringComparison.Ordinal)) + foreach (var line in System.IO.File.ReadLines(CacheFilePath)) { - return; + if (line.StartsWith(productUserId + "=", StringComparison.Ordinal)) + { + return; + } } } - } - System.IO.File.AppendAllText(CacheFilePath, $"{productUserId}={friendCode}{Environment.NewLine}"); + System.IO.File.AppendAllText(CacheFilePath, $"{productUserId}={friendCode}{Environment.NewLine}"); + } + catch + { + // Best-effort cache — silently ignore write failures (e.g. Docker permission) + } } } diff --git a/src/Empostor.Server/Net/Manager/CompatibilityManager.cs b/src/Empostor.Server/Net/Manager/CompatibilityManager.cs index 4b224a6..0267d6b 100644 --- a/src/Empostor.Server/Net/Manager/CompatibilityManager.cs +++ b/src/Empostor.Server/Net/Manager/CompatibilityManager.cs @@ -41,8 +41,8 @@ internal class CompatibilityManager : ICompatibilityManager new GameVersion(2026, 2, 2), // 17.2.2 (2026-03-17, build 6768) new GameVersion(2026, 1, 12), // 17.3 (2026-03-31, build 6803) new GameVersion(2026, 3, 17), // 17.3.1 (2026-04-08, build 6841, mobile only) - new GameVersion(2026, 3, 17), // 17.3.1 (2026-04-08, build 6841, mobile only) new GameVersion(2026, 3, 18), // 17.4 (2026-06-05, build 7044, pc only) + new GameVersion(2026, 4, 23), // 17.4 (2026-06-05, build 7045, mobile only) }, }; diff --git a/src/Empostor.Server/Pages/AdminStrings.json b/src/Empostor.Server/Pages/AdminStrings.json index 91db38b..0a9ed95 100644 --- a/src/Empostor.Server/Pages/AdminStrings.json +++ b/src/Empostor.Server/Pages/AdminStrings.json @@ -29,6 +29,7 @@ "nav.player_logs": "Player Logs", "nav.statistics": "Statistics", "nav.chat_filter": "Chat Filter", + "nav.discord": "Discord", "nav.reload_lang": "Reload Lang", "status.games": "Games", @@ -68,6 +69,7 @@ "kick.title": "Kick by Client ID", "kick.placeholder": "Client ID", + "kick.reason_placeholder": "Reason (optional)", "kick.button": "Kick", "kick.quick": "Quick Kick", @@ -210,5 +212,18 @@ "chatfilter.save": "Save Settings", "chatfilter.saved": "Settings saved.", "chatfilter.word_added": "Word added.", - "chatfilter.word_removed": "Word removed." + "chatfilter.word_removed": "Word removed.", + + "discord.title": "Discord Webhook", + "discord.save": "Save Settings", + "discord.enabled": "Enable Discord Webhook", + "discord.webhook_url": "Webhook URL", + "discord.webhook_url_placeholder": "https://discord.com/api/webhooks/...", + "discord.notifications": "Notifications", + "discord.notify_game_created": "Game Created", + "discord.notify_ban": "Player Banned", + "discord.notify_report": "Player Reported", + "discord.notify_player_join": "Player Joined", + "discord.notify_game_ended": "Game Ended", + "discord.saved": "Discord webhook settings saved." } diff --git a/src/Empostor.Server/Program.cs b/src/Empostor.Server/Program.cs index 0410b6d..7239cf7 100644 --- a/src/Empostor.Server/Program.cs +++ b/src/Empostor.Server/Program.cs @@ -175,6 +175,7 @@ namespace Empostor.Server services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Empostor.Server/Service/Api/DiscordWebhookListener.cs b/src/Empostor.Server/Service/Api/DiscordWebhookListener.cs index 0f9c6d6..2cd59ed 100644 --- a/src/Empostor.Server/Service/Api/DiscordWebhookListener.cs +++ b/src/Empostor.Server/Service/Api/DiscordWebhookListener.cs @@ -6,11 +6,9 @@ using System.Net.Http; using System.Text; using System.Text.Json; using System.Threading.Tasks; -using Empostor.Api.Config; using Empostor.Api.Events; using Empostor.Api.Events.Player; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; namespace Empostor.Server.Service.Api; @@ -20,17 +18,17 @@ internal sealed class DiscordWebhookListener : IEventListener private readonly ILogger _logger; private readonly IHttpClientFactory _http; - private readonly DiscordWebhookConfig _config; + private readonly DiscordWebhookStore _config; private readonly ConcurrentDictionary _throttle = new(); public DiscordWebhookListener( ILogger logger, IHttpClientFactory http, - IOptions config) + DiscordWebhookStore config) { _logger = logger; _http = http; - _config = config.Value; + _config = config; } [EventListener] diff --git a/src/Empostor.Server/Service/Api/DiscordWebhookStore.cs b/src/Empostor.Server/Service/Api/DiscordWebhookStore.cs new file mode 100644 index 0000000..1e763d8 --- /dev/null +++ b/src/Empostor.Server/Service/Api/DiscordWebhookStore.cs @@ -0,0 +1,107 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Empostor.Api.Config; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Empostor.Server.Service.Api; + +public sealed class DiscordWebhookStore +{ + private static readonly string ConfigFile = Path.Combine(Directory.GetCurrentDirectory(), "discord_webhook.json"); + private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = true }; + + private readonly ILogger _logger; + private readonly SemaphoreSlim _saveLock = new(1, 1); + + public DiscordWebhookStore(ILogger logger, IOptions config) + { + _logger = logger; + + if (File.Exists(ConfigFile)) + { + try + { + var json = File.ReadAllText(ConfigFile); + var saved = JsonSerializer.Deserialize(json); + if (saved != null) + { + Enabled = saved.Enabled; + WebhookUrl = saved.WebhookUrl; + NotifyOnGameCreated = saved.NotifyOnGameCreated; + NotifyOnBan = saved.NotifyOnBan; + NotifyOnReport = saved.NotifyOnReport; + NotifyOnPlayerJoin = saved.NotifyOnPlayerJoin; + NotifyOnGameEnded = saved.NotifyOnGameEnded; + _logger.LogInformation("[DiscordWebhook] Loaded from {File}", ConfigFile); + return; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[DiscordWebhook] Failed to load {File}, using defaults", ConfigFile); + } + } + + // Fall back to config.json values + var cfg = config.Value; + Enabled = cfg.Enabled; + WebhookUrl = cfg.WebhookUrl; + NotifyOnGameCreated = cfg.NotifyOnGameCreated; + NotifyOnBan = cfg.NotifyOnBan; + NotifyOnReport = cfg.NotifyOnReport; + NotifyOnPlayerJoin = cfg.NotifyOnPlayerJoin; + NotifyOnGameEnded = cfg.NotifyOnGameEnded; + } + + public bool Enabled { get; set; } + + public string WebhookUrl { get; set; } = string.Empty; + + public bool NotifyOnGameCreated { get; set; } = true; + + public bool NotifyOnBan { get; set; } = true; + + public bool NotifyOnReport { get; set; } = true; + + public bool NotifyOnPlayerJoin { get; set; } + + public bool NotifyOnGameEnded { get; set; } + + public DiscordWebhookConfig Snapshot => new() + { + Enabled = Enabled, + WebhookUrl = WebhookUrl, + NotifyOnGameCreated = NotifyOnGameCreated, + NotifyOnBan = NotifyOnBan, + NotifyOnReport = NotifyOnReport, + NotifyOnPlayerJoin = NotifyOnPlayerJoin, + NotifyOnGameEnded = NotifyOnGameEnded, + }; + + public async ValueTask SaveAsync() + { + if (!await _saveLock.WaitAsync(0)) + { + return; + } + + try + { + var json = JsonSerializer.Serialize(Snapshot, JsonOpts); + await File.WriteAllTextAsync(ConfigFile, json); + _logger.LogInformation("[DiscordWebhook] Saved to {File}", ConfigFile); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[DiscordWebhook] Failed to save {File}", ConfigFile); + } + finally + { + _saveLock.Release(); + } + } +}