mirror of
https://github.com/Empostor/Empostor.git
synced 2026-10-19 15:51:39 +00:00
Update
This commit is contained in:
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 RegisterChips(Action<AdminChips> configure) => Register(configure);
|
||||||
|
|
||||||
|
public AdminPanelBuilder RegisterEntries(Action<AdminEntries> configure) => Register(configure);
|
||||||
|
|
||||||
public AdminPanelBuilder RegisterDivider()
|
public AdminPanelBuilder RegisterDivider()
|
||||||
{
|
{
|
||||||
Widgets.Add(new AdminDivider());
|
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)
|
if (widget is AdminBlock block)
|
||||||
{
|
{
|
||||||
foreach (var child in block.Children)
|
foreach (var child in block.Children)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ namespace Empostor.Api.Admin;
|
|||||||
[JsonDerivedType(typeof(AdminTable), "table")]
|
[JsonDerivedType(typeof(AdminTable), "table")]
|
||||||
[JsonDerivedType(typeof(AdminChips), "chips")]
|
[JsonDerivedType(typeof(AdminChips), "chips")]
|
||||||
[JsonDerivedType(typeof(AdminDivider), "divider")]
|
[JsonDerivedType(typeof(AdminDivider), "divider")]
|
||||||
|
[JsonDerivedType(typeof(AdminEntries), "entries")]
|
||||||
public abstract class AdminWidget
|
public abstract class AdminWidget
|
||||||
{
|
{
|
||||||
public string? Action { get; set; }
|
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 record AdminOption(string Label, string Value);
|
||||||
|
|
||||||
public sealed class AdminTableCell
|
public sealed class AdminTableCell
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System;
|
||||||
using Empostor.Api.Admin;
|
using Empostor.Api.Admin;
|
||||||
|
|
||||||
namespace Empostor.Plugins.DiscordWebhook;
|
namespace Empostor.Plugins.DiscordWebhook;
|
||||||
@@ -47,5 +48,50 @@ public sealed class DiscordWebhookAdminExtension : IAdminExtension
|
|||||||
return AdminActionResult.Ok("Admin webhook saved.");
|
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>
|
/// <summary>Legacy single-webhook fallback used when both URLs are empty.</summary>
|
||||||
public string WebhookUrl { get; set; } = string.Empty;
|
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;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
@@ -7,6 +8,7 @@ using System.Text.Json;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Empostor.Api.Events;
|
using Empostor.Api.Events;
|
||||||
using Empostor.Api.Events.Player;
|
using Empostor.Api.Events.Player;
|
||||||
|
using Empostor.Api.Games;
|
||||||
using Empostor.Api.Innersloth;
|
using Empostor.Api.Innersloth;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@@ -14,29 +16,46 @@ namespace Empostor.Plugins.DiscordWebhook;
|
|||||||
|
|
||||||
public sealed class DiscordWebhookListener : IEventListener
|
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 static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = false };
|
||||||
|
|
||||||
private readonly ILogger<DiscordWebhookListener> _logger;
|
private readonly ILogger<DiscordWebhookListener> _logger;
|
||||||
private readonly IHttpClientFactory _http;
|
private readonly IHttpClientFactory _http;
|
||||||
private readonly DiscordWebhookStore _config;
|
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(
|
public DiscordWebhookListener(
|
||||||
ILogger<DiscordWebhookListener> logger,
|
ILogger<DiscordWebhookListener> logger,
|
||||||
IHttpClientFactory http,
|
IHttpClientFactory http,
|
||||||
DiscordWebhookStore config)
|
DiscordWebhookStore config,
|
||||||
|
LiveGameTracker tracker)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_http = http;
|
_http = http;
|
||||||
_config = config;
|
_config = config;
|
||||||
|
_tracker = tracker;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Live mode: one persistent message per lobby, edited as it changes ----
|
||||||
|
|
||||||
[EventListener]
|
[EventListener]
|
||||||
public void OnGameCreated(IGameCreatedEvent e)
|
public void OnGameCreated(IGameCreatedEvent e)
|
||||||
{
|
{
|
||||||
var url = _config.MatchmakerUrl;
|
if (_config.LiveRoomUpdates)
|
||||||
if (string.IsNullOrWhiteSpace(url)) return;
|
{
|
||||||
|
// 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 ?? "—",
|
["Host"] = e.Host?.Name ?? "—",
|
||||||
["Game"] = GameCodeParser.IntToGameName(e.Game.Code),
|
["Game"] = GameCodeParser.IntToGameName(e.Game.Code),
|
||||||
@@ -50,68 +69,84 @@ public sealed class DiscordWebhookListener : IEventListener
|
|||||||
[EventListener]
|
[EventListener]
|
||||||
public void OnGameStarted(IGameStartedEvent e)
|
public void OnGameStarted(IGameStartedEvent e)
|
||||||
{
|
{
|
||||||
var url = _config.MatchmakerUrl;
|
if (!_config.LiveRoomUpdates) { SendOneShot("Game Started", ColorInGame, LegacyFields(e.Game)); return; }
|
||||||
if (string.IsNullOrWhiteSpace(url)) return;
|
_ = UpdateLobbyAsync(e.Game, force: true);
|
||||||
|
|
||||||
_ = 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(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[EventListener]
|
[EventListener]
|
||||||
public void OnGameEnded(IGameEndedEvent e)
|
public void OnGameEnded(IGameEndedEvent e)
|
||||||
{
|
{
|
||||||
var url = _config.MatchmakerUrl;
|
if (!_config.LiveRoomUpdates)
|
||||||
if (string.IsNullOrWhiteSpace(url)) return;
|
|
||||||
|
|
||||||
_ = SendAsync(url, "Game Ended", 10181046, new()
|
|
||||||
{
|
{
|
||||||
["Game"] = GameCodeParser.IntToGameName(e.Game.Code),
|
SendOneShot("Game Ended", ColorEnded, new()
|
||||||
["Result"] = e.GameOverReason.ToString(),
|
{
|
||||||
["Players"] = e.Game.PlayerCount.ToString(),
|
["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]
|
[EventListener]
|
||||||
public void OnGameDestroyed(IGameDestroyedEvent e)
|
public void OnGameDestroyed(IGameDestroyedEvent e)
|
||||||
{
|
{
|
||||||
var url = _config.MatchmakerUrl;
|
if (!_config.LiveRoomUpdates)
|
||||||
if (string.IsNullOrWhiteSpace(url)) return;
|
|
||||||
|
|
||||||
_ = SendAsync(url, "Game Destroyed", 15158332, new()
|
|
||||||
{
|
{
|
||||||
["Game"] = GameCodeParser.IntToGameName(e.Game.Code),
|
SendOneShot("Game Destroyed", 15158332, new()
|
||||||
["Players"] = e.Game.PlayerCount.ToString(),
|
{
|
||||||
});
|
["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]
|
[EventListener]
|
||||||
public void OnPlayerLeft(IGamePlayerLeftEvent e)
|
public void OnPlayerLeft(IGamePlayerLeftEvent e)
|
||||||
{
|
{
|
||||||
if (!e.IsBan) return;
|
if (e.IsBan) { SendAdminMessage("Player Banned", 15158332, new()
|
||||||
|
|
||||||
var url = _config.AdminUrl;
|
|
||||||
if (string.IsNullOrWhiteSpace(url)) return;
|
|
||||||
|
|
||||||
_ = SendAsync(url, "Player Banned", 15158332, new()
|
|
||||||
{
|
{
|
||||||
["Player"] = e.Player.Client.Name,
|
["Player"] = e.Player.Client.Name,
|
||||||
["Friend Code"] = e.Player.Client.FriendCode ?? "—",
|
["Friend Code"] = e.Player.Client.FriendCode ?? "—",
|
||||||
["Game"] = GameCodeParser.IntToGameName(e.Game.Code),
|
["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]
|
[EventListener]
|
||||||
public void OnPlayerReport(IPlayerReportEvent e)
|
public void OnPlayerReport(IPlayerReportEvent e)
|
||||||
{
|
{
|
||||||
var url = _config.AdminUrl;
|
SendAdminMessage("Player Reported", 16776960, new()
|
||||||
if (string.IsNullOrWhiteSpace(url)) return;
|
|
||||||
|
|
||||||
_ = SendAsync(url, "Player Reported", 16776960, new()
|
|
||||||
{
|
{
|
||||||
["Reporter"] = e.ClientPlayer.Client.Name,
|
["Reporter"] = e.ClientPlayer.Client.Name,
|
||||||
["Reporter FC"] = e.ClientPlayer.Client.FriendCode ?? "—",
|
["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)
|
private async ValueTask SendAsync(string url, string title, int color, Dictionary<string, string> fields)
|
||||||
{
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(url))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var embed = new
|
var embed = new
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ public sealed class DiscordWebhookPluginStartup : IPluginStartup
|
|||||||
public void ConfigureServices(IServiceCollection services)
|
public void ConfigureServices(IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddHttpClient();
|
services.AddHttpClient();
|
||||||
|
services.AddSingleton<LiveGameTracker>();
|
||||||
services.AddSingleton<DiscordWebhookStore>();
|
services.AddSingleton<DiscordWebhookStore>();
|
||||||
services.AddSingleton<IEventListener, DiscordWebhookListener>();
|
services.AddSingleton<IEventListener, DiscordWebhookListener>();
|
||||||
services.AddSingleton<IAdminExtension, DiscordWebhookAdminExtension>();
|
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 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()
|
public DiscordWebhookConfig Snapshot => new()
|
||||||
{
|
{
|
||||||
MatchmakerUrl = MatchmakerUrl,
|
MatchmakerUrl = MatchmakerUrl,
|
||||||
AdminUrl = AdminUrl,
|
AdminUrl = AdminUrl,
|
||||||
|
LiveRoomUpdates = LiveRoomUpdates,
|
||||||
|
DeleteOnClose = DeleteOnClose,
|
||||||
|
MinUpdateIntervalSeconds = MinUpdateIntervalSeconds,
|
||||||
};
|
};
|
||||||
|
|
||||||
protected override DiscordWebhookConfig GetSnapshot() => Snapshot;
|
protected override DiscordWebhookConfig GetSnapshot() => Snapshot;
|
||||||
@@ -42,6 +51,9 @@ public sealed class DiscordWebhookStore : JsonDataStore<DiscordWebhookConfig>
|
|||||||
{
|
{
|
||||||
MatchmakerUrl = data.MatchmakerUrl;
|
MatchmakerUrl = data.MatchmakerUrl;
|
||||||
AdminUrl = data.AdminUrl;
|
AdminUrl = data.AdminUrl;
|
||||||
|
LiveRoomUpdates = data.LiveRoomUpdates;
|
||||||
|
DeleteOnClose = data.DeleteOnClose;
|
||||||
|
MinUpdateIntervalSeconds = data.MinUpdateIntervalSeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
public new async ValueTask SaveAsync() => await base.SaveAsync();
|
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 ILogger<FixedCodeListener> _logger;
|
||||||
|
|
||||||
private readonly Dictionary<string, GameCode> _map;
|
private readonly FixedCodeStore _store;
|
||||||
|
|
||||||
|
private Dictionary<string, GameCode> _map;
|
||||||
|
|
||||||
public int MappingCount => _map.Count;
|
public int MappingCount => _map.Count;
|
||||||
|
|
||||||
public FixedCodeListener(ILogger<FixedCodeListener> logger, FixedCodeConfig config)
|
public FixedCodeListener(ILogger<FixedCodeListener> logger, FixedCodeStore store)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_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]
|
[EventListener]
|
||||||
public void OnGameCreation(IGameCreationEvent e)
|
public void OnGameCreation(IGameCreationEvent e)
|
||||||
{
|
{
|
||||||
@@ -36,12 +42,12 @@ public sealed class FixedCodeListener : IEventListener
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static Dictionary<string, GameCode> BuildMap(
|
private static Dictionary<string, GameCode> BuildMap(
|
||||||
FixedCodeConfig config,
|
IEnumerable<FriendCodeMapping> mappings,
|
||||||
ILogger logger)
|
ILogger logger)
|
||||||
{
|
{
|
||||||
var map = new Dictionary<string, GameCode>(StringComparer.OrdinalIgnoreCase);
|
var map = new Dictionary<string, GameCode>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
foreach (var m in config.Mappings)
|
foreach (var m in mappings)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(m.FriendCode) ||
|
if (string.IsNullOrWhiteSpace(m.FriendCode) ||
|
||||||
string.IsNullOrWhiteSpace(m.RoomCode))
|
string.IsNullOrWhiteSpace(m.RoomCode))
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Empostor.Api.Admin;
|
||||||
using Empostor.Api.Events;
|
using Empostor.Api.Events;
|
||||||
using Empostor.Api.Plugins;
|
using Empostor.Api.Plugins;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -15,11 +16,11 @@ public sealed class FixedCodeStartup : IPluginStartup
|
|||||||
|
|
||||||
public void ConfigureServices(IServiceCollection services)
|
public void ConfigureServices(IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddSingleton(_ =>
|
// Mappings live in Data/FixedCodeStore.json now; the legacy plugin config file is migrated.
|
||||||
PluginConfigLoader.Load<FixedCodeConfig>(_configPath));
|
services.AddSingleton<FixedCodeStore>();
|
||||||
|
|
||||||
services.AddSingleton<FixedCodeListener>();
|
services.AddSingleton<FixedCodeListener>();
|
||||||
services.AddSingleton<IEventListener, FixedCodeListener>(
|
services.AddSingleton<IEventListener, FixedCodeListener>(
|
||||||
sp => sp.GetRequiredService<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 '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">×</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 '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">×</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 '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">✎</button> ` +
|
||||||
|
`<button class="bd bsm" data-act="${e(w.removeAction)}" data-w="ent-remove" data-ent="${eid}" data-row="${e(id)}" title="Delete">✕</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 '';
|
default: return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1872,8 +1892,9 @@ internal static class AdminTemplateDefaults
|
|||||||
clearTimeout(t._h);
|
clearTimeout(t._h);
|
||||||
t._h = setTimeout(function () { t.style.opacity = '0'; }, 3500);
|
t._h = setTimeout(function () { t.style.opacity = '0'; }, 3500);
|
||||||
}
|
}
|
||||||
async function dispatch(extId, action, value) {
|
async function dispatch(extId, action, value, payload) {
|
||||||
const { ok, data } = await api('POST', '/api/admin/ext/' + encodeURIComponent(extId) + '/' + encodeURIComponent(action), { value: value });
|
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 (data && data.message) toast(ok, data.message);
|
||||||
if (ok && data && data.refresh !== false) loadExtension(extId);
|
if (ok && data && data.refresh !== false) loadExtension(extId);
|
||||||
return { ok: ok, data: data };
|
return { ok: ok, data: data };
|
||||||
@@ -1888,6 +1909,17 @@ internal static class AdminTemplateDefaults
|
|||||||
let value;
|
let value;
|
||||||
if (w === 'chip-remove') value = el.getAttribute('data-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 === '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 if (w === 'toggle') value = el.checked;
|
||||||
else value = el.value;
|
else value = el.value;
|
||||||
dispatch(extId, el.getAttribute('data-act'), value);
|
dispatch(extId, el.getAttribute('data-act'), value);
|
||||||
@@ -1902,6 +1934,46 @@ internal static class AdminTemplateDefaults
|
|||||||
if (!extId) return;
|
if (!extId) return;
|
||||||
dispatch(extId, el.getAttribute('data-act'), el.value);
|
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() {
|
async function initExtensions() {
|
||||||
try {
|
try {
|
||||||
const { data } = await api('GET', '/api/admin/ext');
|
const { data } = await api('GET', '/api/admin/ext');
|
||||||
|
|||||||
@@ -20,15 +20,16 @@
|
|||||||
"AllowCheatingHosts": "Never",
|
"AllowCheatingHosts": "Never",
|
||||||
"EnableGameFlowChecks": true,
|
"EnableGameFlowChecks": true,
|
||||||
"EnableMustBeHostChecks": true,
|
"EnableMustBeHostChecks": true,
|
||||||
|
"EnableInvalidObjectChecks": true,
|
||||||
"EnableColorLimitChecks": true,
|
"EnableColorLimitChecks": true,
|
||||||
"EnableNameLimitChecks": true,
|
"EnableNameLimitChecks": true,
|
||||||
|
"EnableItemLimitChecks": true,
|
||||||
"EnableOwnershipChecks": true,
|
"EnableOwnershipChecks": true,
|
||||||
"EnableRoleChecks": true,
|
"EnableRoleChecks": true,
|
||||||
"EnableTargetChecks": true,
|
"EnableTargetChecks": true,
|
||||||
"ForbidProtocolExtensions": true,
|
"ForbidProtocolExtensions": true,
|
||||||
"EnablePacketSizeChecks": true,
|
"EnablePacketSizeChecks": true,
|
||||||
"PacketSizeLimit": 1203,
|
"PacketSizeLimit": 1203
|
||||||
"EnableItemLimitChecks": true
|
|
||||||
},
|
},
|
||||||
"Timeout": {
|
"Timeout": {
|
||||||
"SpawnTimeout": 5000,
|
"SpawnTimeout": 5000,
|
||||||
|
|||||||
Reference in new issue
Block a user