diff --git a/Application/Factories/GameServerInstanceFactory.cs b/Application/Factories/GameServerInstanceFactory.cs index 9e91d54b7..8cadbc73b 100644 --- a/Application/Factories/GameServerInstanceFactory.cs +++ b/Application/Factories/GameServerInstanceFactory.cs @@ -1,4 +1,5 @@ using System; +using Microsoft.Extensions.DependencyInjection; using SharedLibraryCore; using SharedLibraryCore.Configuration; using SharedLibraryCore.Interfaces; @@ -36,7 +37,7 @@ namespace IW4MAdmin.Application.Factories /// public Server CreateServer(ServerConfiguration config, IManager manager) { - return new IW4MServer(config, _translationLookup, _metaService, _serviceProvider); + return new IW4MServer(config, _translationLookup, _metaService, _serviceProvider, _serviceProvider.GetRequiredService()); } } } diff --git a/Application/IW4MServer.cs b/Application/IW4MServer.cs index 75a1331fb..7357d0e2c 100644 --- a/Application/IW4MServer.cs +++ b/Application/IW4MServer.cs @@ -32,13 +32,15 @@ namespace IW4MAdmin private int lastGameTime = 0; public int Id { get; private set; } - private readonly IServiceProvider _serviceProvider; + private readonly IServiceProvider _serviceProvider; + private readonly IClientNoticeMessageFormatter _messageFormatter; public IW4MServer( ServerConfiguration serverConfiguration, ITranslationLookup lookup, IMetaService metaService, - IServiceProvider serviceProvider) : base(serviceProvider.GetRequiredService>(), + IServiceProvider serviceProvider, + IClientNoticeMessageFormatter messageFormatter) : base(serviceProvider.GetRequiredService>(), serviceProvider.GetRequiredService(), serverConfiguration, serviceProvider.GetRequiredService(), @@ -48,6 +50,7 @@ namespace IW4MAdmin _translationLookup = lookup; _metaService = metaService; _serviceProvider = serviceProvider; + _messageFormatter = messageFormatter; } public override async Task OnClientConnected(EFClient clientFromLog) @@ -454,7 +457,6 @@ namespace IW4MAdmin else if (E.Type == GameEvent.EventType.TempBan) { await TempBan(E.Data, (TimeSpan) E.Extra, E.Target, E.ImpersonationOrigin ?? E.Origin); - ; } else if (E.Type == GameEvent.EventType.Ban) @@ -470,7 +472,7 @@ namespace IW4MAdmin else if (E.Type == GameEvent.EventType.Kick) { - await Kick(E.Data, E.Target, E.ImpersonationOrigin ?? E.Origin); + await Kick(E.Data, E.Target, E.ImpersonationOrigin ?? E.Origin, E.Extra as EFPenalty); } else if (E.Type == GameEvent.EventType.Warn) @@ -1015,6 +1017,12 @@ namespace IW4MAdmin { Website = loc["SERVER_WEBSITE_GENERIC"]; } + + // todo: remove this once _website is weaned off + if (string.IsNullOrEmpty(Manager.GetApplicationSettings().Configuration().ContactUri)) + { + Manager.GetApplicationSettings().Configuration().ContactUri = Website; + } InitializeMaps(); @@ -1190,7 +1198,7 @@ namespace IW4MAdmin } } - public override async Task Kick(string Reason, EFClient targetClient, EFClient originClient) + public override async Task Kick(string reason, EFClient targetClient, EFClient originClient, EFPenalty previousPenalty) { targetClient = targetClient.ClientNumber < 0 ? Manager.GetActiveClients() @@ -1202,7 +1210,7 @@ namespace IW4MAdmin Type = EFPenalty.PenaltyType.Kick, Expires = DateTime.UtcNow, Offender = targetClient, - Offense = Reason, + Offense = reason, Punisher = originClient, Link = targetClient.AliasLink }; @@ -1221,8 +1229,11 @@ namespace IW4MAdmin Manager.AddEvent(e); - // todo: move to translation sheet - string formattedKick = string.Format(RconParser.Configuration.CommandPrefixes.Kick, targetClient.ClientNumber, $"{loc["SERVER_KICK_TEXT"]} - ^5{Reason}^7"); + var formattedKick = string.Format(RconParser.Configuration.CommandPrefixes.Kick, + targetClient.ClientNumber, + _messageFormatter.BuildFormattedMessage(RconParser.Configuration, + newPenalty, + previousPenalty)); await targetClient.CurrentServer.ExecuteCommandAsync(formattedKick); } } @@ -1250,14 +1261,15 @@ namespace IW4MAdmin if (targetClient.IsIngame) { - // todo: move to translation sheet - string formattedKick = string.Format(RconParser.Configuration.CommandPrefixes.Kick, targetClient.ClientNumber, $"^7{loc["SERVER_TB_TEXT"]}- ^5{Reason}"); + var formattedKick = string.Format(RconParser.Configuration.CommandPrefixes.Kick, + targetClient.ClientNumber, + _messageFormatter.BuildFormattedMessage(RconParser.Configuration, newPenalty)); ServerLogger.LogDebug("Executing tempban kick command for {targetClient}", targetClient.ToString()); await targetClient.CurrentServer.ExecuteCommandAsync(formattedKick); } } - override public async Task Ban(string reason, EFClient targetClient, EFClient originClient, bool isEvade = false) + public override async Task Ban(string reason, EFClient targetClient, EFClient originClient, bool isEvade = false) { // ensure player gets kicked if command not performed on them in the same server targetClient = targetClient.ClientNumber < 0 ? @@ -1283,8 +1295,9 @@ namespace IW4MAdmin if (targetClient.IsIngame) { ServerLogger.LogDebug("Attempting to kicking newly banned client {targetClient}", targetClient.ToString()); - // todo: move to translation sheet - string formattedString = string.Format(RconParser.Configuration.CommandPrefixes.Kick, targetClient.ClientNumber, $"{loc["SERVER_BAN_TEXT"]} - ^5{reason} ^7{loc["SERVER_BAN_APPEAL"].FormatExt(Website)}^7"); + var formattedString = string.Format(RconParser.Configuration.CommandPrefixes.Kick, + targetClient.ClientNumber, + _messageFormatter.BuildFormattedMessage(RconParser.Configuration, newPenalty)); await targetClient.CurrentServer.ExecuteCommandAsync(formattedString); } } diff --git a/Application/Main.cs b/Application/Main.cs index 4d922277b..6b7ecab89 100644 --- a/Application/Main.cs +++ b/Application/Main.cs @@ -350,6 +350,7 @@ namespace IW4MAdmin.Application .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton(translationLookup); if (args.Contains("serialevents")) diff --git a/Application/Misc/ClientNoticeMessageFormatter.cs b/Application/Misc/ClientNoticeMessageFormatter.cs new file mode 100644 index 000000000..e0d9302cc --- /dev/null +++ b/Application/Misc/ClientNoticeMessageFormatter.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using SharedLibraryCore; +using SharedLibraryCore.Configuration; +using SharedLibraryCore.Database.Models; +using SharedLibraryCore.Interfaces; + +namespace IW4MAdmin.Application.Misc +{ + /// + /// implementation of IClientNoticeMessageFormatter + /// + public class ClientNoticeMessageFormatter : IClientNoticeMessageFormatter + { + private readonly ITranslationLookup _transLookup; + private readonly ApplicationConfiguration _appConfig; + + public ClientNoticeMessageFormatter(ITranslationLookup transLookup, ApplicationConfiguration appConfig) + { + _transLookup = transLookup; + _appConfig = appConfig; + } + + public string BuildFormattedMessage(IRConParserConfiguration config, EFPenalty currentPenalty, EFPenalty originalPenalty = null) + { + var penalty = originalPenalty ?? currentPenalty; + var builder = new StringBuilder(); + // build the top level header + var header = _transLookup[$"SERVER_{penalty.Type.ToString().ToUpper()}_TEXT"]; + builder.Append(header); + builder.Append(Environment.NewLine); + // build the reason + var reason = _transLookup["GAME_MESSAGE_PENALTY_REASON"].FormatExt(penalty.Offense); + foreach (var splitReason in SplitOverMaxLength(reason, config.NoticeMaxCharactersPerLine)) + { + builder.Append(splitReason); + builder.Append(Environment.NewLine); + } + + if (penalty.Type == EFPenalty.PenaltyType.TempBan) + { + // build the time remaining if temporary + var timeRemainingValue = penalty.Expires.HasValue + ? (penalty.Expires - DateTime.UtcNow).Value.HumanizeForCurrentCulture() + : "--"; + var timeRemaining = _transLookup["GAME_MESSAGE_PENALTY_TIME_REMAINING"].FormatExt(timeRemainingValue); + + foreach (var splitReason in SplitOverMaxLength(timeRemaining, config.NoticeMaxCharactersPerLine)) + { + builder.Append(splitReason); + builder.Append(Environment.NewLine); + } + } + + if (penalty.Type == EFPenalty.PenaltyType.Ban) + { + // provide a place to appeal the ban (should always be specified but including a placeholder just incase) + builder.Append(_transLookup["GAME_MESSAGE_PENALTY_APPEAL"].FormatExt(_appConfig.ContactUri ?? "--")); + } + + // final format looks something like: + /* + * You are permanently banned + * Reason - toxic behavior + * Visit example.com to appeal + */ + + return builder.ToString(); + } + + private static IEnumerable SplitOverMaxLength(string source, int maxCharactersPerLine) + { + if (source.Length <= maxCharactersPerLine) + { + return new[] {source}; + } + + var segments = new List(); + var currentLocation = 0; + while (currentLocation < source.Length) + { + var nextLocation = currentLocation + maxCharactersPerLine; + // there's probably a more efficient way to do this but this is readable + segments.Add(string.Concat( + source + .Skip(currentLocation) + .Take(Math.Min(maxCharactersPerLine, source.Length - currentLocation)))); + currentLocation = nextLocation; + } + + if (currentLocation < source.Length) + { + segments.Add(source.Substring(currentLocation, source.Length - currentLocation)); + } + + return segments; + } + } +} \ No newline at end of file diff --git a/Application/RconParsers/DynamicRConParserConfiguration.cs b/Application/RconParsers/DynamicRConParserConfiguration.cs index 61295f923..c57589092 100644 --- a/Application/RconParsers/DynamicRConParserConfiguration.cs +++ b/Application/RconParsers/DynamicRConParserConfiguration.cs @@ -22,6 +22,8 @@ namespace IW4MAdmin.Application.RconParsers public NumberStyles GuidNumberStyle { get; set; } = NumberStyles.HexNumber; public IDictionary OverrideDvarNameMapping { get; set; } = new Dictionary(); public IDictionary DefaultDvarValues { get; set; } = new Dictionary(); + public int NoticeMaximumLines { get; set; } = 8; + public int NoticeMaxCharactersPerLine { get; set; } = 50; public DynamicRConParserConfiguration(IParserRegexFactory parserRegexFactory) { diff --git a/SharedLibraryCore/Configuration/ApplicationConfiguration.cs b/SharedLibraryCore/Configuration/ApplicationConfiguration.cs index 4764366f7..134eb677c 100644 --- a/SharedLibraryCore/Configuration/ApplicationConfiguration.cs +++ b/SharedLibraryCore/Configuration/ApplicationConfiguration.cs @@ -47,6 +47,8 @@ namespace SharedLibraryCore.Configuration public string SocialLinkAddress { get; set; } [LocalizedDisplayName("SETUP_SOCIAL_TITLE")] public string SocialLinkTitle { get; set; } + [LocalizedDisplayName("SETUP_CONTACT_URI")] + public string ContactUri { get; set; } [LocalizedDisplayName("SETUP_USE_CUSTOMENCODING")] [ConfigurationLinked("CustomParserEncoding")] diff --git a/SharedLibraryCore/Interfaces/IClientNoticeMessageFormatter.cs b/SharedLibraryCore/Interfaces/IClientNoticeMessageFormatter.cs new file mode 100644 index 000000000..503daf897 --- /dev/null +++ b/SharedLibraryCore/Interfaces/IClientNoticeMessageFormatter.cs @@ -0,0 +1,16 @@ +using SharedLibraryCore.Database.Models; + +namespace SharedLibraryCore.Interfaces +{ + public interface IClientNoticeMessageFormatter + { + /// + /// builds a game formatted notice message + /// + /// current penalty the message is for + /// previous penalty the current penalty relates to + /// RCon parser config + /// + string BuildFormattedMessage(IRConParserConfiguration config, EFPenalty currentPenalty, EFPenalty originalPenalty = null); + } +} \ No newline at end of file diff --git a/SharedLibraryCore/Interfaces/IGameServer.cs b/SharedLibraryCore/Interfaces/IGameServer.cs new file mode 100644 index 000000000..614b0b46d --- /dev/null +++ b/SharedLibraryCore/Interfaces/IGameServer.cs @@ -0,0 +1,18 @@ +using System.Threading.Tasks; +using SharedLibraryCore.Database.Models; + +namespace SharedLibraryCore.Interfaces +{ + public interface IGameServer + { + /// + /// kicks target on behalf of origin for given reason + /// + /// reason client is being kicked + /// client to kick + /// source of kick action + /// previous penalty the kick is occuring for (if applicable) + /// + public Task Kick(string reason, EFClient target, EFClient origin, EFPenalty previousPenalty = null); + } +} \ No newline at end of file diff --git a/SharedLibraryCore/Interfaces/IRConParserConfiguration.cs b/SharedLibraryCore/Interfaces/IRConParserConfiguration.cs index aee38e284..819aa9561 100644 --- a/SharedLibraryCore/Interfaces/IRConParserConfiguration.cs +++ b/SharedLibraryCore/Interfaces/IRConParserConfiguration.cs @@ -62,5 +62,9 @@ namespace SharedLibraryCore.Interfaces /// specifies the default dvar values for games that don't support certain dvars /// IDictionary DefaultDvarValues { get; set; } + + int NoticeMaximumLines { get; set; } + + int NoticeMaxCharactersPerLine { get; set; } } } diff --git a/SharedLibraryCore/PartialEntities/EFClient.cs b/SharedLibraryCore/PartialEntities/EFClient.cs index 2782ed38e..df2bc1cb1 100644 --- a/SharedLibraryCore/PartialEntities/EFClient.cs +++ b/SharedLibraryCore/PartialEntities/EFClient.cs @@ -320,7 +320,15 @@ namespace SharedLibraryCore.Database.Models /// /// reason to kick for /// client performing the kick - public GameEvent Kick(string kickReason, EFClient sender) + public GameEvent Kick(string kickReason, EFClient sender) => Kick(kickReason, sender, null); + + /// + /// kick a client for the given reason + /// + /// reason to kick for + /// client performing the kick + /// original client penalty + public GameEvent Kick(string kickReason, EFClient sender, EFPenalty originalPenalty) { var e = new GameEvent() { @@ -329,6 +337,7 @@ namespace SharedLibraryCore.Database.Models Target = this, Origin = sender, Data = kickReason, + Extra = originalPenalty, Owner = sender.CurrentServer }; @@ -597,7 +606,6 @@ namespace SharedLibraryCore.Database.Models { var loc = Utilities.CurrentLocalization.LocalizationIndex; var autoKickClient = Utilities.IW4MAdminClient(CurrentServer); - bool isAbleToConnectSimple = IsAbleToConnectSimple(); if (!isAbleToConnectSimple) @@ -617,23 +625,18 @@ namespace SharedLibraryCore.Database.Models // we want to kick them if any account is banned if (banPenalty != null) { - if (Level == Permission.Banned) - { - Utilities.DefaultLogger.LogInformation("Kicking {client} because they are banned", ToString()); - Kick(loc["SERVER_BAN_PREV"].FormatExt(banPenalty?.Offense), autoKickClient); - return false; - } - - else + if (Level != Permission.Banned) { Utilities.DefaultLogger.LogInformation( "Client {client} is banned, but using a new GUID, we we're updating their level and kicking them", ToString()); await SetLevel(Permission.Banned, autoKickClient).WaitAsync(Utilities.DefaultCommandTimeout, CurrentServer.Manager.CancellationToken); - Kick(loc["SERVER_BAN_PREV"].FormatExt(banPenalty?.Offense), autoKickClient); - return false; } + + Utilities.DefaultLogger.LogInformation("Kicking {client} because they are banned", ToString()); + Kick(loc["WEBFRONT_PENALTY_LIST_BANNED_REASON"], autoKickClient, banPenalty); + return false; } // we want to kick them if any account is tempbanned @@ -641,9 +644,7 @@ namespace SharedLibraryCore.Database.Models { Utilities.DefaultLogger.LogInformation("Kicking {client} because their GUID is temporarily banned", ToString()); - Kick( - $"{loc["SERVER_TB_REMAIN"]} ({(tempbanPenalty.Expires.Value - DateTime.UtcNow).HumanizeForCurrentCulture()} {loc["WEBFRONT_PENALTY_TEMPLATE_REMAINING"]})", - autoKickClient); + Kick(loc["WEBFRONT_PENALTY_LIST_TEMPBANNED_REASON"], autoKickClient, tempbanPenalty); return false; } diff --git a/SharedLibraryCore/Server.cs b/SharedLibraryCore/Server.cs index 63c7ae4a9..d129d289a 100644 --- a/SharedLibraryCore/Server.cs +++ b/SharedLibraryCore/Server.cs @@ -14,7 +14,7 @@ using ILogger = Microsoft.Extensions.Logging.ILogger; namespace SharedLibraryCore { - public abstract class Server + public abstract class Server : IGameServer { public enum Game { @@ -205,9 +205,10 @@ namespace SharedLibraryCore /// /// Kick a player from the server /// - /// Reason for kicking + /// Reason for kicking /// EFClient to kick - abstract public Task Kick(String Reason, EFClient Target, EFClient Origin); + public Task Kick(String reason, EFClient Target, EFClient Origin) => Kick(reason, Target, Origin, null); + public abstract Task Kick(string reason, EFClient target, EFClient origin, EFPenalty originalPenalty); /// /// Temporarily ban a player ( default 1 hour ) from the server