This commit is contained in:
linmeideli committed 2026-10-01 17:37:23 +08:00
1 parent cb3d78ace8
commit 8883529f0c
18 files changed
+971 -52

No files matched your search

Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -57,6 +57,8 @@ public sealed class AdminPanelBuilder
public AdminPanelBuilder RegisterChips(Action<AdminChips> configure) => Register(configure);
public AdminPanelBuilder RegisterEntries(Action<AdminEntries> configure) => Register(configure);
public AdminPanelBuilder RegisterDivider()
{
Widgets.Add(new AdminDivider());
@@ -96,6 +98,27 @@ public sealed class AdminPanelBuilder
}
}
if (widget is AdminEntries entries)
{
if (entries.AddHandler != null)
{
entries.AddAction = NextAction();
_actions[entries.AddAction] = entries.AddHandler;
}
if (entries.EditHandler != null)
{
entries.EditAction = NextAction();
_actions[entries.EditAction] = entries.EditHandler;
}
if (entries.RemoveHandler != null)
{
entries.RemoveAction = NextAction();
_actions[entries.RemoveAction] = entries.RemoveHandler;
}
}
if (widget is AdminBlock block)
{
foreach (var child in block.Children)
+62
View File
@@ -14,6 +14,7 @@ namespace Empostor.Api.Admin;
[JsonDerivedType(typeof(AdminTable), "table")]
[JsonDerivedType(typeof(AdminChips), "chips")]
[JsonDerivedType(typeof(AdminDivider), "divider")]
[JsonDerivedType(typeof(AdminEntries), "entries")]
public abstract class AdminWidget
{
public string? Action { get; set; }
@@ -163,6 +164,67 @@ public sealed class AdminDivider : AdminWidget
{
}
/// <summary>One editable column of an <see cref="AdminEntries"/> widget.</summary>
public sealed class AdminEntryField
{
/// <summary>Row value key. The <b>first</b> field is the row identity used by edit/remove actions.</summary>
public string Key { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public bool Monospace { get; set; }
}
/// <summary>
/// An editable list of structured entries (search + add + per-row edit/delete), backed by
/// three actions: add (payload carries the new row), edit (value = old identity, payload carries
/// the replacement row) and remove (value = row identity).
/// </summary>
public sealed class AdminEntries : AdminWidget
{
public List<AdminEntryField> Fields { get; set; } = new();
/// <summary>Rows keyed by field key; the first field's value identifies the row.</summary>
public List<Dictionary<string, string>> Rows { get; set; } = new();
public string? SearchPlaceholder { get; set; }
public string? AddLabel { get; set; }
public string AddAction { get; set; } = string.Empty;
public string EditAction { get; set; } = string.Empty;
public string RemoveAction { get; set; } = string.Empty;
[JsonIgnore]
public AdminAction? AddHandler { get; set; }
[JsonIgnore]
public AdminAction? EditHandler { get; set; }
[JsonIgnore]
public AdminAction? RemoveHandler { get; set; }
public AdminEntries OnAdd(AdminAction handler)
{
AddHandler = handler;
return this;
}
public AdminEntries OnEdit(AdminAction handler)
{
EditHandler = handler;
return this;
}
public AdminEntries OnRemove(AdminAction handler)
{
RemoveHandler = handler;
return this;
}
}
public sealed record AdminOption(string Label, string Value);
public sealed class AdminTableCell
@@ -1,3 +1,4 @@
using System;
using Empostor.Api.Admin;
namespace Empostor.Plugins.DiscordWebhook;
@@ -47,5 +48,50 @@ public sealed class DiscordWebhookAdminExtension : IAdminExtension
return AdminActionResult.Ok("Admin webhook saved.");
}));
});
b.RegisterBlock(block =>
{
block.Title = "Live room tracking";
block.Description = "With live room updates on, each lobby gets one Discord message that is edited as the "
+ "lobby changes and deleted when the lobby closes. Player Banned / Player Reported always post "
+ "one-shot messages to the Admin URL.";
block.Children.Add(new AdminToggle
{
Label = "Live room updates",
Value = _store.LiveRoomUpdates,
}.OnChange(async ctx =>
{
_store.LiveRoomUpdates = string.Equals(ctx.Value, "true", StringComparison.OrdinalIgnoreCase);
await _store.SaveAsync();
return AdminActionResult.Ok($"Live room updates {(_store.LiveRoomUpdates ? "enabled" : "disabled")}.");
}));
block.Children.Add(new AdminToggle
{
Label = "Delete the message when the lobby closes",
Value = _store.DeleteOnClose,
}.OnChange(async ctx =>
{
_store.DeleteOnClose = string.Equals(ctx.Value, "true", StringComparison.OrdinalIgnoreCase);
await _store.SaveAsync();
return AdminActionResult.Ok($"Delete on close {(_store.DeleteOnClose ? "enabled" : "disabled")}.");
}));
block.Children.Add(new AdminNumber
{
Label = "Minimum seconds between two live updates",
Value = _store.MinUpdateIntervalSeconds,
Min = 1,
Max = 60,
}.OnChange(async ctx =>
{
_store.MinUpdateIntervalSeconds = int.TryParse(ctx.Value, out var seconds)
? Math.Clamp(seconds, 1, 60)
: 3;
await _store.SaveAsync();
return AdminActionResult.Ok($"Minimum live update interval set to {_store.MinUpdateIntervalSeconds}s.");
}));
});
}
}
@@ -8,4 +8,16 @@ public sealed class DiscordWebhookConfig
/// <summary>Legacy single-webhook fallback used when both URLs are empty.</summary>
public string WebhookUrl { get; set; } = string.Empty;
/// <summary>
/// Mirror each lobby with one persistent message that gets edited as the lobby changes,
/// instead of posting a separate message per event.
/// </summary>
public bool LiveRoomUpdates { get; set; } = true;
/// <summary>Delete the live message when its lobby is destroyed.</summary>
public bool DeleteOnClose { get; set; } = true;
/// <summary>Minimum seconds between two edits of the same lobby message (Discord rate limits).</summary>
public int MinUpdateIntervalSeconds { get; set; } = 3;
}
@@ -1,4 +1,5 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
@@ -7,6 +8,7 @@ using System.Text.Json;
using System.Threading.Tasks;
using Empostor.Api.Events;
using Empostor.Api.Events.Player;
using Empostor.Api.Games;
using Empostor.Api.Innersloth;
using Microsoft.Extensions.Logging;
@@ -14,29 +16,46 @@ namespace Empostor.Plugins.DiscordWebhook;
public sealed class DiscordWebhookListener : IEventListener
{
private const int ColorLobby = 3447003;
private const int ColorInGame = 3066993;
private const int ColorEnded = 15105570;
private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = false };
private readonly ILogger<DiscordWebhookListener> _logger;
private readonly IHttpClientFactory _http;
private readonly DiscordWebhookStore _config;
private readonly LiveGameTracker _tracker;
// Per-lobby edit throttle, plus a marker so skipped updates are flushed once instead of lost.
private readonly ConcurrentDictionary<int, DateTime> _lastEdit = new();
private readonly ConcurrentDictionary<int, byte> _flushScheduled = new();
public DiscordWebhookListener(
ILogger<DiscordWebhookListener> logger,
IHttpClientFactory http,
DiscordWebhookStore config)
DiscordWebhookStore config,
LiveGameTracker tracker)
{
_logger = logger;
_http = http;
_config = config;
_tracker = tracker;
}
// ---- Live mode: one persistent message per lobby, edited as it changes ----
[EventListener]
public void OnGameCreated(IGameCreatedEvent e)
{
var url = _config.MatchmakerUrl;
if (string.IsNullOrWhiteSpace(url)) return;
if (_config.LiveRoomUpdates)
{
// The live message is created on the first player join instead: at creation time the
// host has not joined yet, so the embed would be born as an empty 0/15 lobby.
return;
}
_ = SendAsync(url, "Game Created", 3066993, new()
SendOneShot("Game Created", ColorLobby, new()
{
["Host"] = e.Host?.Name ?? "—",
["Game"] = GameCodeParser.IntToGameName(e.Game.Code),
@@ -50,68 +69,84 @@ public sealed class DiscordWebhookListener : IEventListener
[EventListener]
public void OnGameStarted(IGameStartedEvent e)
{
var url = _config.MatchmakerUrl;
if (string.IsNullOrWhiteSpace(url)) return;
_ = SendAsync(url, "Game Started", 3447003, new()
{
["Game"] = GameCodeParser.IntToGameName(e.Game.Code),
["Map"] = e.Game.Options.Map.ToString(),
["Players"] = e.Game.PlayerCount.ToString(),
["Impostors"] = e.Game.Options.NumImpostors.ToString(),
});
if (!_config.LiveRoomUpdates) { SendOneShot("Game Started", ColorInGame, LegacyFields(e.Game)); return; }
_ = UpdateLobbyAsync(e.Game, force: true);
}
[EventListener]
public void OnGameEnded(IGameEndedEvent e)
{
var url = _config.MatchmakerUrl;
if (string.IsNullOrWhiteSpace(url)) return;
_ = SendAsync(url, "Game Ended", 10181046, new()
if (!_config.LiveRoomUpdates)
{
["Game"] = GameCodeParser.IntToGameName(e.Game.Code),
["Result"] = e.GameOverReason.ToString(),
["Players"] = e.Game.PlayerCount.ToString(),
});
SendOneShot("Game Ended", ColorEnded, new()
{
["Game"] = GameCodeParser.IntToGameName(e.Game.Code),
["Result"] = e.GameOverReason.ToString(),
["Players"] = e.Game.PlayerCount.ToString(),
});
return;
}
_ = UpdateLobbyAsync(e.Game, force: true, resultOverride: e.GameOverReason.ToString());
}
[EventListener]
public void OnGameDestroyed(IGameDestroyedEvent e)
{
var url = _config.MatchmakerUrl;
if (string.IsNullOrWhiteSpace(url)) return;
_ = SendAsync(url, "Game Destroyed", 15158332, new()
if (!_config.LiveRoomUpdates)
{
["Game"] = GameCodeParser.IntToGameName(e.Game.Code),
["Players"] = e.Game.PlayerCount.ToString(),
});
SendOneShot("Game Destroyed", 15158332, new()
{
["Game"] = GameCodeParser.IntToGameName(e.Game.Code),
["Players"] = e.Game.PlayerCount.ToString(),
});
return;
}
_ = DeleteLiveMessageAsync(e.Game.Code);
}
[EventListener]
public void OnPlayerJoined(IGamePlayerJoinedEvent e)
{
if (!_config.LiveRoomUpdates)
{
return;
}
// The first join is the host creating their own lobby entry — that is the moment the live
// message is born, with a real host name and player count.
if (!_tracker.TryGet(e.Game.Code, out _))
{
_ = CreateLiveMessageAsync(e.Game);
return;
}
_ = UpdateLobbyAsync(e.Game);
}
[EventListener]
public void OnPlayerLeft(IGamePlayerLeftEvent e)
{
if (!e.IsBan) return;
var url = _config.AdminUrl;
if (string.IsNullOrWhiteSpace(url)) return;
_ = SendAsync(url, "Player Banned", 15158332, new()
if (e.IsBan) { SendAdminMessage("Player Banned", 15158332, new()
{
["Player"] = e.Player.Client.Name,
["Friend Code"] = e.Player.Client.FriendCode ?? "—",
["Game"] = GameCodeParser.IntToGameName(e.Game.Code),
});
}); }
_ = UpdateLobbyAsync(e.Game);
}
[EventListener]
public void OnHostChanged(IGameHostChangedEvent e) => _ = UpdateLobbyAsync(e.Game);
// ---- Admin events (always one-shot messages on the admin webhook) ----
[EventListener]
public void OnPlayerReport(IPlayerReportEvent e)
{
var url = _config.AdminUrl;
if (string.IsNullOrWhiteSpace(url)) return;
_ = SendAsync(url, "Player Reported", 16776960, new()
SendAdminMessage("Player Reported", 16776960, new()
{
["Reporter"] = e.ClientPlayer.Client.Name,
["Reporter FC"] = e.ClientPlayer.Client.FriendCode ?? "—",
@@ -122,8 +157,254 @@ public sealed class DiscordWebhookListener : IEventListener
});
}
// ---- Live message lifecycle ----
private async ValueTask CreateLiveMessageAsync(IGame game)
{
var url = _config.MatchmakerUrl;
if (string.IsNullOrWhiteSpace(url))
{
return;
}
try
{
var messageId = await PostLiveMessageAsync(url, BuildLobbyEmbed(game));
if (messageId == null)
{
return;
}
_tracker.Track(new TrackedLobby
{
GameCode = game.Code,
GameCodeName = GameCodeParser.IntToGameName(game.Code),
WebhookUrl = url,
MessageId = messageId,
CreatedAt = DateTime.UtcNow,
Game = game,
});
_lastEdit[game.Code] = DateTime.UtcNow;
_logger.LogInformation(
"DiscordWebhook live message created for {Code} (message {MessageId})",
GameCodeParser.IntToGameName(game.Code), messageId);
// The POST round-trip may have straddled a game start / player change; re-render once
// shortly after creation so the message never stays on stale creation-time data.
_ = Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(1));
if (_tracker.TryGet(game.Code, out var tracked) && tracked.Game != null)
{
await UpdateLobbyAsync(tracked.Game, force: true);
}
});
}
catch (Exception ex)
{
_logger.LogWarning(ex, "DiscordWebhook failed to create the live message");
}
}
private async ValueTask UpdateLobbyAsync(IGame game, bool force = false, string? resultOverride = null)
{
if (!_config.LiveRoomUpdates || !_tracker.TryGet(game.Code, out var lobby))
{
return;
}
var now = DateTime.UtcNow;
var last = _lastEdit.TryGetValue(game.Code, out var previous) ? previous : DateTime.MinValue;
if (!force && now - last < TimeSpan.FromSeconds(Math.Max(1, _config.MinUpdateIntervalSeconds)))
{
ScheduleFlush(game.Code);
return;
}
_lastEdit[game.Code] = now;
try
{
var embed = BuildLobbyEmbed(game, resultOverride);
var gone = await PatchLiveMessageAsync(lobby, embed);
if (gone)
{
// The message was deleted out-of-band (manual cleanup, webhook reset, ...);
// stop tracking instead of hammering a 404 forever.
_tracker.Untrack(game.Code, out _);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "DiscordWebhook failed to update the live message for {Code}", lobby.GameCodeName);
}
}
private async ValueTask DeleteLiveMessageAsync(int gameCode)
{
if (!_tracker.Untrack(gameCode, out var lobby))
{
return;
}
_lastEdit.TryRemove(gameCode, out _);
if (!_config.DeleteOnClose)
{
return;
}
try
{
using var client = _http.CreateClient();
using var response = await client.DeleteAsync($"{TrimUrl(lobby.WebhookUrl)}/messages/{lobby.MessageId}");
_logger.LogInformation(
"DiscordWebhook deleted the live message for {Code} ({Status})", lobby.GameCodeName, (int)response.StatusCode);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "DiscordWebhook failed to delete the live message for {Code}", lobby.GameCodeName);
}
}
/// <summary>Re-renders a lobby whose edit was skipped by the throttle.</summary>
private void ScheduleFlush(int gameCode)
{
if (!_flushScheduled.TryAdd(gameCode, 0))
{
return;
}
_ = Task.Run(async () =>
{
try
{
await Task.Delay(TimeSpan.FromSeconds(Math.Max(1, _config.MinUpdateIntervalSeconds)) + TimeSpan.FromMilliseconds(250));
_flushScheduled.TryRemove(gameCode, out _);
if (_tracker.TryGet(gameCode, out var lobby) && lobby.Game != null)
{
await UpdateLobbyAsync(lobby.Game, force: true);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "DiscordWebhook deferred flush failed");
}
});
}
// ---- Discord payload / transport ----
private static object BuildLobbyEmbed(IGame game, string? resultOverride = null)
{
var code = GameCodeParser.IntToGameName(game.Code);
var (status, color) = game.GameState switch
{
GameStates.Started => ("In Game", ColorInGame),
GameStates.Ended => ("Ended", ColorEnded),
GameStates.Destroyed => ("Closed", ColorEnded),
_ => ("In Lobby", ColorLobby),
};
var fields = new List<object>
{
new { name = "Status", value = status, inline = true },
new { name = "Host", value = game.Host?.Client.Name ?? "—", inline = true },
new { name = "Map", value = game.Options.Map.ToString(), inline = true },
new { name = "Players", value = $"{game.PlayerCount}/{game.Options.MaxPlayers}", inline = true },
new { name = "Impostors", value = game.Options.NumImpostors.ToString(), inline = true },
};
if (resultOverride != null)
{
fields.Add(new { name = "Result", value = resultOverride, inline = false });
}
return new
{
embeds = new[]
{
new
{
title = $"Lobby {code}",
color,
fields,
timestamp = DateTime.UtcNow.ToString("o"),
footer = new { text = "Empostor" },
},
},
};
}
private async ValueTask<string?> PostLiveMessageAsync(string url, object embed)
{
var json = JsonSerializer.Serialize(embed, JsonOpts);
using var client = _http.CreateClient();
using var response = await client.PostAsync(
TrimUrl(url) + (url.Contains('?') ? "&" : "?") + "wait=true",
new StringContent(json, Encoding.UTF8, "application/json"));
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("DiscordWebhook live message create returned {Status}", (int)response.StatusCode);
return null;
}
var body = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(body);
return doc.RootElement.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.String
? id.GetString()
: null;
}
/// <summary>Returns true when the message no longer exists (404).</summary>
private async ValueTask<bool> PatchLiveMessageAsync(TrackedLobby lobby, object embed)
{
var json = JsonSerializer.Serialize(embed, JsonOpts);
using var client = _http.CreateClient();
using var response = await client.PatchAsync(
$"{TrimUrl(lobby.WebhookUrl)}/messages/{lobby.MessageId}",
new StringContent(json, Encoding.UTF8, "application/json"));
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return true;
}
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("DiscordWebhook live message update returned {Status}", (int)response.StatusCode);
}
return false;
}
// ---- Shared helpers ----
private static Dictionary<string, string> LegacyFields(IGame game) => new()
{
["Game"] = GameCodeParser.IntToGameName(game.Code),
["Map"] = game.Options.Map.ToString(),
["Players"] = game.PlayerCount.ToString(),
["Impostors"] = game.Options.NumImpostors.ToString(),
};
private static string TrimUrl(string url) => url.TrimEnd('/');
private void SendOneShot(string title, int color, Dictionary<string, string> fields)
=> _ = SendAsync(_config.MatchmakerUrl, title, color, fields);
private void SendAdminMessage(string title, int color, Dictionary<string, string> fields)
=> _ = SendAsync(_config.AdminUrl, title, color, fields);
private async ValueTask SendAsync(string url, string title, int color, Dictionary<string, string> fields)
{
if (string.IsNullOrWhiteSpace(url))
{
return;
}
try
{
var embed = new
@@ -13,8 +13,10 @@ public sealed class DiscordWebhookPluginStartup : IPluginStartup
public void ConfigureServices(IServiceCollection services)
{
services.AddHttpClient();
services.AddSingleton<LiveGameTracker>();
services.AddSingleton<DiscordWebhookStore>();
services.AddSingleton<IEventListener, DiscordWebhookListener>();
services.AddSingleton<IAdminExtension, DiscordWebhookAdminExtension>();
services.AddHostedService<WebhookOrphanCleanup>();
}
}
@@ -30,10 +30,19 @@ public sealed class DiscordWebhookStore : JsonDataStore<DiscordWebhookConfig>
public string AdminUrl { get; set; } = string.Empty;
public bool LiveRoomUpdates { get; set; } = true;
public bool DeleteOnClose { get; set; } = true;
public int MinUpdateIntervalSeconds { get; set; } = 3;
public DiscordWebhookConfig Snapshot => new()
{
MatchmakerUrl = MatchmakerUrl,
AdminUrl = AdminUrl,
LiveRoomUpdates = LiveRoomUpdates,
DeleteOnClose = DeleteOnClose,
MinUpdateIntervalSeconds = MinUpdateIntervalSeconds,
};
protected override DiscordWebhookConfig GetSnapshot() => Snapshot;
@@ -42,6 +51,9 @@ public sealed class DiscordWebhookStore : JsonDataStore<DiscordWebhookConfig>
{
MatchmakerUrl = data.MatchmakerUrl;
AdminUrl = data.AdminUrl;
LiveRoomUpdates = data.LiveRoomUpdates;
DeleteOnClose = data.DeleteOnClose;
MinUpdateIntervalSeconds = data.MinUpdateIntervalSeconds;
}
public new async ValueTask SaveAsync() => await base.SaveAsync();
@@ -0,0 +1,84 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using Empostor.Api.Games;
using Empostor.Api.Service;
using Microsoft.Extensions.Logging;
namespace Empostor.Plugins.DiscordWebhook;
/// <summary>One live Discord message that mirrors a lobby's state.</summary>
public sealed class TrackedLobby
{
public int GameCode { get; init; }
public string GameCodeName { get; init; } = string.Empty;
public string WebhookUrl { get; init; } = string.Empty;
public string MessageId { get; init; } = string.Empty;
public DateTime CreatedAt { get; init; }
/// <summary>Live reference so deferred updates can render the current state. Never persisted.</summary>
[JsonIgnore]
public IGame? Game { get; set; }
}
/// <summary>
/// Maps live lobbies to the Discord message mirroring them. The mapping is persisted so that
/// messages orphaned by a server restart can be deleted on the next start — lobbies never
/// survive a restart, so every tracked message found at startup is stale by definition.
/// </summary>
public sealed class LiveGameTracker : JsonDataStore<List<TrackedLobby>>
{
private readonly ConcurrentDictionary<int, TrackedLobby> _tracked = new();
public LiveGameTracker(ILogger<LiveGameTracker> logger)
: base(logger)
{
Load();
}
public void Track(TrackedLobby lobby)
{
_tracked[lobby.GameCode] = lobby;
SaveFireAndForget();
}
public bool TryGet(int gameCode, out TrackedLobby lobby)
=> _tracked.TryGetValue(gameCode, out lobby!);
public bool Untrack(int gameCode, out TrackedLobby lobby)
{
var removed = _tracked.TryRemove(gameCode, out var removedLobby);
lobby = removedLobby!;
if (removed)
{
SaveFireAndForget();
}
return removed;
}
public IReadOnlyCollection<TrackedLobby> All => _tracked.Values.ToList().AsReadOnly();
public void Clear()
{
_tracked.Clear();
SaveFireAndForget();
}
protected override List<TrackedLobby> GetSnapshot() => _tracked.Values.ToList();
protected override void ApplySnapshot(List<TrackedLobby> data)
{
_tracked.Clear();
foreach (var lobby in data)
{
_tracked[lobby.GameCode] = lobby;
}
}
}
@@ -0,0 +1,63 @@
using System;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Empostor.Plugins.DiscordWebhook;
public sealed class WebhookOrphanCleanup : IHostedService
{
private readonly LiveGameTracker _tracker;
private readonly IHttpClientFactory _http;
private readonly ILogger<WebhookOrphanCleanup> _logger;
public WebhookOrphanCleanup(
LiveGameTracker tracker,
IHttpClientFactory http,
ILogger<WebhookOrphanCleanup> logger)
{
_tracker = tracker;
_http = http;
_logger = logger;
}
public Task StartAsync(CancellationToken cancellationToken)
{
var orphans = _tracker.All.ToList();
if (orphans.Count == 0)
{
return Task.CompletedTask;
}
_logger.LogInformation(
"DiscordWebhook deleting {Count} orphaned lobby message(s) left over from the previous run", orphans.Count);
_tracker.Clear();
_ = Task.Run(async () =>
{
using var client = _http.CreateClient();
foreach (var lobby in orphans)
{
try
{
using var response = await client.DeleteAsync(
$"{lobby.WebhookUrl.TrimEnd('/')}/messages/{lobby.MessageId}");
_logger.LogInformation(
"DiscordWebhook orphan cleanup for {Code}: {Status}",
lobby.GameCodeName, (int)response.StatusCode);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "DiscordWebhook orphan cleanup failed for {Code}", lobby.GameCodeName);
}
}
});
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
@@ -0,0 +1,211 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Empostor.Api.Admin;
using Empostor.Api.Games;
using Empostor.Api.Innersloth;
using Microsoft.Extensions.Logging;
namespace Empostor.Plugins.FixedCode;
/// <summary>
/// Admin panel page to manage the friend-code → room-code mappings: searchable table with
/// per-row edit/delete and an add row at the bottom. Every change rebuilds the listener's
/// in-memory map immediately, so edits take effect without a restart.
/// </summary>
public sealed class FixedCodeAdminExtension : IAdminExtension
{
private readonly FixedCodeStore _store;
private readonly FixedCodeListener _listener;
private readonly ILogger<FixedCodeAdminExtension> _logger;
public FixedCodeAdminExtension(
FixedCodeStore store,
FixedCodeListener listener,
ILogger<FixedCodeAdminExtension> logger)
{
_store = store;
_listener = listener;
_logger = logger;
}
public string Id => "fixed-code";
public string Title => "Fixed Room Code";
public string Icon => "link";
public string Section => "Plugins";
public void Build(AdminPanelBuilder b)
{
b.RegisterEntries(ent =>
{
ent.Fields = new List<AdminEntryField>
{
new() { Key = "friendCode", Label = "Friend Code", Monospace = true },
new() { Key = "roomCode", Label = "Room Code", Monospace = true },
};
ent.Rows = _store.Mappings
.Select(m => new Dictionary<string, string>
{
["friendCode"] = m.FriendCode,
["roomCode"] = m.RoomCode,
})
.ToList();
ent.SearchPlaceholder = "Search friend code or room code...";
ent.AddLabel = "Add mapping";
ent.OnAdd(OnAddEntry);
ent.OnEdit(OnEditEntry);
ent.OnRemove(OnRemoveEntry);
});
b.RegisterText(t =>
{
t.Tone = "muted";
t.Content = "Hosts whose friend code is listed always get the mapped room code when creating a lobby. "
+ $"Currently {_listener.MappingCount} active mapping(s). Changes apply immediately, no restart needed.";
});
}
private ValueTask<AdminActionResult> OnAddEntry(AdminActionContext ctx)
{
var row = ReadRow(ctx);
if (row == null)
{
return ValueTask.FromResult(AdminActionResult.Fail("Friend code and room code are both required."));
}
var friendCode = row["friendCode"];
var roomCode = row["roomCode"].Trim().ToUpperInvariant();
var error = ValidateRoomCode(roomCode);
if (error != null)
{
return ValueTask.FromResult(AdminActionResult.Fail(error));
}
if (_store.Find(friendCode) != null)
{
return ValueTask.FromResult(AdminActionResult.Fail($"{friendCode} already has a mapping — edit it instead."));
}
var mappings = _store.Mappings.ToList();
mappings.Add(new FriendCodeMapping { FriendCode = friendCode, RoomCode = roomCode });
_store.SetMappings(mappings);
_listener.Rebuild();
_logger.LogInformation("[FixedCode] Mapping {FC} → {Code} added via admin panel", friendCode, roomCode);
return ValueTask.FromResult(AdminActionResult.Ok($"Mapping {friendCode} → {roomCode} added."));
}
private ValueTask<AdminActionResult> OnEditEntry(AdminActionContext ctx)
{
var oldFriendCode = ctx.Value;
var row = ReadRow(ctx);
if (string.IsNullOrWhiteSpace(oldFriendCode) || row == null)
{
return ValueTask.FromResult(AdminActionResult.Fail("Friend code and room code are both required."));
}
var existing = _store.Find(oldFriendCode);
if (existing == null)
{
return ValueTask.FromResult(AdminActionResult.Fail("That mapping no longer exists — refresh and try again."));
}
var friendCode = row["friendCode"];
var roomCode = row["roomCode"].Trim().ToUpperInvariant();
var error = ValidateRoomCode(roomCode);
if (error != null)
{
return ValueTask.FromResult(AdminActionResult.Fail(error));
}
if (!string.Equals(friendCode, oldFriendCode, StringComparison.OrdinalIgnoreCase)
&& _store.Find(friendCode) != null)
{
return ValueTask.FromResult(AdminActionResult.Fail($"{friendCode} already has a mapping."));
}
existing.FriendCode = friendCode;
existing.RoomCode = roomCode;
_store.SetMappings(_store.Mappings.ToList());
_listener.Rebuild();
_logger.LogInformation("[FixedCode] Mapping {Old} → {FC} → {Code} edited via admin panel", oldFriendCode, friendCode, roomCode);
return ValueTask.FromResult(AdminActionResult.Ok($"Mapping updated to {friendCode} → {roomCode}."));
}
private ValueTask<AdminActionResult> OnRemoveEntry(AdminActionContext ctx)
{
var friendCode = ctx.Value;
var existing = _store.Find(friendCode);
if (existing == null)
{
return ValueTask.FromResult(AdminActionResult.Fail("That mapping no longer exists — refresh and try again."));
}
_store.SetMappings(_store.Mappings
.Where(m => !string.Equals(m.FriendCode, friendCode, StringComparison.OrdinalIgnoreCase))
.ToList());
_listener.Rebuild();
_logger.LogInformation("[FixedCode] Mapping {FC} → {Code} removed via admin panel", friendCode, existing.RoomCode);
return ValueTask.FromResult(AdminActionResult.Ok($"Mapping {friendCode} → {existing.RoomCode} removed."));
}
/// <summary>The edit/remove actions identify the row by its first field (friend code).</summary>
private static Dictionary<string, string>? ReadRow(AdminActionContext ctx)
{
if (ctx.Payload == null || ctx.Payload.Value.ValueKind != JsonValueKind.Object)
{
return null;
}
if (!ctx.Payload.Value.TryGetProperty("row", out var rowEl)
|| rowEl.ValueKind != JsonValueKind.Object)
{
return null;
}
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var property in rowEl.EnumerateObject())
{
if (property.Value.ValueKind == JsonValueKind.String)
{
result[property.Name] = property.Value.GetString() ?? string.Empty;
}
}
if (!result.TryGetValue("friendCode", out var friendCode) || string.IsNullOrWhiteSpace(friendCode))
{
return null;
}
if (!result.TryGetValue("roomCode", out var roomCode) || string.IsNullOrWhiteSpace(roomCode))
{
return null;
}
return result;
}
private static string? ValidateRoomCode(string roomCode)
{
if (roomCode.Length != 4 && roomCode.Length != 6)
{
return "Room code must be 4 or 6 letters.";
}
if (!roomCode.All(char.IsLetter))
{
return "Room code may only contain letters.";
}
return new GameCode(roomCode).IsInvalid ? "Not a valid room code." : null;
}
}
@@ -11,16 +11,22 @@ public sealed class FixedCodeListener : IEventListener
{
private readonly ILogger<FixedCodeListener> _logger;
private readonly Dictionary<string, GameCode> _map;
private readonly FixedCodeStore _store;
private Dictionary<string, GameCode> _map;
public int MappingCount => _map.Count;
public FixedCodeListener(ILogger<FixedCodeListener> logger, FixedCodeConfig config)
public FixedCodeListener(ILogger<FixedCodeListener> logger, FixedCodeStore store)
{
_logger = logger;
_map = BuildMap(config, logger);
_store = store;
_map = BuildMap(_store.Mappings, logger);
}
/// <summary>Rebuilds the in-memory map after the admin panel changed the mappings.</summary>
public void Rebuild() => _map = BuildMap(_store.Mappings, _logger);
[EventListener]
public void OnGameCreation(IGameCreationEvent e)
{
@@ -36,12 +42,12 @@ public sealed class FixedCodeListener : IEventListener
}
private static Dictionary<string, GameCode> BuildMap(
FixedCodeConfig config,
IEnumerable<FriendCodeMapping> mappings,
ILogger logger)
{
var map = new Dictionary<string, GameCode>(StringComparer.OrdinalIgnoreCase);
foreach (var m in config.Mappings)
foreach (var m in mappings)
{
if (string.IsNullOrWhiteSpace(m.FriendCode) ||
string.IsNullOrWhiteSpace(m.RoomCode))
@@ -1,3 +1,4 @@
using Empostor.Api.Admin;
using Empostor.Api.Events;
using Empostor.Api.Plugins;
using Microsoft.Extensions.DependencyInjection;
@@ -15,11 +16,11 @@ public sealed class FixedCodeStartup : IPluginStartup
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton(_ =>
PluginConfigLoader.Load<FixedCodeConfig>(_configPath));
// Mappings live in Data/FixedCodeStore.json now; the legacy plugin config file is migrated.
services.AddSingleton<FixedCodeStore>();
services.AddSingleton<FixedCodeListener>();
services.AddSingleton<IEventListener, FixedCodeListener>(
sp => sp.GetRequiredService<FixedCodeListener>());
services.AddSingleton<IAdminExtension, FixedCodeAdminExtension>();
}
}
@@ -0,0 +1,43 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Empostor.Api.Service;
using Microsoft.Extensions.Logging;
namespace Empostor.Plugins.FixedCode;
/// <summary>
/// Persistent friend-code → room-code mappings, stored in <c>Data/FixedCodeStore.json</c>.
/// The old plugin config file (<c>[Fixed Room Code]Config.json</c>) is migrated automatically.
/// </summary>
public sealed class FixedCodeStore : JsonDataStore<FixedCodeConfig>
{
private readonly List<FriendCodeMapping> _mappings = new();
public FixedCodeStore(ILogger<FixedCodeStore> logger)
: base(logger, legacyPath: "[Fixed Room Code]Config.json")
{
Load();
}
public IReadOnlyList<FriendCodeMapping> Mappings => _mappings;
public void SetMappings(IEnumerable<FriendCodeMapping> mappings)
{
_mappings.Clear();
_mappings.AddRange(mappings);
SaveFireAndForget();
}
public FriendCodeMapping? Find(string? friendCode)
=> _mappings.FirstOrDefault(m =>
string.Equals(m.FriendCode, friendCode, StringComparison.OrdinalIgnoreCase));
protected override FixedCodeConfig GetSnapshot() => new() { Mappings = _mappings.ToList() };
protected override void ApplySnapshot(FixedCodeConfig data)
{
_mappings.Clear();
_mappings.AddRange(data.Mappings);
}
}
@@ -1843,6 +1843,26 @@ internal static class AdminTemplateDefaults
case 'table': return '<table><thead><tr>' + (w.columns || []).map(function (c) { return '<th>' + e(c) + '</th>'; }).join('') + '</tr></thead><tbody>' + (w.rows || []).map(function (r) { return '<tr>' + (r || []).map(function (c) { return '<td class="' + (c.tone && c.tone !== 'default' ? 'tone-' + c.tone : '') + (c.monospace ? ' mono' : '') + '">' + e(c.text) + '</td>'; }).join('') + '</tr>'; }).join('') + '</tbody></table>';
case 'chips': return '<div class="aw-chips"><div class="chips-list">' + (w.items || []).map(function (c) { return '<span class="chip">' + e(c.label) + '<button data-act="' + e(w.removeAction) + '" data-w="chip-remove" data-value="' + e(c.value) + '" title="Remove">&times;</button></span>'; }).join('') + '</div><div class="row"><input type="text" data-w="chip-input" placeholder="' + e(w.placeholder || '') + '"><button class="bp bsm" data-act="' + e(w.addAction) + '" data-w="chip-add">' + e(w.addLabel || 'Add') + '</button></div></div>';
case 'divider': return '<hr class="aw-divider">';
case 'entries': {
const fields = w.fields || [];
const rows = w.rows || [];
const eid = 'ent' + Math.floor(Math.random() * 1e9);
const rowHtml = function (r) {
const id = (fields.length && r[fields[0].key]) || '';
const cells = fields.map(f => `<td data-key="${e(f.key)}" data-val="${e(r[f.key] || '')}"${f.monospace ? ' class="mono"' : ''}>${e(r[f.key] || '')}</td>`).join('');
return `<tr data-ent-row="${e(id)}">${cells}<td class="ent-ops">` +
`<button class="bsm" data-act="${e(w.editAction)}" data-w="ent-edit" data-ent="${eid}" data-row="${e(id)}" title="Edit">&#9998;</button> ` +
`<button class="bd bsm" data-act="${e(w.removeAction)}" data-w="ent-remove" data-ent="${eid}" data-row="${e(id)}" title="Delete">&#10005;</button></td></tr>`;
};
return '<div class="aw-entries" data-entries="' + eid + '">' +
'<div class="row"><input type="text" class="ent-search" placeholder="' + e(w.searchPlaceholder || 'Search...') + '"></div>' +
'<table><thead><tr>' + fields.map(f => `<th>${e(f.label)}</th>`).join('') + '<th></th></tr></thead><tbody>' +
(rows.length ? rows.map(rowHtml).join('') : `<tr><td colspan="${fields.length + 1}" class="empty">${_('entries.empty', 'No entries yet.')}</td></tr>`) +
'</tbody></table>' +
'<div class="row" style="margin-top:8px">' + fields.map(f => `<input type="text" class="ent-new" data-key="${e(f.key)}" placeholder="${e(f.label)}">`).join('') +
`<button class="bp bsm" data-act="${e(w.addAction)}" data-w="ent-add" data-ent="${eid}">${e(w.addLabel || 'Add')}</button></div>` +
'</div>';
}
default: return '';
}
}
@@ -1872,8 +1892,9 @@ internal static class AdminTemplateDefaults
clearTimeout(t._h);
t._h = setTimeout(function () { t.style.opacity = '0'; }, 3500);
}
async function dispatch(extId, action, value) {
const { ok, data } = await api('POST', '/api/admin/ext/' + encodeURIComponent(extId) + '/' + encodeURIComponent(action), { value: value });
async function dispatch(extId, action, value, payload) {
const body = payload ? Object.assign({ value: value }, payload) : { value: value };
const { ok, data } = await api('POST', '/api/admin/ext/' + encodeURIComponent(extId) + '/' + encodeURIComponent(action), body);
if (data && data.message) toast(ok, data.message);
if (ok && data && data.refresh !== false) loadExtension(extId);
return { ok: ok, data: data };
@@ -1888,6 +1909,17 @@ internal static class AdminTemplateDefaults
let value;
if (w === 'chip-remove') value = el.getAttribute('data-value');
else if (w === 'chip-add') { const inp = el.parentElement.querySelector('[data-w="chip-input"]'); value = inp ? inp.value : ''; }
else if (w === 'ent-remove') { dispatch(extId, el.getAttribute('data-act'), el.getAttribute('data-row')); return; }
else if (w === 'ent-add') {
const box = el.closest('[data-entries]');
const row = collectEntryInputs(box, '.ent-new');
if (!row) return;
dispatch(extId, el.getAttribute('data-act'), null, { row: row });
return;
}
else if (w === 'ent-edit') { startEntryEdit(el); return; }
else if (w === 'ent-save') { saveEntryEdit(el, extId); return; }
else if (w === 'ent-cancel') { loadExtension(extId); return; }
else if (w === 'toggle') value = el.checked;
else value = el.value;
dispatch(extId, el.getAttribute('data-act'), value);
@@ -1902,6 +1934,46 @@ internal static class AdminTemplateDefaults
if (!extId) return;
dispatch(extId, el.getAttribute('data-act'), el.value);
});
document.addEventListener('input', function (ev) {
const el = ev.target;
if (!el.classList || !el.classList.contains('ent-search')) return;
const box = el.closest('[data-entries]');
if (!box) return;
const q = el.value.trim().toLowerCase();
box.querySelectorAll('tbody tr[data-ent-row]').forEach(function (tr) {
tr.style.display = tr.textContent.toLowerCase().indexOf(q) >= 0 ? '' : 'none';
});
});
function collectEntryInputs(scope, selector) {
const row = {};
let any = false;
scope.querySelectorAll(selector).forEach(function (inp) {
row[inp.getAttribute('data-key')] = inp.value.trim();
if (inp.value.trim()) any = true;
});
return any ? row : null;
}
function startEntryEdit(btn) {
const tr = btn.closest('tr');
if (!tr || tr.getAttribute('data-editing')) return;
tr.setAttribute('data-editing', '1');
const editAction = btn.getAttribute('data-act');
const entId = btn.getAttribute('data-ent');
const rowId = btn.getAttribute('data-row');
tr.querySelectorAll('td[data-key]').forEach(function (td) {
const val = td.getAttribute('data-val') || '';
td.innerHTML = '<input type="text" class="ent-cell" data-key="' + td.getAttribute('data-key') + '" value="' + e(val) + '">';
});
const ops = tr.querySelector('td.ent-ops');
ops.innerHTML = '<button class="bp bsm" data-act="' + e(editAction) + '" data-w="ent-save" data-ent="' + e(entId) + '" data-row="' + e(rowId) + '">' + _('entries.save', 'Save') + '</button> ' +
'<button class="bsm" data-act="' + e(editAction) + '" data-w="ent-cancel">' + _('entries.cancel', 'Cancel') + '</button>';
}
async function saveEntryEdit(btn, extId) {
const tr = btn.closest('tr');
const row = {};
tr.querySelectorAll('input.ent-cell').forEach(function (inp) { row[inp.getAttribute('data-key')] = inp.value.trim(); });
await dispatch(extId, btn.getAttribute('data-act'), btn.getAttribute('data-row'), { row: row });
}
async function initExtensions() {
try {
const { data } = await api('GET', '/api/admin/ext');
+3 -2
View File
@@ -20,15 +20,16 @@
"AllowCheatingHosts": "Never",
"EnableGameFlowChecks": true,
"EnableMustBeHostChecks": true,
"EnableInvalidObjectChecks": true,
"EnableColorLimitChecks": true,
"EnableNameLimitChecks": true,
"EnableItemLimitChecks": true,
"EnableOwnershipChecks": true,
"EnableRoleChecks": true,
"EnableTargetChecks": true,
"ForbidProtocolExtensions": true,
"EnablePacketSizeChecks": true,
"PacketSizeLimit": 1203,
"EnableItemLimitChecks": true
"PacketSizeLimit": 1203
},
"Timeout": {
"SpawnTimeout": 5000,