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 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)
|
||||
|
||||
@@ -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">×</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">✎</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 '';
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user