diff --git a/Content.Client/Chat/Managers/ChatManager.cs b/Content.Client/Chat/Managers/ChatManager.cs index 18f03cd7db..f1ab25ba38 100644 --- a/Content.Client/Chat/Managers/ChatManager.cs +++ b/Content.Client/Chat/Managers/ChatManager.cs @@ -5,81 +5,91 @@ using Robust.Client.Console; using Robust.Shared.Utility; -namespace Content.Client.Chat.Managers +namespace Content.Client.Chat.Managers; + +internal sealed class ChatManager : IChatManager { - internal sealed class ChatManager : IChatManager + [Dependency] private readonly IClientConsoleHost _consoleHost = default!; + [Dependency] private readonly IClientAdminManager _adminMgr = default!; + [Dependency] private readonly IEntitySystemManager _systems = default!; + + private ISawmill _sawmill = default!; + public event Action? PermissionsUpdated; //Nyano - Summary: need to be able to update perms for new psionics. + public void Initialize() { - [Dependency] private readonly IClientConsoleHost _consoleHost = default!; - [Dependency] private readonly IClientAdminManager _adminMgr = default!; - [Dependency] private readonly IEntitySystemManager _systems = default!; + _sawmill = Logger.GetSawmill("chat"); + _sawmill.Level = LogLevel.Info; + } - private ISawmill _sawmill = default!; - public event Action? PermissionsUpdated; //Nyano - Summary: need to be able to update perms for new psionics. - public void Initialize() - { - _sawmill = Logger.GetSawmill("chat"); - _sawmill.Level = LogLevel.Info; - } + public void SendAdminAlert(string message) + { + // See server-side manager. This just exists for shared code. + } - public void SendMessage(string text, ChatSelectChannel channel) - { - var str = text.ToString(); - switch (channel) - { - case ChatSelectChannel.Console: - // run locally - _consoleHost.ExecuteCommand(text); - break; - - case ChatSelectChannel.LOOC: - _consoleHost.ExecuteCommand($"looc \"{CommandParsing.Escape(str)}\""); - break; - - case ChatSelectChannel.OOC: - _consoleHost.ExecuteCommand($"ooc \"{CommandParsing.Escape(str)}\""); - break; - - case ChatSelectChannel.Admin: - _consoleHost.ExecuteCommand($"asay \"{CommandParsing.Escape(str)}\""); - break; - - case ChatSelectChannel.Emotes: - _consoleHost.ExecuteCommand($"me \"{CommandParsing.Escape(str)}\""); - break; - - case ChatSelectChannel.Dead: - if (_systems.GetEntitySystemOrNull() is {IsGhost: true}) - goto case ChatSelectChannel.Local; - - if (_adminMgr.HasFlag(AdminFlags.Admin)) - _consoleHost.ExecuteCommand($"dsay \"{CommandParsing.Escape(str)}\""); - else - _sawmill.Warning("Tried to speak on deadchat without being ghost or admin."); - break; - - // TODO sepearate radio and say into separate commands. - case ChatSelectChannel.Radio: - case ChatSelectChannel.Local: - _consoleHost.ExecuteCommand($"say \"{CommandParsing.Escape(str)}\""); - break; - - case ChatSelectChannel.Whisper: - _consoleHost.ExecuteCommand($"whisper \"{CommandParsing.Escape(str)}\""); - break; - - //Nyano - Summary: sends the command for telepath communication. - case ChatSelectChannel.Telepathic: - _consoleHost.ExecuteCommand($"tsay \"{CommandParsing.Escape(str)}\""); - break; - - default: - throw new ArgumentOutOfRangeException(nameof(channel), channel, null); - } - } - //Nyano - Summary: fires off the update permissions script. - public void UpdatePermissions() + public void SendAdminAlert(EntityUid player, string message) + { + // See server-side manager. This just exists for shared code. + } + + public void SendMessage(string text, ChatSelectChannel channel) + { + var str = text.ToString(); + switch (channel) { - PermissionsUpdated?.Invoke(); + case ChatSelectChannel.Console: + // run locally + _consoleHost.ExecuteCommand(text); + break; + + case ChatSelectChannel.LOOC: + _consoleHost.ExecuteCommand($"looc \"{CommandParsing.Escape(str)}\""); + break; + + case ChatSelectChannel.OOC: + _consoleHost.ExecuteCommand($"ooc \"{CommandParsing.Escape(str)}\""); + break; + + case ChatSelectChannel.Admin: + _consoleHost.ExecuteCommand($"asay \"{CommandParsing.Escape(str)}\""); + break; + + case ChatSelectChannel.Emotes: + _consoleHost.ExecuteCommand($"me \"{CommandParsing.Escape(str)}\""); + break; + + case ChatSelectChannel.Dead: + if (_systems.GetEntitySystemOrNull() is {IsGhost: true}) + goto case ChatSelectChannel.Local; + + if (_adminMgr.HasFlag(AdminFlags.Admin)) + _consoleHost.ExecuteCommand($"dsay \"{CommandParsing.Escape(str)}\""); + else + _sawmill.Warning("Tried to speak on deadchat without being ghost or admin."); + break; + + // TODO sepearate radio and say into separate commands. + case ChatSelectChannel.Radio: + case ChatSelectChannel.Local: + _consoleHost.ExecuteCommand($"say \"{CommandParsing.Escape(str)}\""); + break; + + case ChatSelectChannel.Whisper: + _consoleHost.ExecuteCommand($"whisper \"{CommandParsing.Escape(str)}\""); + break; + + //Nyano - Summary: sends the command for telepath communication. + case ChatSelectChannel.Telepathic: + _consoleHost.ExecuteCommand($"tsay \"{CommandParsing.Escape(str)}\""); + break; + + default: + throw new ArgumentOutOfRangeException(nameof(channel), channel, null); } } + + //Nyano - Summary: fires off the update permissions script. + public void UpdatePermissions() + { + PermissionsUpdated?.Invoke(); + } } diff --git a/Content.Client/Chat/Managers/IChatManager.cs b/Content.Client/Chat/Managers/IChatManager.cs index a21a8194fd..f731798197 100644 --- a/Content.Client/Chat/Managers/IChatManager.cs +++ b/Content.Client/Chat/Managers/IChatManager.cs @@ -2,10 +2,8 @@ namespace Content.Client.Chat.Managers { - public interface IChatManager + public interface IChatManager : ISharedChatManager { - void Initialize(); - public void SendMessage(string text, ChatSelectChannel channel); /// diff --git a/Content.Client/Entry/EntryPoint.cs b/Content.Client/Entry/EntryPoint.cs index b9c7ab5b7d..bf5f021be3 100644 --- a/Content.Client/Entry/EntryPoint.cs +++ b/Content.Client/Entry/EntryPoint.cs @@ -1,4 +1,3 @@ -using Content.Client._White.TTS; using Content.Client.Administration.Managers; using Content.Client.Changelog; using Content.Client.Chat.Managers; @@ -73,7 +72,6 @@ public sealed class EntryPoint : GameClient [Dependency] private readonly ILogManager _logManager = default!; [Dependency] private readonly JoinQueueManager _joinQueue = default!; [Dependency] private readonly DiscordAuthManager _discordAuth = default!; - [Dependency] private readonly TTSManager _ttsManager = default!; // WD EDIT public override void Init() { @@ -168,7 +166,6 @@ public override void PostInit() _documentParsingManager.Initialize(); _joinQueue.Initialize(); _discordAuth.Initialize(); - _ttsManager.Initialize(); // WD EDIT _baseClient.RunLevelChanged += (_, args) => { diff --git a/Content.Client/IoC/ClientContentIoC.cs b/Content.Client/IoC/ClientContentIoC.cs index df3833eb60..bbdf2b0301 100644 --- a/Content.Client/IoC/ClientContentIoC.cs +++ b/Content.Client/IoC/ClientContentIoC.cs @@ -1,4 +1,3 @@ -using Content.Client._White.TTS; using Content.Client.Administration.Managers; using Content.Client.Changelog; using Content.Client.Chat.Managers; @@ -18,10 +17,12 @@ using Content.Shared.Administration.Logs; using Content.Client.Guidebook; using Content.Client.Lobby; +using Content.Client.Players.RateLimiting; using Content.Client.Replay; using Content.Shared.Administration.Managers; +using Content.Shared.Chat; using Content.Shared.Players.PlayTimeTracking; - +using Content.Shared.Players.RateLimiting; namespace Content.Client.IoC { @@ -33,6 +34,7 @@ public static void Register() collection.Register(); collection.Register(); + collection.Register(); collection.Register(); collection.Register(); collection.Register(); @@ -51,9 +53,10 @@ public static void Register() collection.Register(); collection.Register(); collection.Register(); + collection.Register(); + collection.Register(); IoCManager.Register(); IoCManager.Register(); - IoCManager.Register(); // WD EDIT } } } diff --git a/Content.Client/Players/RateLimiting/PlayerRateLimitManager.cs b/Content.Client/Players/RateLimiting/PlayerRateLimitManager.cs new file mode 100644 index 0000000000..e79eadd92b --- /dev/null +++ b/Content.Client/Players/RateLimiting/PlayerRateLimitManager.cs @@ -0,0 +1,23 @@ +using Content.Shared.Players.RateLimiting; +using Robust.Shared.Player; + +namespace Content.Client.Players.RateLimiting; + +public sealed class PlayerRateLimitManager : SharedPlayerRateLimitManager +{ + public override RateLimitStatus CountAction(ICommonSession player, string key) + { + // TODO Rate-Limit + // Add support for rate limit prediction + // I.e., dont mis-predict just because somebody is clicking too quickly. + return RateLimitStatus.Allowed; + } + + public override void Register(string key, RateLimitRegistration registration) + { + } + + public override void Initialize() + { + } +} diff --git a/Content.Client/_White/TTS/AnnounceTTSSystem.cs b/Content.Client/_White/TTS/AnnounceTTSSystem.cs new file mode 100644 index 0000000000..4191a76664 --- /dev/null +++ b/Content.Client/_White/TTS/AnnounceTTSSystem.cs @@ -0,0 +1,194 @@ +using System.Diagnostics.CodeAnalysis; +using Content.Shared._White.TTS; +using Content.Shared.CCVar; +using Content.Shared.GameTicking; +using Robust.Client.ResourceManagement; +using Robust.Shared.Audio; +using Robust.Shared.Audio.Components; +using Robust.Shared.Audio.Systems; +using Robust.Shared.Configuration; +using Robust.Shared.ContentPack; +using Robust.Shared.Player; +using Robust.Shared.Utility; + +namespace Content.Client._White.TTS; + +// ReSharper disable once InconsistentNaming +public sealed class AnnounceTTSSystem : EntitySystem +{ + [Dependency] private readonly SharedAudioSystem _audio = default!; + [Dependency] private readonly IConfigurationManager _cfg = default!; + [Dependency] private readonly IResourceCache _resourceCache = default!; + + private ISawmill _sawmill = default!; + private readonly MemoryContentRoot _contentRoot = new(); + private ResPath _prefix; + + private float _volume = 0.0f; + private ulong _fileIdx = 0; + private static ulong _shareIdx = 0; + + private TTSAudioStream? _currentlyPlaying; + private readonly Queue _queuedStreams = new(); + + /// + public override void Initialize() + { + _prefix = ResPath.Root / $"TTSAnon{_shareIdx++}"; + _resourceCache.AddRoot(_prefix, _contentRoot); + _sawmill = Logger.GetSawmill("AnnounceTTSSystem"); + _cfg.OnValueChanged(CCVars.AnnouncerVolume, OnTtsVolumeChanged, true); + SubscribeNetworkEvent(OnAnnounceTTSPlay); + SubscribeNetworkEvent(OnCleanup); + } + + private void OnCleanup(RoundRestartCleanupEvent ev) + { + EndStreams(); + _contentRoot.Clear(); + } + + /// + public override void FrameUpdate(float frameTime) + { + if (_queuedStreams.Count == 0) + return; + + var isDoNext = true; + try + { + isDoNext = _currentlyPlaying == null || + (_currentlyPlaying.AudioStream != null && TerminatingOrDeleted(_currentlyPlaying.AudioStream!.Value)) + || !(_currentlyPlaying.AudioStream?.Comp.Playing ?? false); + } + catch (Exception err) + { + isDoNext = true; + } + + if (isDoNext) + { + _currentlyPlaying?.StopAndClean(this); + ProcessEntityQueue(); + } + + } + + /// + public override void Shutdown() + { + _cfg.UnsubValueChanged(CCVars.AnnouncerVolume, OnTtsVolumeChanged); + EndStreams(); + _contentRoot.Dispose(); + } + + private void OnAnnounceTTSPlay(AnnounceTTSEvent ev) + { + var volume = Math.Max(-5f, SharedAudioSystem.GainToVolume(_volume)); + + + var file = new ResPath(ev.AnnouncementSound); + + if (!_resourceCache.TryGetResource(file, out var audio)) + { + _sawmill.Error($"Server tried to play audio file {ev.AnnouncementSound} which does not exist."); + return; + } + + if (TryCreateAudioSource(file, ev.AnnouncementParams.Volume, out var sourceAnnounce)) + AddEntityStreamToQueue(sourceAnnounce); + if (ev.Data.Length > 0 && TryCreateAudioSource(ev.Data, volume, out var source)) + { + source.DelayMs = (int) audio.AudioStream.Length.TotalMilliseconds; + AddEntityStreamToQueue(source); + } + + } + + private void AddEntityStreamToQueue(TTSAudioStream stream) + { + _queuedStreams.Enqueue(stream); + } + + private void ProcessEntityQueue() + { + if (_queuedStreams.TryDequeue(out _currentlyPlaying)) + PlayEntity(_currentlyPlaying); + } + + private bool TryCreateAudioSource(byte[] data, float volume, [NotNullWhen(true)] out TTSAudioStream? source) + { + var filePath = new ResPath($"{_fileIdx++}.ogg"); + _contentRoot.AddOrUpdateFile(filePath, data); + + var audioParams = AudioParams.Default.WithVolume(volume).WithRolloffFactor(1f).WithMaxDistance(float.MaxValue).WithReferenceDistance(1f); + var soundPath = new SoundPathSpecifier(_prefix / filePath, audioParams); + + source = new TTSAudioStream(soundPath, filePath); + + return true; + } + + private bool TryCreateAudioSource(ResPath audio, float volume, + [NotNullWhen(true)] out TTSAudioStream? source) + { + var audioParams = AudioParams.Default.WithVolume(volume).WithRolloffFactor(1f).WithMaxDistance(float.MaxValue).WithReferenceDistance(1f); + + var soundPath = new SoundPathSpecifier(audio, audioParams); + + + source = new TTSAudioStream(soundPath, null); + + return true; + } + + private void PlayEntity(TTSAudioStream stream) + { + stream.AudioStream = _audio.PlayGlobal(stream.Source, Filter.Local(), false); + } + + private void OnTtsVolumeChanged(float volume) + { + _volume = volume; + } + + private void EndStreams() + { + foreach (var stream in _queuedStreams) + { + stream.StopAndClean(this); + } + + _queuedStreams.Clear(); + } + + // ReSharper disable once InconsistentNaming + private sealed class TTSAudioStream + { + public SoundPathSpecifier Source { get; } + public ResPath? CacheFile { get; } + public Entity? AudioStream { get; set; } + + public int DelayMs { get; set; } + + public TTSAudioStream(SoundPathSpecifier source, ResPath? cacheFile, int delayMs = 0) + { + Source = source; + CacheFile = cacheFile; + DelayMs = delayMs; + } + + public void StopAndClean(AnnounceTTSSystem sys) + { + if (AudioStream != null) + { + sys._audio.Stop(AudioStream.Value,AudioStream.Value); + + } + if (CacheFile != null) + { + sys._contentRoot.RemoveFile(CacheFile.Value); + } + } + } +} diff --git a/Content.Client/_White/TTS/HumanoidProfileEditor.TTS.cs b/Content.Client/_White/TTS/HumanoidProfileEditor.TTS.cs index c4ad69ca98..d4606b4faa 100644 --- a/Content.Client/_White/TTS/HumanoidProfileEditor.TTS.cs +++ b/Content.Client/_White/TTS/HumanoidProfileEditor.TTS.cs @@ -1,8 +1,7 @@ using System.Linq; using Content.Client._White.TTS; -using Content.Shared.Preferences; using Content.Shared._White.TTS; -using Robust.Shared.Random; +using Content.Shared.Preferences; // ReSharper disable InconsistentNaming // ReSharper disable once CheckNamespace @@ -10,26 +9,15 @@ namespace Content.Client.Lobby.UI; public sealed partial class HumanoidProfileEditor { - private TTSSystem _ttsSystem = default!; - private TTSManager _ttsManager = default!; - private IRobustRandom _random = default!; - - private List _voiceList = default!; - - private readonly string[] _sampleText = - [ - "Помогите, клоун насилует в технических тоннелях!", - "ХоС, ваши сотрудники украли у меня собаку и засунули ее в стиральную машину!", - "Агент синдиката украл пиво из бара и взорвался!", - "Врача! Позовите врача!" - ]; + private List _voiceList = new(); private void InitializeVoice() { - _random = IoCManager.Resolve(); - _ttsManager = IoCManager.Resolve(); - _ttsSystem = IoCManager.Resolve().System(); - _voiceList = _prototypeManager.EnumeratePrototypes().Where(o => o.RoundStart).ToList(); + _voiceList = _prototypeManager + .EnumeratePrototypes() + .Where(o => o.RoundStart) + .OrderBy(o => Loc.GetString(o.Name)) + .ToList(); VoiceButton.OnItemSelected += args => { @@ -37,7 +25,7 @@ private void InitializeVoice() SetVoice(_voiceList[args.Id].ID); }; - VoicePlayButton.OnPressed += _ => { PlayTTS(); }; + VoicePlayButton.OnPressed += _ => PlayPreviewTTS(); } private void UpdateTTSVoicesControls() @@ -62,16 +50,18 @@ private void UpdateTTSVoicesControls() } var voiceChoiceId = _voiceList.FindIndex(x => x.ID == Profile.Voice); - if (!VoiceButton.TrySelectId(voiceChoiceId) && VoiceButton.TrySelectId(firstVoiceChoiceId)) + if (!VoiceButton.TrySelectId(voiceChoiceId) && + VoiceButton.TrySelectId(firstVoiceChoiceId)) + { SetVoice(_voiceList[firstVoiceChoiceId].ID); + } } - private void PlayTTS() + private void PlayPreviewTTS() { if (Profile is null) return; - _ttsSystem.StopCurrentTTS(PreviewDummy); - _ttsManager.RequestTTS(PreviewDummy, _random.Pick(_sampleText), Profile.Voice); + _entManager.System().RequestGlobalTTS(VoiceRequestType.Preview,Profile.Voice); } } diff --git a/Content.Client/_White/TTS/TTSManager.cs b/Content.Client/_White/TTS/TTSManager.cs index f37989d41e..dec300755d 100644 --- a/Content.Client/_White/TTS/TTSManager.cs +++ b/Content.Client/_White/TTS/TTSManager.cs @@ -1,24 +1,119 @@ +using Content.Shared._White; +using Content.Shared.Chat; using Content.Shared._White.TTS; -using Robust.Shared.Network; +using Content.Shared.GameTicking; +using Robust.Client.Audio; +using Robust.Client.ResourceManagement; +using Robust.Shared.Audio; +using Robust.Shared.Audio.Systems; +using Robust.Shared.Configuration; +using Robust.Shared.ContentPack; +using Robust.Shared.Utility; namespace Content.Client._White.TTS; +/// +/// Plays TTS audio in world +/// // ReSharper disable once InconsistentNaming -public sealed class TTSManager +public sealed class TTSSystem : EntitySystem { - [Dependency] private readonly IClientNetManager _netMgr = default!; - [Dependency] private readonly EntityManager _entityManager = default!; + [Dependency] private readonly IConfigurationManager _cfg = default!; + [Dependency] private readonly IResourceManager _res = default!; + [Dependency] private readonly AudioSystem _audio = default!; - public void Initialize() + private ISawmill _sawmill = default!; + private readonly MemoryContentRoot _contentRoot = new(); + private ResPath _prefix; + + /// + /// Reducing the volume of the TTS when whispering. Will be converted to logarithm. + /// + private const float WhisperFade = 4f; + + /// + /// The volume at which the TTS sound will not be heard. + /// + private const float MinimalVolume = -10f; + + private float _volume = 0.0f; + private ulong _fileIdx = 0; + private static ulong _shareIdx = 0; + + public override void Initialize() { - _netMgr.RegisterNetMessage(); + _prefix = ResPath.Root / $"TTS{_shareIdx++}"; + _sawmill = Logger.GetSawmill("tts"); + _res.AddRoot(_prefix, _contentRoot); + _cfg.OnValueChanged(WhiteCVars.TTSVolume, OnTtsVolumeChanged, true); + SubscribeNetworkEvent(OnPlayTTS); + SubscribeLocalEvent(OnRoundRestart); + } + + private void OnRoundRestart(RoundRestartCleanupEvent ev) + { + _contentRoot.Clear(); + } + + public override void Shutdown() + { + base.Shutdown(); + _cfg.UnsubValueChanged(WhiteCVars.TTSVolume, OnTtsVolumeChanged); + _contentRoot.Dispose(); + } + + public void RequestGlobalTTS(VoiceRequestType text, string voiceId) + { + RaiseNetworkEvent(new RequestPreviewTTSEvent(voiceId)); + } + + private void OnTtsVolumeChanged(float volume) + { + _volume = volume; + } + + private void OnPlayTTS(PlayTTSEvent ev) + { + _sawmill.Verbose($"Play TTS audio {ev.Data.Length} bytes from {ev.SourceUid} entity"); + + var filePath = new ResPath($"{_fileIdx++}.ogg"); + _contentRoot.AddOrUpdateFile(filePath, ev.Data); + + var audioResource = new AudioResource(); + audioResource.Load(IoCManager.Instance!, _prefix / filePath); + + var audioParams = AudioParams.Default + .WithVolume(AdjustVolume(ev.IsWhisper)) + .WithMaxDistance(AdjustDistance(ev.IsWhisper)); + + if (ev.SourceUid != null) + { + var sourceUid = GetEntity(ev.SourceUid.Value); + if(sourceUid.IsValid()) + _audio.PlayEntity(audioResource.AudioStream, sourceUid, audioParams); + } + else + { + _audio.PlayGlobal(audioResource.AudioStream, audioParams); + } + + _contentRoot.RemoveFile(filePath); + } + + private float AdjustVolume(bool isWhisper) + { + var volume = Math.Max(MinimalVolume, SharedAudioSystem.GainToVolume(_volume)); + + if (isWhisper) + { + volume -= SharedAudioSystem.GainToVolume(WhisperFade); + } + + return volume; } - // ReSharper disable once InconsistentNaming - public void RequestTTS(EntityUid uid, string text, string voiceId) + private float AdjustDistance(bool isWhisper) { - var netEntity = _entityManager.GetNetEntity(uid); - var msg = new MsgRequestTTS { Text = text, Uid = netEntity, VoiceId = voiceId }; - _netMgr.ClientSendMessage(msg); + return isWhisper ? SharedChatSystem.WhisperMuffledRange : SharedChatSystem.VoiceRange; } } diff --git a/Content.Client/_White/TTS/TTSSystem.cs b/Content.Client/_White/TTS/TTSSystem.cs deleted file mode 100644 index d8c9fef280..0000000000 --- a/Content.Client/_White/TTS/TTSSystem.cs +++ /dev/null @@ -1,130 +0,0 @@ -using System.IO; -using Content.Shared._White; -using Content.Shared._White.TTS.Events; -using Robust.Client.Audio; -using Robust.Shared.Audio; -using Robust.Shared.Audio.Components; -using Robust.Shared.Configuration; - -namespace Content.Client._White.TTS; - -// ReSharper disable InconsistentNaming -public sealed class TTSSystem : EntitySystem -{ - [Dependency] private readonly IAudioManager _audioManager = default!; - [Dependency] private readonly IConfigurationManager _cfg = default!; - [Dependency] private readonly AudioSystem _audioSystem = default!; - - private float _volume; - private readonly Dictionary _currentlyPlaying = new(); - - private readonly Dictionary> _enquedStreams = new(); - - // Same as Server.ChatSystem.VoiceRange - private const float VoiceRange = 10; - - public override void Initialize() - { - _cfg.OnValueChanged(WhiteCVars.TTSVolume, OnTtsVolumeChanged, true); - - SubscribeNetworkEvent(OnPlayTTS); - } - - public override void Shutdown() - { - base.Shutdown(); - _cfg.UnsubValueChanged(WhiteCVars.TTSVolume, OnTtsVolumeChanged); - ClearQueues(); - } - - public override void FrameUpdate(float frameTime) - { - foreach (var (uid, audioComponent) in _currentlyPlaying) - { - if (!Deleted(uid) && audioComponent is { Running: true, Playing: true } - || !_enquedStreams.TryGetValue(uid, out var queue) - || !queue.TryDequeue(out var toPlay)) - continue; - - var audio = _audioSystem.PlayEntity(toPlay.Stream, uid, toPlay.Params); - if (!audio.HasValue) - continue; - - _currentlyPlaying[uid] = audio.Value.Component; - } - } - - private void OnTtsVolumeChanged(float volume) - { - _volume = volume; - } - - private void OnPlayTTS(PlayTTSEvent ev) - { - PlayTTS(GetEntity(ev.Uid), ev.Data, ev.BoostVolume ? _volume + 5 : _volume); - } - - public void PlayTTS(EntityUid uid, byte[] data, float volume) - { - if (_volume <= -20f) - return; - - var stream = CreateAudioStream(data); - - var audioParams = new AudioParams - { - Volume = volume, - MaxDistance = VoiceRange - }; - - var audioStream = new AudioStreamWithParams(stream, audioParams); - EnqueueAudio(uid, audioStream); - } - - public void StopCurrentTTS(EntityUid uid) - { - if (!_currentlyPlaying.TryGetValue(uid, out var audio)) - return; - - _audioSystem.Stop(audio.Owner); - } - - private void EnqueueAudio(EntityUid uid, AudioStreamWithParams audioStream) - { - if (!_currentlyPlaying.ContainsKey(uid)) - { - var audio = _audioSystem.PlayEntity(audioStream.Stream, uid, audioStream.Params); - if (!audio.HasValue) - return; - - _currentlyPlaying[uid] = audio.Value.Component; - return; - } - - if (_enquedStreams.TryGetValue(uid, out var queue)) - { - queue.Enqueue(audioStream); - return; - } - - queue = new Queue(); - queue.Enqueue(audioStream); - _enquedStreams[uid] = queue; - } - - private void ClearQueues() - { - foreach (var (_, queue) in _enquedStreams) - { - queue.Clear(); - } - } - - private AudioStream CreateAudioStream(byte[] data) - { - var dataStream = new MemoryStream(data) { Position = 0 }; - return _audioManager.LoadAudioOggVorbis(dataStream); - } - - private record AudioStreamWithParams(AudioStream Stream, AudioParams Params); -} diff --git a/Content.IntegrationTests/PoolManager.Cvars.cs b/Content.IntegrationTests/PoolManager.Cvars.cs index 5acd9d502c..8d65dd69ed 100644 --- a/Content.IntegrationTests/PoolManager.Cvars.cs +++ b/Content.IntegrationTests/PoolManager.Cvars.cs @@ -35,7 +35,9 @@ private static readonly (string cvar, string value)[] TestCvars = (CCVars.ConfigPresetDevelopment.Name, "false"), (CCVars.AdminLogsEnabled.Name, "false"), (CCVars.AutosaveEnabled.Name, "false"), - (CVars.NetBufferSize.Name, "0") + (CVars.NetBufferSize.Name, "0"), + (CCVars.InteractionRateLimitCount.Name, "9999999"), + (CCVars.InteractionRateLimitPeriod.Name, "0.1"), }; public static async Task SetupCVars(RobustIntegrationTest.IntegrationInstance instance, PoolSettings settings) diff --git a/Content.Server/Administration/BanPanelEui.cs b/Content.Server/Administration/BanPanelEui.cs index aa6bd8d4bf..b3253f0d0c 100644 --- a/Content.Server/Administration/BanPanelEui.cs +++ b/Content.Server/Administration/BanPanelEui.cs @@ -132,13 +132,12 @@ private async void BanPlayer(string? target, string? ipAddressString, bool useLa } if (erase && - targetUid != null && - _playerManager.TryGetSessionById(targetUid.Value, out var targetPlayer)) + targetUid != null) { try { if (_entities.TrySystem(out AdminSystem? adminSystem)) - adminSystem.Erase(targetPlayer); + adminSystem.Erase(targetUid.Value); } catch (Exception e) { diff --git a/Content.Server/Administration/Commands/EraseCommand.cs b/Content.Server/Administration/Commands/EraseCommand.cs new file mode 100644 index 0000000000..cb01d742a0 --- /dev/null +++ b/Content.Server/Administration/Commands/EraseCommand.cs @@ -0,0 +1,47 @@ +using System.Linq; +using Content.Server.Administration.Systems; +using Content.Shared.Administration; +using Robust.Server.Player; +using Robust.Shared.Console; + +namespace Content.Server.Administration.Commands; + +[AdminCommand(AdminFlags.Admin)] +public sealed class EraseCommand : LocalizedEntityCommands +{ + [Dependency] private readonly IPlayerLocator _locator = default!; + [Dependency] private readonly IPlayerManager _players = default!; + [Dependency] private readonly AdminSystem _admin = default!; + + public override string Command => "erase"; + + public override async void Execute(IConsoleShell shell, string argStr, string[] args) + { + if (args.Length != 1) + { + shell.WriteError(Loc.GetString("cmd-erase-invalid-args")); + shell.WriteLine(Help); + return; + } + + var located = await _locator.LookupIdByNameOrIdAsync(args[0]); + + if (located == null) + { + shell.WriteError(Loc.GetString("cmd-erase-player-not-found")); + return; + } + + _admin.Erase(located.UserId); + } + + public override CompletionResult GetCompletion(IConsoleShell shell, string[] args) + { + if (args.Length != 1) + return CompletionResult.Empty; + + var options = _players.Sessions.OrderBy(c => c.Name).Select(c => c.Name).ToArray(); + + return CompletionResult.FromHintOptions(options, Loc.GetString("cmd-erase-player-completion")); + } +} diff --git a/Content.Server/Administration/Systems/AdminSystem.cs b/Content.Server/Administration/Systems/AdminSystem.cs index 82bbe6e266..cd961c9fba 100644 --- a/Content.Server/Administration/Systems/AdminSystem.cs +++ b/Content.Server/Administration/Systems/AdminSystem.cs @@ -4,7 +4,6 @@ using Content.Server.Forensics; using Content.Server.GameTicking; using Content.Server.Hands.Systems; -using Content.Server.IdentityManagement; using Content.Server.Mind; using Content.Server.Players.PlayTimeTracking; using Content.Server.Popups; @@ -16,7 +15,9 @@ using Content.Shared.Hands.Components; using Content.Shared.IdentityManagement; using Content.Shared.Inventory; +using Content.Shared.Mind; using Content.Shared.PDA; +using Content.Shared.Players; using Content.Shared.Players.PlayTimeTracking; using Content.Shared.Popups; using Content.Shared.Roles; @@ -32,286 +33,299 @@ using Robust.Shared.Network; using Robust.Shared.Player; -namespace Content.Server.Administration.Systems +namespace Content.Server.Administration.Systems; + +public sealed class AdminSystem : EntitySystem { - public sealed class AdminSystem : EntitySystem + [Dependency] private readonly IAdminManager _adminManager = default!; + [Dependency] private readonly IChatManager _chat = default!; + [Dependency] private readonly IConfigurationManager _config = default!; + [Dependency] private readonly IPlayerManager _playerManager = default!; + [Dependency] private readonly HandsSystem _hands = default!; + [Dependency] private readonly SharedJobSystem _jobs = default!; + [Dependency] private readonly InventorySystem _inventory = default!; + [Dependency] private readonly MindSystem _minds = default!; + [Dependency] private readonly PopupSystem _popup = default!; + [Dependency] private readonly PhysicsSystem _physics = default!; + [Dependency] private readonly PlayTimeTrackingManager _playTime = default!; + [Dependency] private readonly SharedRoleSystem _role = default!; + [Dependency] private readonly GameTicker _gameTicker = default!; + [Dependency] private readonly SharedAudioSystem _audio = default!; + [Dependency] private readonly StationRecordsSystem _stationRecords = default!; + [Dependency] private readonly TransformSystem _transform = default!; + + private readonly Dictionary _playerList = new(); + + /// + /// Set of players that have participated in this round. + /// + public IReadOnlySet RoundActivePlayers => _roundActivePlayers; + + private readonly HashSet _roundActivePlayers = new(); + public readonly PanicBunkerStatus PanicBunker = new(); + + public override void Initialize() { - [Dependency] private readonly IAdminManager _adminManager = default!; - [Dependency] private readonly IChatManager _chat = default!; - [Dependency] private readonly IConfigurationManager _config = default!; - [Dependency] private readonly IPlayerManager _playerManager = default!; - [Dependency] private readonly HandsSystem _hands = default!; - [Dependency] private readonly SharedJobSystem _jobs = default!; - [Dependency] private readonly InventorySystem _inventory = default!; - [Dependency] private readonly MindSystem _minds = default!; - [Dependency] private readonly PopupSystem _popup = default!; - [Dependency] private readonly PhysicsSystem _physics = default!; - [Dependency] private readonly PlayTimeTrackingManager _playTime = default!; - [Dependency] private readonly SharedRoleSystem _role = default!; - [Dependency] private readonly GameTicker _gameTicker = default!; - [Dependency] private readonly SharedAudioSystem _audio = default!; - [Dependency] private readonly StationRecordsSystem _stationRecords = default!; - [Dependency] private readonly TransformSystem _transform = default!; - - private readonly Dictionary _playerList = new(); + base.Initialize(); + + _playerManager.PlayerStatusChanged += OnPlayerStatusChanged; + _adminManager.OnPermsChanged += OnAdminPermsChanged; + + // Panic Bunker Settings + Subs.CVar(_config, CCVars.PanicBunkerEnabled, OnPanicBunkerChanged, true); + Subs.CVar(_config, CCVars.PanicBunkerDisableWithAdmins, OnPanicBunkerDisableWithAdminsChanged, true); + Subs.CVar(_config, CCVars.PanicBunkerEnableWithoutAdmins, OnPanicBunkerEnableWithoutAdminsChanged, true); + Subs.CVar(_config, CCVars.PanicBunkerCountDeadminnedAdmins, OnPanicBunkerCountDeadminnedAdminsChanged, true); + Subs.CVar(_config, CCVars.PanicBunkerShowReason, OnPanicBunkerShowReasonChanged, true); + Subs.CVar(_config, CCVars.PanicBunkerMinAccountAge, OnPanicBunkerMinAccountAgeChanged, true); + + SubscribeLocalEvent(OnIdentityChanged); + SubscribeLocalEvent(OnPlayerAttached); + SubscribeLocalEvent(OnPlayerDetached); + SubscribeLocalEvent(OnRoleEvent); + SubscribeLocalEvent(OnRoleEvent); + SubscribeLocalEvent(OnRoundRestartCleanup); + } - /// - /// Set of players that have participated in this round. - /// - public IReadOnlySet RoundActivePlayers => _roundActivePlayers; + private void OnRoundRestartCleanup(RoundRestartCleanupEvent ev) + { + _roundActivePlayers.Clear(); + + foreach (var (id, data) in _playerList) + { + if (!data.ActiveThisRound) + continue; + + if (!_playerManager.TryGetPlayerData(id, out var playerData)) + return; - private readonly HashSet _roundActivePlayers = new(); - public readonly PanicBunkerStatus PanicBunker = new(); + _playerManager.TryGetSessionById(id, out var session); + _playerList[id] = GetPlayerInfo(playerData, session); + } + + var updateEv = new FullPlayerListEvent() { PlayersInfo = _playerList.Values.ToList() }; - public override void Initialize() + foreach (var admin in _adminManager.ActiveAdmins) { - base.Initialize(); - - _playerManager.PlayerStatusChanged += OnPlayerStatusChanged; - _adminManager.OnPermsChanged += OnAdminPermsChanged; - - Subs.CVar(_config, CCVars.PanicBunkerEnabled, OnPanicBunkerChanged, true); - Subs.CVar(_config, CCVars.PanicBunkerDisableWithAdmins, OnPanicBunkerDisableWithAdminsChanged, true); - Subs.CVar(_config, CCVars.PanicBunkerEnableWithoutAdmins, OnPanicBunkerEnableWithoutAdminsChanged, true); - Subs.CVar(_config, CCVars.PanicBunkerCountDeadminnedAdmins, OnPanicBunkerCountDeadminnedAdminsChanged, true); - Subs.CVar(_config, CCVars.PanicBunkerShowReason, OnShowReasonChanged, true); - Subs.CVar(_config, CCVars.PanicBunkerMinAccountAge, OnPanicBunkerMinAccountAgeChanged, true); - Subs.CVar(_config, CCVars.PanicBunkerMinOverallHours, OnPanicBunkerMinOverallHoursChanged, true); - - SubscribeLocalEvent(OnIdentityChanged); - SubscribeLocalEvent(OnPlayerAttached); - SubscribeLocalEvent(OnPlayerDetached); - SubscribeLocalEvent(OnRoleEvent); - SubscribeLocalEvent(OnRoleEvent); - SubscribeLocalEvent(OnRoundRestartCleanup); + RaiseNetworkEvent(updateEv, admin.Channel); } + } + + public void UpdatePlayerList(ICommonSession player) + { + _playerList[player.UserId] = GetPlayerInfo(player.Data, player); - private void OnRoundRestartCleanup(RoundRestartCleanupEvent ev) + var playerInfoChangedEvent = new PlayerInfoChangedEvent { - _roundActivePlayers.Clear(); + PlayerInfo = _playerList[player.UserId] + }; - foreach (var (id, data) in _playerList) - { - if (!data.ActiveThisRound) - continue; + foreach (var admin in _adminManager.ActiveAdmins) + { + RaiseNetworkEvent(playerInfoChangedEvent, admin.Channel); + } + } - if (!_playerManager.TryGetPlayerData(id, out var playerData)) - return; + public PlayerInfo? GetCachedPlayerInfo(NetUserId? netUserId) + { + if (netUserId == null) + return null; - _playerManager.TryGetSessionById(id, out var session); - _playerList[id] = GetPlayerInfo(playerData, session); - } + _playerList.TryGetValue(netUserId.Value, out var value); + return value ?? null; + } - var updateEv = new FullPlayerListEvent() { PlayersInfo = _playerList.Values.ToList() }; + private void OnIdentityChanged(ref IdentityChangedEvent ev) + { + if (!TryComp(ev.CharacterEntity, out var actor)) + return; - foreach (var admin in _adminManager.ActiveAdmins) - { - RaiseNetworkEvent(updateEv, admin.Channel); - } - } + UpdatePlayerList(actor.PlayerSession); + } - public void UpdatePlayerList(ICommonSession player) - { - _playerList[player.UserId] = GetPlayerInfo(player.Data, player); + private void OnRoleEvent(RoleEvent ev) + { + var session = _minds.GetSession(ev.Mind); + if (!ev.Antagonist || session == null) + return; - var playerInfoChangedEvent = new PlayerInfoChangedEvent - { - PlayerInfo = _playerList[player.UserId] - }; + UpdatePlayerList(session); + } - foreach (var admin in _adminManager.ActiveAdmins) - { - RaiseNetworkEvent(playerInfoChangedEvent, admin.Channel); - } - } + private void OnAdminPermsChanged(AdminPermsChangedEventArgs obj) + { + UpdatePanicBunker(); - public PlayerInfo? GetCachedPlayerInfo(NetUserId? netUserId) + if (!obj.IsAdmin) { - if (netUserId == null) - return null; - - _playerList.TryGetValue(netUserId.Value, out var value); - return value ?? null; + RaiseNetworkEvent(new FullPlayerListEvent(), obj.Player.Channel); + return; } - private void OnIdentityChanged(ref IdentityChangedEvent ev) - { - if (!TryComp(ev.CharacterEntity, out var actor)) - return; + SendFullPlayerList(obj.Player); + } - UpdatePlayerList(actor.PlayerSession); - } + private void OnPlayerDetached(PlayerDetachedEvent ev) + { + // If disconnected then the player won't have a connected entity to get character name from. + // The disconnected state gets sent by OnPlayerStatusChanged. + if (ev.Player.Status == SessionStatus.Disconnected) + return; - private void OnRoleEvent(RoleEvent ev) - { - var session = _minds.GetSession(ev.Mind); - if (!ev.Antagonist || session == null) - return; + UpdatePlayerList(ev.Player); + } - UpdatePlayerList(session); - } + private void OnPlayerAttached(PlayerAttachedEvent ev) + { + if (ev.Player.Status == SessionStatus.Disconnected) + return; - private void OnAdminPermsChanged(AdminPermsChangedEventArgs obj) - { - UpdatePanicBunker(); + _roundActivePlayers.Add(ev.Player.UserId); + UpdatePlayerList(ev.Player); + } - if (!obj.IsAdmin) - { - RaiseNetworkEvent(new FullPlayerListEvent(), obj.Player.Channel); - return; - } + public override void Shutdown() + { + base.Shutdown(); + _playerManager.PlayerStatusChanged -= OnPlayerStatusChanged; + _adminManager.OnPermsChanged -= OnAdminPermsChanged; + } - SendFullPlayerList(obj.Player); - } + private void OnPlayerStatusChanged(object? sender, SessionStatusEventArgs e) + { + UpdatePlayerList(e.Session); + UpdatePanicBunker(); + } - private void OnPlayerDetached(PlayerDetachedEvent ev) - { - // If disconnected then the player won't have a connected entity to get character name from. - // The disconnected state gets sent by OnPlayerStatusChanged. - if (ev.Player.Status == SessionStatus.Disconnected) - return; + private void SendFullPlayerList(ICommonSession playerSession) + { + var ev = new FullPlayerListEvent(); - UpdatePlayerList(ev.Player); - } + ev.PlayersInfo = _playerList.Values.ToList(); - private void OnPlayerAttached(PlayerAttachedEvent ev) - { - if (ev.Player.Status == SessionStatus.Disconnected) - return; + RaiseNetworkEvent(ev, playerSession.Channel); + } - _roundActivePlayers.Add(ev.Player.UserId); - UpdatePlayerList(ev.Player); - } + private PlayerInfo GetPlayerInfo(SessionData data, ICommonSession? session) + { + var name = data.UserName; + var entityName = string.Empty; + var identityName = string.Empty; - public override void Shutdown() + if (session?.AttachedEntity != null) { - base.Shutdown(); - _playerManager.PlayerStatusChanged -= OnPlayerStatusChanged; - _adminManager.OnPermsChanged -= OnAdminPermsChanged; + entityName = EntityManager.GetComponent(session.AttachedEntity.Value).EntityName; + identityName = Identity.Name(session.AttachedEntity.Value, EntityManager); } - private void OnPlayerStatusChanged(object? sender, SessionStatusEventArgs e) + var antag = false; + var startingRole = string.Empty; + if (_minds.TryGetMind(session, out var mindId, out _)) { - UpdatePlayerList(e.Session); - UpdatePanicBunker(); + antag = _role.MindIsAntagonist(mindId); + startingRole = _jobs.MindTryGetJobName(mindId); } - private void SendFullPlayerList(ICommonSession playerSession) + var connected = session != null && session.Status is SessionStatus.Connected or SessionStatus.InGame; + TimeSpan? overallPlaytime = null; + if (session != null && + _playTime.TryGetTrackerTimes(session, out var playTimes) && + playTimes.TryGetValue(PlayTimeTrackingShared.TrackerOverall, out var playTime)) { - var ev = new FullPlayerListEvent(); - - ev.PlayersInfo = _playerList.Values.ToList(); - - RaiseNetworkEvent(ev, playerSession.Channel); + overallPlaytime = playTime; } - private PlayerInfo GetPlayerInfo(SessionData data, ICommonSession? session) - { - var name = data.UserName; - var entityName = string.Empty; - var identityName = string.Empty; - - if (session?.AttachedEntity != null) - { - entityName = EntityManager.GetComponent(session.AttachedEntity.Value).EntityName; - identityName = Identity.Name(session.AttachedEntity.Value, EntityManager); - } - - var antag = false; - var startingRole = string.Empty; - if (_minds.TryGetMind(session, out var mindId, out _)) - { - antag = _role.MindIsAntagonist(mindId); - startingRole = _jobs.MindTryGetJobName(mindId); - } + return new PlayerInfo(name, entityName, identityName, startingRole, antag, GetNetEntity(session?.AttachedEntity), data.UserId, + connected, _roundActivePlayers.Contains(data.UserId), overallPlaytime); + } - var connected = session != null && session.Status is SessionStatus.Connected or SessionStatus.InGame; - TimeSpan? overallPlaytime = null; - if (session != null && - _playTime.TryGetTrackerTimes(session, out var playTimes) && - playTimes.TryGetValue(PlayTimeTrackingShared.TrackerOverall, out var playTime)) - { - overallPlaytime = playTime; - } + private void OnPanicBunkerChanged(bool enabled) + { + PanicBunker.Enabled = enabled; + _chat.SendAdminAlert(Loc.GetString(enabled + ? "admin-ui-panic-bunker-enabled-admin-alert" + : "admin-ui-panic-bunker-disabled-admin-alert" + )); - return new PlayerInfo(name, entityName, identityName, startingRole, antag, GetNetEntity(session?.AttachedEntity), data.UserId, - connected, _roundActivePlayers.Contains(data.UserId), overallPlaytime); - } + SendPanicBunkerStatusAll(); + } - private void OnPanicBunkerChanged(bool enabled) - { - PanicBunker.Enabled = enabled; - _chat.SendAdminAlert(Loc.GetString(enabled - ? "admin-ui-panic-bunker-enabled-admin-alert" - : "admin-ui-panic-bunker-disabled-admin-alert" - )); + private void OnPanicBunkerDisableWithAdminsChanged(bool enabled) + { + PanicBunker.DisableWithAdmins = enabled; + UpdatePanicBunker(); + } - SendPanicBunkerStatusAll(); - } + private void OnPanicBunkerEnableWithoutAdminsChanged(bool enabled) + { + PanicBunker.EnableWithoutAdmins = enabled; + UpdatePanicBunker(); + } - private void OnPanicBunkerDisableWithAdminsChanged(bool enabled) - { - PanicBunker.DisableWithAdmins = enabled; - UpdatePanicBunker(); - } + private void OnPanicBunkerCountDeadminnedAdminsChanged(bool enabled) + { + PanicBunker.CountDeadminnedAdmins = enabled; + UpdatePanicBunker(); + } - private void OnPanicBunkerEnableWithoutAdminsChanged(bool enabled) - { - PanicBunker.EnableWithoutAdmins = enabled; - UpdatePanicBunker(); - } + private void OnPanicBunkerShowReasonChanged(bool enabled) + { + PanicBunker.ShowReason = enabled; + SendPanicBunkerStatusAll(); + } - private void OnPanicBunkerCountDeadminnedAdminsChanged(bool enabled) - { - PanicBunker.CountDeadminnedAdmins = enabled; - UpdatePanicBunker(); - } + private void OnPanicBunkerMinAccountAgeChanged(int minutes) + { + PanicBunker.MinAccountAgeHours = minutes / 60; + SendPanicBunkerStatusAll(); + } - private void OnShowReasonChanged(bool enabled) - { - PanicBunker.ShowReason = enabled; - SendPanicBunkerStatusAll(); - } + private void OnPanicBunkerMinOverallHoursChanged(int hours) + { + PanicBunker.MinOverallHours = hours; + SendPanicBunkerStatusAll(); + } - private void OnPanicBunkerMinAccountAgeChanged(int minutes) + private void UpdatePanicBunker() + { + var admins = PanicBunker.CountDeadminnedAdmins + ? _adminManager.AllAdmins + : _adminManager.ActiveAdmins; + var hasAdmins = admins.Any(); + + // TODO Fix order dependent Cvars + // Please for the sake of my sanity don't make cvars & order dependent. + // Just make a bool field on the system instead of having some cvars automatically modify other cvars. + // + // I.e., this: + // /sudo cvar game.panic_bunker.enabled true + // /sudo cvar game.panic_bunker.disable_with_admins true + // and this: + // /sudo cvar game.panic_bunker.disable_with_admins true + // /sudo cvar game.panic_bunker.enabled true + // + // should have the same effect, but currently setting the disable_with_admins can modify enabled. + + if (hasAdmins && PanicBunker.DisableWithAdmins) { - PanicBunker.MinAccountAgeHours = minutes / 60; - SendPanicBunkerStatusAll(); + _config.SetCVar(CCVars.PanicBunkerEnabled, false); } - - private void OnPanicBunkerMinOverallHoursChanged(int hours) + else if (!hasAdmins && PanicBunker.EnableWithoutAdmins) { - PanicBunker.MinOverallHours = hours; - SendPanicBunkerStatusAll(); + _config.SetCVar(CCVars.PanicBunkerEnabled, true); } - private void UpdatePanicBunker() - { - var admins = PanicBunker.CountDeadminnedAdmins - ? _adminManager.AllAdmins - : _adminManager.ActiveAdmins; - var hasAdmins = admins.Any(); - - if (hasAdmins && PanicBunker.DisableWithAdmins) - { - _config.SetCVar(CCVars.PanicBunkerEnabled, false); - } - else if (!hasAdmins && PanicBunker.EnableWithoutAdmins) - { - _config.SetCVar(CCVars.PanicBunkerEnabled, true); - } - - SendPanicBunkerStatusAll(); - } + SendPanicBunkerStatusAll(); + } - private void SendPanicBunkerStatusAll() + private void SendPanicBunkerStatusAll() + { + var ev = new PanicBunkerChangedEvent(PanicBunker); + foreach (var admin in _adminManager.AllAdmins) { - var ev = new PanicBunkerChangedEvent(PanicBunker); - foreach (var admin in _adminManager.AllAdmins) - { - RaiseNetworkEvent(ev, admin); - } + RaiseNetworkEvent(ev, admin); } + } /// /// Erases a player from the round. @@ -319,69 +333,75 @@ private void SendPanicBunkerStatusAll() /// chat messages and showing a popup to other players. /// Their items are dropped on the ground. /// - public void Erase(ICommonSession player) + public void Erase(NetUserId uid) { - var entity = player.AttachedEntity; - _chat.DeleteMessagesBy(player); + _chat.DeleteMessagesBy(uid); - if (entity != null && !TerminatingOrDeleted(entity.Value)) + if (!_minds.TryGetMind(uid, out var mindId, out var mind) || mind.OwnedEntity == null || TerminatingOrDeleted(mind.OwnedEntity.Value)) + return; + + var entity = mind.OwnedEntity.Value; + + if (TryComp(entity, out TransformComponent? transform)) { - if (TryComp(entity.Value, out TransformComponent? transform)) - { - var coordinates = _transform.GetMoverCoordinates(entity.Value, transform); - var name = Identity.Entity(entity.Value, EntityManager); - _popup.PopupCoordinates(Loc.GetString("admin-erase-popup", ("user", name)), coordinates, PopupType.LargeCaution); - var filter = Filter.Pvs(coordinates, 1, EntityManager, _playerManager); - var audioParams = new AudioParams().WithVolume(3); - _audio.PlayStatic("/Audio/DeltaV/Misc/reducedtoatmos.ogg", filter, coordinates, true, audioParams); - } + var coordinates = _transform.GetMoverCoordinates(entity, transform); + var name = Identity.Entity(entity, EntityManager); + _popup.PopupCoordinates(Loc.GetString("admin-erase-popup", ("user", name)), coordinates, PopupType.LargeCaution); + var filter = Filter.Pvs(coordinates, 1, EntityManager, _playerManager); + var audioParams = new AudioParams().WithVolume(3); + _audio.PlayStatic("/Audio/Effects/pop_high.ogg", filter, coordinates, true, audioParams); + } - foreach (var item in _inventory.GetHandOrInventoryEntities(entity.Value)) + foreach (var item in _inventory.GetHandOrInventoryEntities(entity)) + { + if (TryComp(item, out PdaComponent? pda) && + TryComp(pda.ContainedId, out StationRecordKeyStorageComponent? keyStorage) && + keyStorage.Key is { } key && + _stationRecords.TryGetRecord(key, out GeneralStationRecord? record)) { - if (TryComp(item, out PdaComponent? pda) && - TryComp(pda.ContainedId, out StationRecordKeyStorageComponent? keyStorage) && - keyStorage.Key is { } key && - _stationRecords.TryGetRecord(key, out GeneralStationRecord? record)) + if (TryComp(entity, out DnaComponent? dna) && + dna.DNA != record.DNA) { - if (TryComp(entity, out DnaComponent? dna) && - dna.DNA != record.DNA) - { - continue; - } - - if (TryComp(entity, out FingerprintComponent? fingerPrint) && - fingerPrint.Fingerprint != record.Fingerprint) - { - continue; - } - - _stationRecords.RemoveRecord(key); - Del(item); + continue; } - } - if (_inventory.TryGetContainerSlotEnumerator(entity.Value, out var enumerator)) - { - while (enumerator.NextItem(out var item, out var slot)) + if (TryComp(entity, out FingerprintComponent? fingerPrint) && + fingerPrint.Fingerprint != record.Fingerprint) { - if (_inventory.TryUnequip(entity.Value, entity.Value, slot.Name, true, true)) - _physics.ApplyAngularImpulse(item, ThrowingSystem.ThrowAngularImpulse); + continue; } + + _stationRecords.RemoveRecord(key); + Del(item); } + } - if (TryComp(entity.Value, out HandsComponent? hands)) + if (_inventory.TryGetContainerSlotEnumerator(entity, out var enumerator)) + { + while (enumerator.NextItem(out var item, out var slot)) { - foreach (var hand in _hands.EnumerateHands(entity.Value, hands)) - { - _hands.TryDrop(entity.Value, hand, checkActionBlocker: false, doDropInteraction: false, handsComp: hands); - } + if (_inventory.TryUnequip(entity, entity, slot.Name, true, true)) + _physics.ApplyAngularImpulse(item, ThrowingSystem.ThrowAngularImpulse); + } + } + + if (TryComp(entity, out HandsComponent? hands)) + { + foreach (var hand in _hands.EnumerateHands(entity, hands)) + { + _hands.TryDrop(entity, hand, checkActionBlocker: false, doDropInteraction: false, handsComp: hands); } } - _minds.WipeMind(player); + _minds.WipeMind(mindId, mind); QueueDel(entity); - _gameTicker.SpawnObserver(player); + if (_playerManager.TryGetSessionById(uid, out var session)) + _gameTicker.SpawnObserver(session); } + + private void OnSessionPlayTimeUpdated(ICommonSession session) + { + UpdatePlayerList(session); } } diff --git a/Content.Server/Administration/Systems/AdminVerbSystem.cs b/Content.Server/Administration/Systems/AdminVerbSystem.cs index 5bb75b4c99..5aa05ce28b 100644 --- a/Content.Server/Administration/Systems/AdminVerbSystem.cs +++ b/Content.Server/Administration/Systems/AdminVerbSystem.cs @@ -34,7 +34,11 @@ using Robust.Shared.Toolshed; using Robust.Shared.Utility; using System.Linq; -using System.Numerics; +using Content.Server.Silicons.Laws; +using Content.Shared.Silicons.Laws; +using Content.Shared.Silicons.Laws.Components; +using Robust.Server.Player; +using Content.Shared.Mind; using Robust.Shared.Physics.Components; using static Content.Shared.Configurable.ConfigurationComponent; @@ -67,6 +71,9 @@ public sealed partial class AdminVerbSystem : EntitySystem [Dependency] private readonly StationSystem _stations = default!; [Dependency] private readonly StationSpawningSystem _spawning = default!; [Dependency] private readonly ExamineSystemShared _examine = default!; + [Dependency] private readonly AdminFrozenSystem _freeze = default!; + [Dependency] private readonly IPlayerManager _playerManager = default!; + [Dependency] private readonly SiliconLawSystem _siliconLawSystem = default!; private readonly Dictionary> _openSolutionUis = new(); @@ -130,54 +137,6 @@ private void AddAdminVerbs(GetVerbsEvent args) prayerVerb.Impact = LogImpact.Low; args.Verbs.Add(prayerVerb); - // Freeze - var frozen = HasComp(args.Target); - args.Verbs.Add(new Verb - { - Priority = -1, // This is just so it doesn't change position in the menu between freeze/unfreeze. - Text = frozen - ? Loc.GetString("admin-verbs-unfreeze") - : Loc.GetString("admin-verbs-freeze"), - Category = VerbCategory.Admin, - Icon = new SpriteSpecifier.Texture(new ("/Textures/Interface/VerbIcons/snow.svg.192dpi.png")), - Act = () => - { - if (frozen) - RemComp(args.Target); - else - EnsureComp(args.Target); - }, - Impact = LogImpact.Medium, - }); - - // Erase - args.Verbs.Add(new Verb - { - Text = Loc.GetString("admin-verbs-erase"), - Message = Loc.GetString("admin-verbs-erase-description"), - Category = VerbCategory.Admin, - Icon = new SpriteSpecifier.Texture(new("/Textures/Interface/VerbIcons/delete_transparent.svg.192dpi.png")), - Act = () => - { - _adminSystem.Erase(targetActor.PlayerSession); - }, - Impact = LogImpact.Extreme, - ConfirmationPopup = true - }); - - // Respawn - args.Verbs.Add(new Verb() - { - Text = Loc.GetString("admin-player-actions-respawn"), - Category = VerbCategory.Admin, - Act = () => - { - _console.ExecuteCommand(player, $"respawn {targetActor.PlayerSession.Name}"); - }, - ConfirmationPopup = true, - // No logimpact as the command does it internally. - }); - // Spawn - Like respawn but on the spot. args.Verbs.Add(new Verb() { @@ -199,7 +158,7 @@ private void AddAdminVerbs(GetVerbsEvent args) if (targetMind != null) { - _mindSystem.TransferTo(targetMind.Value, mobUid); + _mindSystem.TransferTo(targetMind.Value, mobUid, true); } }, ConfirmationPopup = true, @@ -227,8 +186,103 @@ private void AddAdminVerbs(GetVerbsEvent args) ConfirmationPopup = true, Impact = LogImpact.High, }); + + // PlayerPanel + args.Verbs.Add(new Verb + { + Text = Loc.GetString("admin-player-actions-player-panel"), + Category = VerbCategory.Admin, + Act = () => _console.ExecuteCommand(player, $"playerpanel \"{targetActor.PlayerSession.UserId}\""), + Impact = LogImpact.Low + }); + } + + if (_mindSystem.TryGetMind(args.Target, out _, out var mind) && mind.UserId != null) + { + // Erase + args.Verbs.Add(new Verb + { + Text = Loc.GetString("admin-verbs-erase"), + Message = Loc.GetString("admin-verbs-erase-description"), + Category = VerbCategory.Admin, + Icon = new SpriteSpecifier.Texture( + new("/Textures/Interface/VerbIcons/delete_transparent.svg.192dpi.png")), + Act = () => + { + _adminSystem.Erase(mind.UserId.Value); + }, + Impact = LogImpact.Extreme, + ConfirmationPopup = true + }); + + // Respawn + args.Verbs.Add(new Verb + { + Text = Loc.GetString("admin-player-actions-respawn"), + Category = VerbCategory.Admin, + Act = () => + { + _console.ExecuteCommand(player, $"respawn \"{mind.UserId}\""); + }, + ConfirmationPopup = true, + // No logimpact as the command does it internally. + }); + } + + // Freeze + var frozen = TryComp(args.Target, out var frozenComp); + var frozenAndMuted = frozenComp?.Muted ?? false; + + if (!frozen) + { + args.Verbs.Add(new Verb + { + Priority = -1, // This is just so it doesn't change position in the menu between freeze/unfreeze. + Text = Loc.GetString("admin-verbs-freeze"), + Category = VerbCategory.Admin, + Icon = new SpriteSpecifier.Texture(new ("/Textures/Interface/VerbIcons/snow.svg.192dpi.png")), + Act = () => + { + EnsureComp(args.Target); + }, + Impact = LogImpact.Medium, + }); + } + + if (!frozenAndMuted) + { + // allow you to additionally mute someone when they are already frozen + args.Verbs.Add(new Verb + { + Priority = -1, // This is just so it doesn't change position in the menu between freeze/unfreeze. + Text = Loc.GetString("admin-verbs-freeze-and-mute"), + Category = VerbCategory.Admin, + Icon = new SpriteSpecifier.Texture(new ("/Textures/Interface/VerbIcons/snow.svg.192dpi.png")), + Act = () => + { + _freeze.FreezeAndMute(args.Target); + }, + Impact = LogImpact.Medium, + }); + } + + if (frozen) + { + args.Verbs.Add(new Verb + { + Priority = -1, // This is just so it doesn't change position in the menu between freeze/unfreeze. + Text = Loc.GetString("admin-verbs-unfreeze"), + Category = VerbCategory.Admin, + Icon = new SpriteSpecifier.Texture(new ("/Textures/Interface/VerbIcons/snow.svg.192dpi.png")), + Act = () => + { + RemComp(args.Target); + }, + Impact = LogImpact.Medium, + }); } + // Admin Logs if (_adminManager.HasAdminFlag(player, AdminFlags.Logs)) { @@ -294,6 +348,25 @@ private void AddAdminVerbs(GetVerbsEvent args) Impact = LogImpact.Low }); + if (TryComp(args.Target, out var lawBoundComponent)) + { + args.Verbs.Add(new Verb() + { + Text = Loc.GetString("silicon-law-ui-verb"), + Category = VerbCategory.Admin, + Act = () => + { + var ui = new SiliconLawEui(_siliconLawSystem, EntityManager, _adminManager); + if (!_playerManager.TryGetSessionByEntity(args.User, out var session)) + { + return; + } + _euiManager.OpenEui(ui, session); + ui.UpdateLaws(lawBoundComponent, args.Target); + }, + Icon = new SpriteSpecifier.Rsi(new ResPath("/Textures/Interface/Actions/actions_borg.rsi"), "state-laws"), + }); + } } } diff --git a/Content.Server/Administration/Systems/BwoinkSystem.cs b/Content.Server/Administration/Systems/BwoinkSystem.cs index a07115544b..1efc0a9d56 100644 --- a/Content.Server/Administration/Systems/BwoinkSystem.cs +++ b/Content.Server/Administration/Systems/BwoinkSystem.cs @@ -7,11 +7,15 @@ using System.Threading.Tasks; using Content.Server.Administration.Managers; using Content.Server.Afk; +using Content.Server.Database; using Content.Server.Discord; using Content.Server.GameTicking; +using Content.Server.Players.RateLimiting; using Content.Shared.Administration; using Content.Shared.CCVar; +using Content.Shared.GameTicking; using Content.Shared.Mind; +using Content.Shared.Players.RateLimiting; using JetBrains.Annotations; using Robust.Server.Player; using Robust.Shared; @@ -27,6 +31,8 @@ namespace Content.Server.Administration.Systems [UsedImplicitly] public sealed partial class BwoinkSystem : SharedBwoinkSystem { + private const string RateLimitKey = "AdminHelp"; + [Dependency] private readonly IPlayerManager _playerManager = default!; [Dependency] private readonly IAdminManager _adminManager = default!; [Dependency] private readonly IConfigurationManager _config = default!; @@ -35,6 +41,8 @@ public sealed partial class BwoinkSystem : SharedBwoinkSystem [Dependency] private readonly GameTicker _gameTicker = default!; [Dependency] private readonly SharedMindSystem _minds = default!; [Dependency] private readonly IAfkManager _afkManager = default!; + [Dependency] private readonly IServerDbManager _dbManager = default!; + [Dependency] private readonly PlayerRateLimitManager _rateLimit = default!; [GeneratedRegex(@"^https://discord\.com/api/webhooks/(\d+)/((?!.*/).*)$")] private static partial Regex DiscordRegex(); @@ -46,7 +54,11 @@ public sealed partial class BwoinkSystem : SharedBwoinkSystem private string _footerIconUrl = string.Empty; private string _avatarUrl = string.Empty; private string _serverName = string.Empty; - private readonly Dictionary _relayMessages = new(); + + private readonly + Dictionary _relayMessages = new(); + private Dictionary _oldMessageIds = new(); private readonly Dictionary> _messageQueues = new(); private readonly HashSet _processingChannels = new(); @@ -65,6 +77,7 @@ public sealed partial class BwoinkSystem : SharedBwoinkSystem private const string TooLongText = "... **(too long)**"; private int _maxAdditionalChars; + private readonly Dictionary _activeConversations = new(); public override void Initialize() { @@ -75,11 +88,34 @@ public override void Initialize() Subs.CVar(_config, CVars.GameHostName, OnServerNameChanged, true); Subs.CVar(_config, CCVars.AdminAhelpOverrideClientName, OnOverrideChanged, true); _sawmill = IoCManager.Resolve().GetSawmill("AHELP"); - _maxAdditionalChars = GenerateAHelpMessage("", "", true, _gameTicker.RoundDuration().ToString("hh\\:mm\\:ss"), _gameTicker.RunLevel, playedSound: false).Length; + var defaultParams = new AHelpMessageParams( + string.Empty, + string.Empty, + true, + _gameTicker.RoundDuration().ToString("hh\\:mm\\:ss"), + _gameTicker.RunLevel, + playedSound: false + ); + _maxAdditionalChars = GenerateAHelpMessage(defaultParams).Length; _playerManager.PlayerStatusChanged += OnPlayerStatusChanged; SubscribeLocalEvent(OnGameRunLevelChanged); SubscribeNetworkEvent(OnClientTypingUpdated); + SubscribeLocalEvent(_ => _activeConversations.Clear()); + + _rateLimit.Register( + RateLimitKey, + new RateLimitRegistration(CCVars.AhelpRateLimitPeriod, + CCVars.AhelpRateLimitCount, + PlayerRateLimitedAction) + ); + } + + private void PlayerRateLimitedAction(ICommonSession obj) + { + RaiseNetworkEvent( + new BwoinkTextMessage(obj.UserId, default, Loc.GetString("bwoink-system-rate-limited"), playSound: false), + obj.Channel); } private void OnOverrideChanged(string obj) @@ -87,14 +123,129 @@ private void OnOverrideChanged(string obj) _overrideClientName = obj; } - private void OnPlayerStatusChanged(object? sender, SessionStatusEventArgs e) + private async void OnPlayerStatusChanged(object? sender, SessionStatusEventArgs e) { + if (e.NewStatus == SessionStatus.Disconnected) + { + if (_activeConversations.TryGetValue(e.Session.UserId, out var lastMessageTime)) + { + var timeSinceLastMessage = DateTime.Now - lastMessageTime; + if (timeSinceLastMessage > TimeSpan.FromMinutes(5)) + { + _activeConversations.Remove(e.Session.UserId); + return; // Do not send disconnect message if timeout exceeded + } + } + + // Check if the user has been banned + var ban = await _dbManager.GetServerBanAsync(null, e.Session.UserId, null); + if (ban != null) + { + var banMessage = Loc.GetString("bwoink-system-player-banned", ("banReason", ban.Reason)); + NotifyAdmins(e.Session, banMessage, PlayerStatusType.Banned); + _activeConversations.Remove(e.Session.UserId); + return; + } + } + + // Notify all admins if a player disconnects or reconnects + var message = e.NewStatus switch + { + SessionStatus.Connected => Loc.GetString("bwoink-system-player-reconnecting"), + SessionStatus.Disconnected => Loc.GetString("bwoink-system-player-disconnecting"), + _ => null + }; + + if (message != null) + { + var statusType = e.NewStatus == SessionStatus.Connected + ? PlayerStatusType.Connected + : PlayerStatusType.Disconnected; + NotifyAdmins(e.Session, message, statusType); + } + if (e.NewStatus != SessionStatus.InGame) return; RaiseNetworkEvent(new BwoinkDiscordRelayUpdated(!string.IsNullOrWhiteSpace(_webhookUrl)), e.Session); } + private void NotifyAdmins(ICommonSession session, string message, PlayerStatusType statusType) + { + if (!_activeConversations.ContainsKey(session.UserId)) + { + // If the user is not part of an active conversation, do not notify admins. + return; + } + + // Get the current timestamp + var timestamp = DateTime.Now.ToString("HH:mm:ss"); + var roundTime = _gameTicker.RoundDuration().ToString("hh\\:mm\\:ss"); + + // Determine the icon based on the status type + string icon = statusType switch + { + PlayerStatusType.Connected => ":green_circle:", + PlayerStatusType.Disconnected => ":red_circle:", + PlayerStatusType.Banned => ":no_entry:", + _ => ":question:" + }; + + // Create the message parameters for Discord + var messageParams = new AHelpMessageParams( + session.Name, + message, + true, + roundTime, + _gameTicker.RunLevel, + playedSound: true, + icon: icon + ); + + // Create the message for in-game with username + var color = statusType switch + { + PlayerStatusType.Connected => Color.Green.ToHex(), + PlayerStatusType.Disconnected => Color.Yellow.ToHex(), + PlayerStatusType.Banned => Color.Orange.ToHex(), + _ => Color.Gray.ToHex(), + }; + var inGameMessage = $"[color={color}]{session.Name} {message}[/color]"; + + var bwoinkMessage = new BwoinkTextMessage( + userId: session.UserId, + trueSender: SystemUserId, + text: inGameMessage, + sentAt: DateTime.Now, + playSound: false + ); + + var admins = GetTargetAdmins(); + foreach (var admin in admins) + { + RaiseNetworkEvent(bwoinkMessage, admin); + } + + // Enqueue the message for Discord relay + if (_webhookUrl != string.Empty) + { + // if (!_messageQueues.ContainsKey(session.UserId)) + // _messageQueues[session.UserId] = new Queue(); + // + // var escapedText = FormattedMessage.EscapeText(message); + // messageParams.Message = escapedText; + // + // var discordMessage = GenerateAHelpMessage(messageParams); + // _messageQueues[session.UserId].Enqueue(discordMessage); + + var queue = _messageQueues.GetOrNew(session.UserId); + var escapedText = FormattedMessage.EscapeText(message); + messageParams.Message = escapedText; + var discordMessage = GenerateAHelpMessage(messageParams); + queue.Enqueue(discordMessage); + } + } + private void OnGameRunLevelChanged(GameRunLevelChangedEvent args) { // Don't make a new embed if we @@ -189,7 +340,8 @@ private async Task SetWebhookData(string id, string token) var content = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { - _sawmill.Log(LogLevel.Error, $"Discord returned bad status code when trying to get webhook data (perhaps the webhook URL is invalid?): {response.StatusCode}\nResponse: {content}"); + _sawmill.Log(LogLevel.Error, + $"Discord returned bad status code when trying to get webhook data (perhaps the webhook URL is invalid?): {response.StatusCode}\nResponse: {content}"); return; } @@ -213,7 +365,7 @@ private async void ProcessQueue(NetUserId userId, Queue messages) // Whether the message will become too long after adding these new messages var tooLong = exists && messages.Sum(msg => Math.Min(msg.Length, MessageLengthCap) + "\n".Length) - + existingEmbed.description.Length > DescriptionMax; + + existingEmbed.description.Length > DescriptionMax; // If there is no existing embed, or it is getting too long, we create a new embed if (!exists || tooLong) @@ -222,7 +374,8 @@ private async void ProcessQueue(NetUserId userId, Queue messages) if (lookup == null) { - _sawmill.Log(LogLevel.Error, $"Unable to find player for NetUserId {userId} when sending discord webhook."); + _sawmill.Log(LogLevel.Error, + $"Unable to find player for NetUserId {userId} when sending discord webhook."); _relayMessages.Remove(userId); return; } @@ -234,11 +387,13 @@ private async void ProcessQueue(NetUserId userId, Queue messages) { if (tooLong && existingEmbed.id != null) { - linkToPrevious = $"**[Go to previous embed of this round](https://discord.com/channels/{guildId}/{channelId}/{existingEmbed.id})**\n"; + linkToPrevious = + $"**[Go to previous embed of this round](https://discord.com/channels/{guildId}/{channelId}/{existingEmbed.id})**\n"; } else if (_oldMessageIds.TryGetValue(userId, out var id) && !string.IsNullOrEmpty(id)) { - linkToPrevious = $"**[Go to last round's conversation with this player](https://discord.com/channels/{guildId}/{channelId}/{id})**\n"; + linkToPrevious = + $"**[Go to last round's conversation with this player](https://discord.com/channels/{guildId}/{channelId}/{id})**\n"; } } @@ -254,7 +409,8 @@ private async void ProcessQueue(NetUserId userId, Queue messages) GameRunLevel.PreRoundLobby => "\n\n:arrow_forward: _**Pre-round lobby started**_\n", GameRunLevel.InRound => "\n\n:arrow_forward: _**Round started**_\n", GameRunLevel.PostRound => "\n\n:stop_button: _**Post-round started**_\n", - _ => throw new ArgumentOutOfRangeException(nameof(_gameTicker.RunLevel), $"{_gameTicker.RunLevel} was not matched."), + _ => throw new ArgumentOutOfRangeException(nameof(_gameTicker.RunLevel), + $"{_gameTicker.RunLevel} was not matched."), }; existingEmbed.lastRunLevel = _gameTicker.RunLevel; @@ -270,7 +426,9 @@ private async void ProcessQueue(NetUserId userId, Queue messages) existingEmbed.description += $"\n{message}"; } - var payload = GeneratePayload(existingEmbed.description, existingEmbed.username, existingEmbed.characterName); + var payload = GeneratePayload(existingEmbed.description, + existingEmbed.username, + existingEmbed.characterName); // If there is no existing embed, create a new one // Otherwise patch (edit) it @@ -282,7 +440,8 @@ private async void ProcessQueue(NetUserId userId, Queue messages) var content = await request.Content.ReadAsStringAsync(); if (!request.IsSuccessStatusCode) { - _sawmill.Log(LogLevel.Error, $"Discord returned bad status code when posting message (perhaps the message is too long?): {request.StatusCode}\nResponse: {content}"); + _sawmill.Log(LogLevel.Error, + $"Discord returned bad status code when posting message (perhaps the message is too long?): {request.StatusCode}\nResponse: {content}"); _relayMessages.Remove(userId); return; } @@ -290,7 +449,8 @@ private async void ProcessQueue(NetUserId userId, Queue messages) var id = JsonNode.Parse(content)?["id"]; if (id == null) { - _sawmill.Log(LogLevel.Error, $"Could not find id in json-content returned from discord webhook: {content}"); + _sawmill.Log(LogLevel.Error, + $"Could not find id in json-content returned from discord webhook: {content}"); _relayMessages.Remove(userId); return; } @@ -305,7 +465,8 @@ private async void ProcessQueue(NetUserId userId, Queue messages) if (!request.IsSuccessStatusCode) { var content = await request.Content.ReadAsStringAsync(); - _sawmill.Log(LogLevel.Error, $"Discord returned bad status code when patching message (perhaps the message is too long?): {request.StatusCode}\nResponse: {content}"); + _sawmill.Log(LogLevel.Error, + $"Discord returned bad status code when patching message (perhaps the message is too long?): {request.StatusCode}\nResponse: {content}"); _relayMessages.Remove(userId); return; } @@ -335,7 +496,8 @@ private WebhookPayload GeneratePayload(string messages, string username, string? : $"pre-round lobby for round {_gameTicker.RoundId + 1}", GameRunLevel.InRound => $"round {_gameTicker.RoundId}", GameRunLevel.PostRound => $"post-round {_gameTicker.RoundId}", - _ => throw new ArgumentOutOfRangeException(nameof(_gameTicker.RunLevel), $"{_gameTicker.RunLevel} was not matched."), + _ => throw new ArgumentOutOfRangeException(nameof(_gameTicker.RunLevel), + $"{_gameTicker.RunLevel} was not matched."), }; return new WebhookPayload @@ -381,6 +543,7 @@ public override void Update(float frameTime) protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySessionEventArgs eventArgs) { base.OnBwoinkTextMessage(message, eventArgs); + _activeConversations[message.UserId] = DateTime.Now; var senderSession = eventArgs.SenderSession; // TODO: Sanitize text? @@ -395,17 +558,29 @@ protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySes return; } + if (_rateLimit.CountAction(eventArgs.SenderSession, RateLimitKey) != RateLimitStatus.Allowed) + return; + var escapedText = FormattedMessage.EscapeText(message.Text); string bwoinkText; + string adminPrefix = ""; + + //Getting an administrator position + if (_config.GetCVar(CCVars.AhelpAdminPrefix) && senderAdmin is not null && senderAdmin.Title is not null) + { + adminPrefix = $"[bold]\\[{senderAdmin.Title}\\][/bold] "; + } - if (senderAdmin is not null && senderAdmin.Flags == AdminFlags.Adminhelp) // Mentor. Not full admin. That's why it's colored differently. + if (senderAdmin is not null && + senderAdmin.Flags == + AdminFlags.Adminhelp) // Mentor. Not full admin. That's why it's colored differently. { - bwoinkText = $"[color=purple]{senderSession.Name}[/color]"; + bwoinkText = $"[color=purple]{adminPrefix}{senderSession.Name}[/color]"; } else if (senderAdmin is not null && senderAdmin.HasFlag(AdminFlags.Adminhelp)) { - bwoinkText = $"[color=red]{senderSession.Name}[/color]"; + bwoinkText = $"[color=red]{adminPrefix}{senderSession.Name}[/color]"; } else { @@ -428,6 +603,13 @@ protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySes RaiseNetworkEvent(msg, channel); } + string adminPrefixWebhook = ""; + + if (_config.GetCVar(CCVars.AhelpAdminPrefixWebhook) && senderAdmin is not null && senderAdmin.Title is not null) + { + adminPrefixWebhook = $"[bold]\\[{senderAdmin.Title}\\][/bold] "; + } + // Notify player if (_playerManager.TryGetSessionById(message.UserId, out var session)) { @@ -438,13 +620,15 @@ protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySes { string overrideMsgText; // Doing the same thing as above, but with the override name. Theres probably a better way to do this. - if (senderAdmin is not null && senderAdmin.Flags == AdminFlags.Adminhelp) // Mentor. Not full admin. That's why it's colored differently. + if (senderAdmin is not null && + senderAdmin.Flags == + AdminFlags.Adminhelp) // Mentor. Not full admin. That's why it's colored differently. { - overrideMsgText = $"[color=purple]{_overrideClientName}[/color]"; + overrideMsgText = $"[color=purple]{adminPrefixWebhook}{_overrideClientName}[/color]"; } else if (senderAdmin is not null && senderAdmin.HasFlag(AdminFlags.Adminhelp)) { - overrideMsgText = $"[color=red]{_overrideClientName}[/color]"; + overrideMsgText = $"[color=red]{adminPrefixWebhook}{_overrideClientName}[/color]"; } else { @@ -453,7 +637,11 @@ protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySes overrideMsgText = $"{(message.PlaySound ? "" : "(S) ")}{overrideMsgText}: {escapedText}"; - RaiseNetworkEvent(new BwoinkTextMessage(message.UserId, senderSession.UserId, overrideMsgText, playSound: playSound), session.Channel); + RaiseNetworkEvent(new BwoinkTextMessage(message.UserId, + senderSession.UserId, + overrideMsgText, + playSound: playSound), + session.Channel); } else RaiseNetworkEvent(msg, session.Channel); @@ -473,8 +661,18 @@ protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySes { str = str[..(DescriptionMax - _maxAdditionalChars - unameLength)]; } + var nonAfkAdmins = GetNonAfkAdmins(); - _messageQueues[msg.UserId].Enqueue(GenerateAHelpMessage(senderSession.Name, str, !personalChannel, _gameTicker.RoundDuration().ToString("hh\\:mm\\:ss"), _gameTicker.RunLevel, playedSound: playSound, noReceivers: nonAfkAdmins.Count == 0)); + var messageParams = new AHelpMessageParams( + senderSession.Name, + str, + !personalChannel, + _gameTicker.RoundDuration().ToString("hh\\:mm\\:ss"), + _gameTicker.RunLevel, + playedSound: playSound, + noReceivers: nonAfkAdmins.Count == 0 + ); + _messageQueues[msg.UserId].Enqueue(GenerateAHelpMessage(messageParams)); } if (admins.Count != 0 || sendsWebhook) @@ -489,7 +687,8 @@ protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySes private IList GetNonAfkAdmins() { return _adminManager.ActiveAdmins - .Where(p => (_adminManager.GetAdminData(p)?.HasFlag(AdminFlags.Adminhelp) ?? false) && !_afkManager.IsAfk(p)) + .Where(p => (_adminManager.GetAdminData(p)?.HasFlag(AdminFlags.Adminhelp) ?? false) && + !_afkManager.IsAfk(p)) .Select(p => p.Channel) .ToList(); } @@ -502,25 +701,69 @@ private IList GetTargetAdmins() .ToList(); } - private static string GenerateAHelpMessage(string username, string message, bool admin, string roundTime, GameRunLevel roundState, bool playedSound, bool noReceivers = false) + private static string GenerateAHelpMessage(AHelpMessageParams parameters) { var stringbuilder = new StringBuilder(); - if (admin) + if (parameters.Icon != null) + stringbuilder.Append(parameters.Icon); + else if (parameters.IsAdmin) stringbuilder.Append(":outbox_tray:"); - else if (noReceivers) + else if (parameters.NoReceivers) stringbuilder.Append(":sos:"); else stringbuilder.Append(":inbox_tray:"); - if(roundTime != string.Empty && roundState == GameRunLevel.InRound) - stringbuilder.Append($" **{roundTime}**"); - if (!playedSound) + if (parameters.RoundTime != string.Empty && parameters.RoundState == GameRunLevel.InRound) + stringbuilder.Append($" **{parameters.RoundTime}**"); + if (!parameters.PlayedSound) stringbuilder.Append(" **(S)**"); - stringbuilder.Append($" **{username}:** "); - stringbuilder.Append(message); + + if (parameters.Icon == null) + stringbuilder.Append($" **{parameters.Username}:** "); + else + stringbuilder.Append($" **{parameters.Username}** "); + stringbuilder.Append(parameters.Message); return stringbuilder.ToString(); } } -} + public sealed class AHelpMessageParams + { + public string Username { get; set; } + public string Message { get; set; } + public bool IsAdmin { get; set; } + public string RoundTime { get; set; } + public GameRunLevel RoundState { get; set; } + public bool PlayedSound { get; set; } + public bool NoReceivers { get; set; } + public string? Icon { get; set; } + + public AHelpMessageParams( + string username, + string message, + bool isAdmin, + string roundTime, + GameRunLevel roundState, + bool playedSound, + bool noReceivers = false, + string? icon = null) + { + Username = username; + Message = message; + IsAdmin = isAdmin; + RoundTime = roundTime; + RoundState = roundState; + PlayedSound = playedSound; + NoReceivers = noReceivers; + Icon = icon; + } + } + + public enum PlayerStatusType + { + Connected, + Disconnected, + Banned, + } +} diff --git a/Content.Server/Chat/Managers/ChatManager.RateLimit.cs b/Content.Server/Chat/Managers/ChatManager.RateLimit.cs index cf87ab6322..ccb38166a6 100644 --- a/Content.Server/Chat/Managers/ChatManager.RateLimit.cs +++ b/Content.Server/Chat/Managers/ChatManager.RateLimit.cs @@ -1,84 +1,38 @@ -using System.Runtime.InteropServices; using Content.Shared.CCVar; using Content.Shared.Database; -using Robust.Shared.Enums; +using Content.Shared.Players.RateLimiting; using Robust.Shared.Player; -using Robust.Shared.Timing; namespace Content.Server.Chat.Managers; internal sealed partial class ChatManager { - private readonly Dictionary _rateLimitData = new(); + private const string RateLimitKey = "Chat"; - public bool HandleRateLimit(ICommonSession player) + private void RegisterRateLimits() { - ref var datum = ref CollectionsMarshal.GetValueRefOrAddDefault(_rateLimitData, player, out _); - var time = _gameTiming.RealTime; - if (datum.CountExpires < time) - { - // Period expired, reset it. - var periodLength = _configurationManager.GetCVar(CCVars.ChatRateLimitPeriod); - datum.CountExpires = time + TimeSpan.FromSeconds(periodLength); - datum.Count = 0; - datum.Announced = false; - } - - var maxCount = _configurationManager.GetCVar(CCVars.ChatRateLimitCount); - datum.Count += 1; - - if (datum.Count <= maxCount) - return true; - - // Breached rate limits, inform admins if configured. - if (_configurationManager.GetCVar(CCVars.ChatRateLimitAnnounceAdmins)) - { - if (datum.NextAdminAnnounce < time) - { - SendAdminAlert(Loc.GetString("chat-manager-rate-limit-admin-announcement", ("player", player.Name))); - var delay = _configurationManager.GetCVar(CCVars.ChatRateLimitAnnounceAdminsDelay); - datum.NextAdminAnnounce = time + TimeSpan.FromSeconds(delay); - } - } - - if (!datum.Announced) - { - DispatchServerMessage(player, Loc.GetString("chat-manager-rate-limited"), suppressLog: true); - _adminLogger.Add(LogType.ChatRateLimited, LogImpact.Medium, $"Player {player} breached chat rate limits"); - - datum.Announced = true; - } - - return false; + _rateLimitManager.Register(RateLimitKey, + new RateLimitRegistration(CCVars.ChatRateLimitPeriod, + CCVars.ChatRateLimitCount, + RateLimitPlayerLimited, + CCVars.ChatRateLimitAnnounceAdminsDelay, + RateLimitAlertAdmins, + LogType.ChatRateLimited) + ); } - private void PlayerStatusChanged(object? sender, SessionStatusEventArgs e) + private void RateLimitPlayerLimited(ICommonSession player) { - if (e.NewStatus == SessionStatus.Disconnected) - _rateLimitData.Remove(e.Session); + DispatchServerMessage(player, Loc.GetString("chat-manager-rate-limited"), suppressLog: true); } - private struct RateLimitDatum + private void RateLimitAlertAdmins(ICommonSession player) { - /// - /// Time stamp (relative to ) this rate limit period will expire at. - /// - public TimeSpan CountExpires; - - /// - /// How many messages have been sent in the current rate limit period. - /// - public int Count; - - /// - /// Have we announced to the player that they've been blocked in this rate limit period? - /// - public bool Announced; + SendAdminAlert(Loc.GetString("chat-manager-rate-limit-admin-announcement", ("player", player.Name))); + } - /// - /// Time stamp (relative to ) of the - /// next time we can send an announcement to admins about rate limit breach. - /// - public TimeSpan NextAdminAnnounce; + public RateLimitStatus HandleRateLimit(ICommonSession player) + { + return _rateLimitManager.CountAction(player, RateLimitKey); } } diff --git a/Content.Server/Chat/Managers/ChatManager.cs b/Content.Server/Chat/Managers/ChatManager.cs index 812aed80bd..02f718daef 100644 --- a/Content.Server/Chat/Managers/ChatManager.cs +++ b/Content.Server/Chat/Managers/ChatManager.cs @@ -5,392 +5,397 @@ using Content.Server.Administration.Managers; using Content.Server.Administration.Systems; using Content.Server.MoMMI; +using Content.Server.Players.RateLimiting; using Content.Server.Preferences.Managers; using Content.Shared.Administration; using Content.Shared.CCVar; using Content.Shared.Chat; using Content.Shared.Database; using Content.Shared.Mind; -using Robust.Server.Player; using Robust.Shared.Configuration; using Robust.Shared.Network; using Robust.Shared.Player; using Robust.Shared.Replays; -using Robust.Shared.Timing; using Robust.Shared.Utility; -namespace Content.Server.Chat.Managers +namespace Content.Server.Chat.Managers; + +/// +/// Dispatches chat messages to clients. +/// +internal sealed partial class ChatManager : IChatManager { + private static readonly Dictionary PatronOocColors = new() + { + // I had plans for multiple colors and those went nowhere so... + { "nuclear_operative", "#aa00ff" }, + { "syndicate_agent", "#aa00ff" }, + { "revolutionary", "#aa00ff" } + }; + + [Dependency] private readonly IReplayRecordingManager _replay = default!; + [Dependency] private readonly IServerNetManager _netManager = default!; + [Dependency] private readonly IMoMMILink _mommiLink = default!; + [Dependency] private readonly IAdminManager _adminManager = default!; + [Dependency] private readonly IAdminLogManager _adminLogger = default!; + [Dependency] private readonly IServerPreferencesManager _preferencesManager = default!; + [Dependency] private readonly IConfigurationManager _configurationManager = default!; + [Dependency] private readonly INetConfigurationManager _netConfigManager = default!; + [Dependency] private readonly IEntityManager _entityManager = default!; + [Dependency] private readonly PlayerRateLimitManager _rateLimitManager = default!; + /// - /// Dispatches chat messages to clients. + /// The maximum length a player-sent message can be sent /// - internal sealed partial class ChatManager : IChatManager + public int MaxMessageLength => _configurationManager.GetCVar(CCVars.ChatMaxMessageLength); + + private bool _oocEnabled = true; + private bool _adminOocEnabled = true; + + private readonly Dictionary _players = new(); + + public void Initialize() { - private static readonly Dictionary PatronOocColors = new() - { - // I had plans for multiple colors and those went nowhere so... - { "nuclear_operative", "#aa00ff" }, - { "syndicate_agent", "#aa00ff" }, - { "revolutionary", "#aa00ff" } - }; - - [Dependency] private readonly IReplayRecordingManager _replay = default!; - [Dependency] private readonly IServerNetManager _netManager = default!; - [Dependency] private readonly IMoMMILink _mommiLink = default!; - [Dependency] private readonly IAdminManager _adminManager = default!; - [Dependency] private readonly IAdminLogManager _adminLogger = default!; - [Dependency] private readonly IServerPreferencesManager _preferencesManager = default!; - [Dependency] private readonly IConfigurationManager _configurationManager = default!; - [Dependency] private readonly INetConfigurationManager _netConfigManager = default!; - [Dependency] private readonly IEntityManager _entityManager = default!; - [Dependency] private readonly IGameTiming _gameTiming = default!; - [Dependency] private readonly IPlayerManager _playerManager = default!; - - /// - /// The maximum length a player-sent message can be sent - /// - public int MaxMessageLength => _configurationManager.GetCVar(CCVars.ChatMaxMessageLength); - - private bool _oocEnabled = true; - private bool _adminOocEnabled = true; - - private readonly Dictionary _players = new(); - - public void Initialize() - { - _netManager.RegisterNetMessage(); - _netManager.RegisterNetMessage(); + _netManager.RegisterNetMessage(); + _netManager.RegisterNetMessage(); - _configurationManager.OnValueChanged(CCVars.OocEnabled, OnOocEnabledChanged, true); - _configurationManager.OnValueChanged(CCVars.AdminOocEnabled, OnAdminOocEnabledChanged, true); + _configurationManager.OnValueChanged(CCVars.OocEnabled, OnOocEnabledChanged, true); + _configurationManager.OnValueChanged(CCVars.AdminOocEnabled, OnAdminOocEnabledChanged, true); - _playerManager.PlayerStatusChanged += PlayerStatusChanged; - } + RegisterRateLimits(); + } - private void OnOocEnabledChanged(bool val) - { - if (_oocEnabled == val) return; + private void OnOocEnabledChanged(bool val) + { + if (_oocEnabled == val) return; - _oocEnabled = val; - DispatchServerAnnouncement(Loc.GetString(val ? "chat-manager-ooc-chat-enabled-message" : "chat-manager-ooc-chat-disabled-message")); - } + _oocEnabled = val; + DispatchServerAnnouncement(Loc.GetString(val ? "chat-manager-ooc-chat-enabled-message" : "chat-manager-ooc-chat-disabled-message")); + } - private void OnAdminOocEnabledChanged(bool val) - { - if (_adminOocEnabled == val) return; + private void OnAdminOocEnabledChanged(bool val) + { + if (_adminOocEnabled == val) return; - _adminOocEnabled = val; - DispatchServerAnnouncement(Loc.GetString(val ? "chat-manager-admin-ooc-chat-enabled-message" : "chat-manager-admin-ooc-chat-disabled-message")); - } + _adminOocEnabled = val; + DispatchServerAnnouncement(Loc.GetString(val ? "chat-manager-admin-ooc-chat-enabled-message" : "chat-manager-admin-ooc-chat-disabled-message")); + } - public void DeleteMessagesBy(ICommonSession player) + public void DeleteMessagesBy(NetUserId uid) { - if (!_players.TryGetValue(player.UserId, out var user)) + if (!_players.TryGetValue(uid, out var user)) return; - var msg = new MsgDeleteChatMessagesBy { Key = user.Key, Entities = user.Entities }; - _netManager.ServerSendToAll(msg); - } + var msg = new MsgDeleteChatMessagesBy { Key = user.Key, Entities = user.Entities }; + _netManager.ServerSendToAll(msg); + } - [return: NotNullIfNotNull(nameof(author))] - public ChatUser? EnsurePlayer(NetUserId? author) - { - if (author == null) - return null; + [return: NotNullIfNotNull(nameof(author))] + public ChatUser? EnsurePlayer(NetUserId? author) + { + if (author == null) + return null; - ref var user = ref CollectionsMarshal.GetValueRefOrAddDefault(_players, author.Value, out var exists); - if (!exists || user == null) - user = new ChatUser(_players.Count); + ref var user = ref CollectionsMarshal.GetValueRefOrAddDefault(_players, author.Value, out var exists); + if (!exists || user == null) + user = new ChatUser(_players.Count); - return user; - } + return user; + } - #region Server Announcements + #region Server Announcements - public void DispatchServerAnnouncement(string message, Color? colorOverride = null) - { - var wrappedMessage = Loc.GetString("chat-manager-server-wrap-message", ("message", FormattedMessage.EscapeText(message))); - ChatMessageToAll(ChatChannel.Server, message, wrappedMessage, EntityUid.Invalid, hideChat: false, recordReplay: true, colorOverride: colorOverride); - Logger.InfoS("SERVER", message); + public void DispatchServerAnnouncement(string message, Color? colorOverride = null) + { + var wrappedMessage = Loc.GetString("chat-manager-server-wrap-message", ("message", FormattedMessage.EscapeText(message))); + ChatMessageToAll(ChatChannel.Server, message, wrappedMessage, EntityUid.Invalid, hideChat: false, recordReplay: true, colorOverride: colorOverride); + Logger.InfoS("SERVER", message); - _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Server announcement: {message}"); - } + _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Server announcement: {message}"); + } - public void DispatchServerMessage(ICommonSession player, string message, bool suppressLog = false) - { - var wrappedMessage = Loc.GetString("chat-manager-server-wrap-message", ("message", FormattedMessage.EscapeText(message))); - ChatMessageToOne(ChatChannel.Server, message, wrappedMessage, default, false, player.Channel); + public void DispatchServerMessage(ICommonSession player, string message, bool suppressLog = false) + { + var wrappedMessage = Loc.GetString("chat-manager-server-wrap-message", ("message", FormattedMessage.EscapeText(message))); + ChatMessageToOne(ChatChannel.Server, message, wrappedMessage, default, false, player.Channel); - if (!suppressLog) - _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Server message to {player:Player}: {message}"); - } + if (!suppressLog) + _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Server message to {player:Player}: {message}"); + } - public void SendAdminAnnouncement(string message, AdminFlags? flagBlacklist, AdminFlags? flagWhitelist) + public void SendAdminAnnouncement(string message, AdminFlags? flagBlacklist, AdminFlags? flagWhitelist) + { + var clients = _adminManager.ActiveAdmins.Where(p => { - var clients = _adminManager.ActiveAdmins.Where(p => - { - var adminData = _adminManager.GetAdminData(p); + var adminData = _adminManager.GetAdminData(p); - DebugTools.AssertNotNull(adminData); + DebugTools.AssertNotNull(adminData); - if (adminData == null) - return false; + if (adminData == null) + return false; - if (flagBlacklist != null && adminData.HasFlag(flagBlacklist.Value)) - return false; + if (flagBlacklist != null && adminData.HasFlag(flagBlacklist.Value)) + return false; - return flagWhitelist == null || adminData.HasFlag(flagWhitelist.Value); + return flagWhitelist == null || adminData.HasFlag(flagWhitelist.Value); - }).Select(p => p.Channel); + }).Select(p => p.Channel); - var wrappedMessage = Loc.GetString("chat-manager-send-admin-announcement-wrap-message", - ("adminChannelName", Loc.GetString("chat-manager-admin-channel-name")), ("message", FormattedMessage.EscapeText(message))); + var wrappedMessage = Loc.GetString("chat-manager-send-admin-announcement-wrap-message", + ("adminChannelName", Loc.GetString("chat-manager-admin-channel-name")), ("message", FormattedMessage.EscapeText(message))); - ChatMessageToMany(ChatChannel.Admin, message, wrappedMessage, default, false, true, clients); - _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Admin announcement: {message}"); - } + ChatMessageToMany(ChatChannel.Admin, message, wrappedMessage, default, false, true, clients); + _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Admin announcement: {message}"); + } - public void SendAdminAlert(string message) - { - var clients = _adminManager.ActiveAdmins.Select(p => p.Channel); + public void SendAdminAnnouncementMessage(ICommonSession player, string message, bool suppressLog = true) + { + var wrappedMessage = Loc.GetString("chat-manager-send-admin-announcement-wrap-message", + ("adminChannelName", Loc.GetString("chat-manager-admin-channel-name")), + ("message", FormattedMessage.EscapeText(message))); + ChatMessageToOne(ChatChannel.Admin, message, wrappedMessage, default, false, player.Channel); + } - var wrappedMessage = Loc.GetString("chat-manager-send-admin-announcement-wrap-message", - ("adminChannelName", Loc.GetString("chat-manager-admin-channel-name")), ("message", FormattedMessage.EscapeText(message))); + public void SendAdminAlert(string message) + { + var clients = _adminManager.ActiveAdmins.Select(p => p.Channel); - ChatMessageToMany(ChatChannel.AdminAlert, message, wrappedMessage, default, false, true, clients); - } + var wrappedMessage = Loc.GetString("chat-manager-send-admin-announcement-wrap-message", + ("adminChannelName", Loc.GetString("chat-manager-admin-channel-name")), ("message", FormattedMessage.EscapeText(message))); - public void SendAdminAlert(EntityUid player, string message) + ChatMessageToMany(ChatChannel.AdminAlert, message, wrappedMessage, default, false, true, clients); + } + + public void SendAdminAlert(EntityUid player, string message) + { + var mindSystem = _entityManager.System(); + if (!mindSystem.TryGetMind(player, out var mindId, out var mind)) { - var mindSystem = _entityManager.System(); - if (!mindSystem.TryGetMind(player, out var mindId, out var mind)) - { - SendAdminAlert(message); - return; - } + SendAdminAlert(message); + return; + } - var adminSystem = _entityManager.System(); - var antag = mind.UserId != null && (adminSystem.GetCachedPlayerInfo(mind.UserId.Value)?.Antag ?? false); + var adminSystem = _entityManager.System(); + var antag = mind.UserId != null && (adminSystem.GetCachedPlayerInfo(mind.UserId.Value)?.Antag ?? false); - SendAdminAlert($"{mind.Session?.Name}{(antag ? " (ANTAG)" : "")} {message}"); - } + SendAdminAlert($"{mind.Session?.Name}{(antag ? " (ANTAG)" : "")} {message}"); + } - public void SendHookOOC(string sender, string message) + public void SendHookOOC(string sender, string message) + { + if (!_oocEnabled && _configurationManager.GetCVar(CCVars.DisablingOOCDisablesRelay)) { - if (!_oocEnabled && _configurationManager.GetCVar(CCVars.DisablingOOCDisablesRelay)) - { - return; - } - var wrappedMessage = Loc.GetString("chat-manager-send-hook-ooc-wrap-message", ("senderName", sender), ("message", FormattedMessage.EscapeText(message))); - ChatMessageToAll(ChatChannel.OOC, message, wrappedMessage, source: EntityUid.Invalid, hideChat: false, recordReplay: true); - _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Hook OOC from {sender}: {message}"); + return; } + var wrappedMessage = Loc.GetString("chat-manager-send-hook-ooc-wrap-message", ("senderName", sender), ("message", FormattedMessage.EscapeText(message))); + ChatMessageToAll(ChatChannel.OOC, message, wrappedMessage, source: EntityUid.Invalid, hideChat: false, recordReplay: true); + _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Hook OOC from {sender}: {message}"); + } - #endregion + #endregion - #region Public OOC Chat API + #region Public OOC Chat API - /// - /// Called for a player to attempt sending an OOC, out-of-game. message. - /// - /// The player sending the message. - /// The message. - /// The type of message. - public void TrySendOOCMessage(ICommonSession player, string message, OOCChatType type) - { - if (!HandleRateLimit(player)) - return; + /// + /// Called for a player to attempt sending an OOC, out-of-game. message. + /// + /// The player sending the message. + /// The message. + /// The type of message. + public void TrySendOOCMessage(ICommonSession player, string message, OOCChatType type) + { + if (HandleRateLimit(player) != RateLimitStatus.Allowed) + return; - // Check if message exceeds the character limit - if (message.Length > MaxMessageLength) - { - DispatchServerMessage(player, Loc.GetString("chat-manager-max-message-length-exceeded-message", ("limit", MaxMessageLength))); - return; - } + // Check if message exceeds the character limit + if (message.Length > MaxMessageLength) + { + DispatchServerMessage(player, Loc.GetString("chat-manager-max-message-length-exceeded-message", ("limit", MaxMessageLength))); + return; + } - switch (type) - { - case OOCChatType.OOC: - SendOOC(player, message); - break; - case OOCChatType.Admin: - SendAdminChat(player, message); - break; - } + switch (type) + { + case OOCChatType.OOC: + SendOOC(player, message); + break; + case OOCChatType.Admin: + SendAdminChat(player, message); + break; } + } - #endregion + #endregion - #region Private API + #region Private API - private void SendOOC(ICommonSession player, string message) + private void SendOOC(ICommonSession player, string message) + { + if (_adminManager.IsAdmin(player)) { - if (_adminManager.IsAdmin(player)) - { - if (!_adminOocEnabled) - { - return; - } - } - else if (!_oocEnabled) + if (!_adminOocEnabled) { return; } - - Color? colorOverride = null; - var wrappedMessage = Loc.GetString("chat-manager-send-ooc-wrap-message", ("playerName",player.Name), ("message", FormattedMessage.EscapeText(message))); - if (_adminManager.HasAdminFlag(player, AdminFlags.Admin)) - { - var prefs = _preferencesManager.GetPreferences(player.UserId); - colorOverride = prefs.AdminOOCColor; - } - if ( _netConfigManager.GetClientCVar(player.Channel, CCVars.ShowOocPatronColor) && player.Channel.UserData.PatronTier is { } patron && PatronOocColors.TryGetValue(patron, out var patronColor)) - { - wrappedMessage = Loc.GetString("chat-manager-send-ooc-patron-wrap-message", ("patronColor", patronColor),("playerName", player.Name), ("message", FormattedMessage.EscapeText(message))); - } - - //TODO: player.Name color, this will need to change the structure of the MsgChatMessage - ChatMessageToAll(ChatChannel.OOC, message, wrappedMessage, EntityUid.Invalid, hideChat: false, recordReplay: true, colorOverride: colorOverride, author: player.UserId); - _mommiLink.SendOOCMessage(player.Name, message); - _adminLogger.Add(LogType.Chat, LogImpact.Low, $"OOC from {player:Player}: {message}"); } - - private void SendAdminChat(ICommonSession player, string message) + else if (!_oocEnabled) { - if (!_adminManager.IsAdmin(player)) - { - _adminLogger.Add(LogType.Chat, LogImpact.Extreme, $"{player:Player} attempted to send admin message but was not admin"); - return; - } + return; + } - var clients = _adminManager.ActiveAdmins.Select(p => p.Channel); - var wrappedMessage = Loc.GetString("chat-manager-send-admin-chat-wrap-message", - ("adminChannelName", Loc.GetString("chat-manager-admin-channel-name")), - ("playerName", player.Name), ("message", FormattedMessage.EscapeText(message))); + Color? colorOverride = null; + var wrappedMessage = Loc.GetString("chat-manager-send-ooc-wrap-message", ("playerName",player.Name), ("message", FormattedMessage.EscapeText(message))); + if (_adminManager.HasAdminFlag(player, AdminFlags.Admin)) + { + var prefs = _preferencesManager.GetPreferences(player.UserId); + colorOverride = prefs.AdminOOCColor; + } + if ( _netConfigManager.GetClientCVar(player.Channel, CCVars.ShowOocPatronColor) && player.Channel.UserData.PatronTier is { } patron && PatronOocColors.TryGetValue(patron, out var patronColor)) + { + wrappedMessage = Loc.GetString("chat-manager-send-ooc-patron-wrap-message", ("patronColor", patronColor),("playerName", player.Name), ("message", FormattedMessage.EscapeText(message))); + } - foreach (var client in clients) - { - var isSource = client != player.Channel; - ChatMessageToOne(ChatChannel.AdminChat, - message, - wrappedMessage, - default, - false, - client, - audioPath: isSource ? _netConfigManager.GetClientCVar(client, CCVars.AdminChatSoundPath) : default, - audioVolume: isSource ? _netConfigManager.GetClientCVar(client, CCVars.AdminChatSoundVolume) : default, - author: player.UserId); - } + //TODO: player.Name color, this will need to change the structure of the MsgChatMessage + ChatMessageToAll(ChatChannel.OOC, message, wrappedMessage, EntityUid.Invalid, hideChat: false, recordReplay: true, colorOverride: colorOverride, author: player.UserId); + _mommiLink.SendOOCMessage(player.Name, message.Replace("@", "\\@").Replace("<", "\\<").Replace("/", "\\/")); // @ and < are both problematic for discord due to pinging. / is sanitized solely to kneecap links to murder embeds via blunt force + _adminLogger.Add(LogType.Chat, LogImpact.Low, $"OOC from {player:Player}: {message}"); + } - _adminLogger.Add(LogType.Chat, $"Admin chat from {player:Player}: {message}"); + private void SendAdminChat(ICommonSession player, string message) + { + if (!_adminManager.IsAdmin(player)) + { + _adminLogger.Add(LogType.Chat, LogImpact.Extreme, $"{player:Player} attempted to send admin message but was not admin"); + return; } - #endregion + var clients = _adminManager.ActiveAdmins.Select(p => p.Channel); + var wrappedMessage = Loc.GetString("chat-manager-send-admin-chat-wrap-message", + ("adminChannelName", Loc.GetString("chat-manager-admin-channel-name")), + ("playerName", player.Name), ("message", FormattedMessage.EscapeText(message))); - #region Utility - - public void ChatMessageToOne(ChatChannel channel, string message, string wrappedMessage, EntityUid source, bool hideChat, INetChannel client, Color? colorOverride = null, bool recordReplay = false, string? audioPath = null, float audioVolume = 0, NetUserId? author = null) + foreach (var client in clients) { - var user = author == null ? null : EnsurePlayer(author); - var netSource = _entityManager.GetNetEntity(source); - user?.AddEntity(netSource); + var isSource = client != player.Channel; + ChatMessageToOne(ChatChannel.AdminChat, + message, + wrappedMessage, + default, + false, + client, + audioPath: isSource ? _netConfigManager.GetClientCVar(client, CCVars.AdminChatSoundPath) : default, + audioVolume: isSource ? _netConfigManager.GetClientCVar(client, CCVars.AdminChatSoundVolume) : default, + author: player.UserId); + } - var msg = new ChatMessage(channel, message, wrappedMessage, netSource, user?.Key, hideChat, colorOverride, audioPath, audioVolume); - _netManager.ServerSendMessage(new MsgChatMessage() { Message = msg }, client); + _adminLogger.Add(LogType.Chat, $"Admin chat from {player:Player}: {message}"); + } - if (!recordReplay) - return; + #endregion - if ((channel & ChatChannel.AdminRelated) == 0 || - _configurationManager.GetCVar(CCVars.ReplayRecordAdminChat)) - { - _replay.RecordServerMessage(msg); - } - } + #region Utility + + public void ChatMessageToOne(ChatChannel channel, string message, string wrappedMessage, EntityUid source, bool hideChat, INetChannel client, Color? colorOverride = null, bool recordReplay = false, string? audioPath = null, float audioVolume = 0, NetUserId? author = null) + { + var user = author == null ? null : EnsurePlayer(author); + var netSource = _entityManager.GetNetEntity(source); + user?.AddEntity(netSource); + + var msg = new ChatMessage(channel, message, wrappedMessage, netSource, user?.Key, hideChat, colorOverride, audioPath, audioVolume); + _netManager.ServerSendMessage(new MsgChatMessage() { Message = msg }, client); - public void ChatMessageToMany(ChatChannel channel, string message, string wrappedMessage, EntityUid source, bool hideChat, bool recordReplay, IEnumerable clients, Color? colorOverride = null, string? audioPath = null, float audioVolume = 0, NetUserId? author = null) - => ChatMessageToMany(channel, message, wrappedMessage, source, hideChat, recordReplay, clients.ToList(), colorOverride, audioPath, audioVolume, author); + if (!recordReplay) + return; - public void ChatMessageToMany(ChatChannel channel, string message, string wrappedMessage, EntityUid source, bool hideChat, bool recordReplay, List clients, Color? colorOverride = null, string? audioPath = null, float audioVolume = 0, NetUserId? author = null) + if ((channel & ChatChannel.AdminRelated) == 0 || + _configurationManager.GetCVar(CCVars.ReplayRecordAdminChat)) { - var user = author == null ? null : EnsurePlayer(author); - var netSource = _entityManager.GetNetEntity(source); - user?.AddEntity(netSource); + _replay.RecordServerMessage(msg); + } + } - var msg = new ChatMessage(channel, message, wrappedMessage, netSource, user?.Key, hideChat, colorOverride, audioPath, audioVolume); - _netManager.ServerSendToMany(new MsgChatMessage() { Message = msg }, clients); + public void ChatMessageToMany(ChatChannel channel, string message, string wrappedMessage, EntityUid source, bool hideChat, bool recordReplay, IEnumerable clients, Color? colorOverride = null, string? audioPath = null, float audioVolume = 0, NetUserId? author = null) + => ChatMessageToMany(channel, message, wrappedMessage, source, hideChat, recordReplay, clients.ToList(), colorOverride, audioPath, audioVolume, author); - if (!recordReplay) - return; + public void ChatMessageToMany(ChatChannel channel, string message, string wrappedMessage, EntityUid source, bool hideChat, bool recordReplay, List clients, Color? colorOverride = null, string? audioPath = null, float audioVolume = 0, NetUserId? author = null) + { + var user = author == null ? null : EnsurePlayer(author); + var netSource = _entityManager.GetNetEntity(source); + user?.AddEntity(netSource); - if ((channel & ChatChannel.AdminRelated) == 0 || - _configurationManager.GetCVar(CCVars.ReplayRecordAdminChat)) - { - _replay.RecordServerMessage(msg); - } - } + var msg = new ChatMessage(channel, message, wrappedMessage, netSource, user?.Key, hideChat, colorOverride, audioPath, audioVolume); + _netManager.ServerSendToMany(new MsgChatMessage() { Message = msg }, clients); - public void ChatMessageToManyFiltered(Filter filter, ChatChannel channel, string message, string wrappedMessage, EntityUid source, - bool hideChat, bool recordReplay, Color? colorOverride = null, string? audioPath = null, float audioVolume = 0) + if (!recordReplay) + return; + + if ((channel & ChatChannel.AdminRelated) == 0 || + _configurationManager.GetCVar(CCVars.ReplayRecordAdminChat)) { - if (!recordReplay && !filter.Recipients.Any()) - return; + _replay.RecordServerMessage(msg); + } + } - var clients = new List(); - foreach (var recipient in filter.Recipients) - { - clients.Add(recipient.Channel); - } + public void ChatMessageToManyFiltered(Filter filter, ChatChannel channel, string message, string wrappedMessage, EntityUid source, + bool hideChat, bool recordReplay, Color? colorOverride = null, string? audioPath = null, float audioVolume = 0) + { + if (!recordReplay && !filter.Recipients.Any()) + return; - ChatMessageToMany(channel, message, wrappedMessage, source, hideChat, recordReplay, clients, colorOverride, audioPath, audioVolume); + var clients = new List(); + foreach (var recipient in filter.Recipients) + { + clients.Add(recipient.Channel); } - public void ChatMessageToAll(ChatChannel channel, string message, string wrappedMessage, EntityUid source, bool hideChat, bool recordReplay, Color? colorOverride = null, string? audioPath = null, float audioVolume = 0, NetUserId? author = null) - { - var user = author == null ? null : EnsurePlayer(author); - var netSource = _entityManager.GetNetEntity(source); - user?.AddEntity(netSource); + ChatMessageToMany(channel, message, wrappedMessage, source, hideChat, recordReplay, clients, colorOverride, audioPath, audioVolume); + } - var msg = new ChatMessage(channel, message, wrappedMessage, netSource, user?.Key, hideChat, colorOverride, audioPath, audioVolume); - _netManager.ServerSendToAll(new MsgChatMessage() { Message = msg }); + public void ChatMessageToAll(ChatChannel channel, string message, string wrappedMessage, EntityUid source, bool hideChat, bool recordReplay, Color? colorOverride = null, string? audioPath = null, float audioVolume = 0, NetUserId? author = null) + { + var user = author == null ? null : EnsurePlayer(author); + var netSource = _entityManager.GetNetEntity(source); + user?.AddEntity(netSource); - if (!recordReplay) - return; + var msg = new ChatMessage(channel, message, wrappedMessage, netSource, user?.Key, hideChat, colorOverride, audioPath, audioVolume); + _netManager.ServerSendToAll(new MsgChatMessage() { Message = msg }); - if ((channel & ChatChannel.AdminRelated) == 0 || - _configurationManager.GetCVar(CCVars.ReplayRecordAdminChat)) - { - _replay.RecordServerMessage(msg); - } - } + if (!recordReplay) + return; - public bool MessageCharacterLimit(ICommonSession? player, string message) + if ((channel & ChatChannel.AdminRelated) == 0 || + _configurationManager.GetCVar(CCVars.ReplayRecordAdminChat)) { - var isOverLength = false; + _replay.RecordServerMessage(msg); + } + } - // Non-players don't need to be checked. - if (player == null) - return false; + public bool MessageCharacterLimit(ICommonSession? player, string message) + { + var isOverLength = false; - // Check if message exceeds the character limit if the sender is a player - if (message.Length > MaxMessageLength) - { - var feedback = Loc.GetString("chat-manager-max-message-length-exceeded-message", ("limit", MaxMessageLength)); + // Non-players don't need to be checked. + if (player == null) + return false; - DispatchServerMessage(player, feedback); + // Check if message exceeds the character limit if the sender is a player + if (message.Length > MaxMessageLength) + { + var feedback = Loc.GetString("chat-manager-max-message-length-exceeded-message", ("limit", MaxMessageLength)); - isOverLength = true; - } + DispatchServerMessage(player, feedback); - return isOverLength; + isOverLength = true; } - #endregion + return isOverLength; } - public enum OOCChatType : byte - { - OOC, - Admin - } + #endregion +} + +public enum OOCChatType : byte +{ + OOC, + Admin } diff --git a/Content.Server/Chat/Managers/IChatManager.cs b/Content.Server/Chat/Managers/IChatManager.cs index 59945bf5ca..d14bccc637 100644 --- a/Content.Server/Chat/Managers/IChatManager.cs +++ b/Content.Server/Chat/Managers/IChatManager.cs @@ -1,15 +1,16 @@ using System.Diagnostics.CodeAnalysis; +using Content.Server.Players; +using Content.Server.Players.RateLimiting; using Content.Shared.Administration; using Content.Shared.Chat; +using Content.Shared.Players.RateLimiting; using Robust.Shared.Network; using Robust.Shared.Player; namespace Content.Server.Chat.Managers { - public interface IChatManager + public interface IChatManager : ISharedChatManager { - void Initialize(); - /// /// Dispatch a server announcement to every connected player. /// @@ -23,8 +24,7 @@ public interface IChatManager void SendHookOOC(string sender, string message); void SendAdminAnnouncement(string message, AdminFlags? flagBlacklist = null, AdminFlags? flagWhitelist = null); - void SendAdminAlert(string message); - void SendAdminAlert(EntityUid player, string message); + void SendAdminAnnouncementMessage(ICommonSession player, string message, bool suppressLog = true); void ChatMessageToOne(ChatChannel channel, string message, string wrappedMessage, EntityUid source, bool hideChat, INetChannel client, Color? colorOverride = null, bool recordReplay = false, string? audioPath = null, float audioVolume = 0, NetUserId? author = null); @@ -38,7 +38,7 @@ void ChatMessageToMany(ChatChannel channel, string message, string wrappedMessag bool MessageCharacterLimit(ICommonSession player, string message); - void DeleteMessagesBy(ICommonSession player); + void DeleteMessagesBy(NetUserId uid); [return: NotNullIfNotNull(nameof(author))] ChatUser? EnsurePlayer(NetUserId? author); @@ -49,6 +49,6 @@ void ChatMessageToMany(ChatChannel channel, string message, string wrappedMessag /// /// The player sending a chat message. /// False if the player has violated rate limits and should be blocked from sending further messages. - bool HandleRateLimit(ICommonSession player); + RateLimitStatus HandleRateLimit(ICommonSession player); } } diff --git a/Content.Server/Chat/Systems/ChatSystem.cs b/Content.Server/Chat/Systems/ChatSystem.cs index 5ba5845f08..90bbb781e2 100644 --- a/Content.Server/Chat/Systems/ChatSystem.cs +++ b/Content.Server/Chat/Systems/ChatSystem.cs @@ -21,6 +21,7 @@ using Content.Shared.Language.Systems; using Content.Shared.Mobs.Systems; using Content.Shared.Players; +using Content.Shared.Players.RateLimiting; using Content.Shared.Radio; using Content.Shared.Speech; using Robust.Server.Player; @@ -192,7 +193,7 @@ public void TrySendInGameICMessage( return; } - if (player != null && !_chatManager.HandleRateLimit(player)) + if (player != null && _chatManager.HandleRateLimit(player) != RateLimitStatus.Allowed) return; // Sus @@ -291,7 +292,7 @@ public void TrySendInGameOOCMessage( if (!CanSendInGame(message, shell, player)) return; - if (player != null && !_chatManager.HandleRateLimit(player)) + if (player != null && _chatManager.HandleRateLimit(player) != RateLimitStatus.Allowed) return; // It doesn't make any sense for a non-player to send in-game OOC messages, whereas non-players may be sending diff --git a/Content.Server/Entry/EntryPoint.cs b/Content.Server/Entry/EntryPoint.cs index 42c663e8f3..48e6e5996d 100644 --- a/Content.Server/Entry/EntryPoint.cs +++ b/Content.Server/Entry/EntryPoint.cs @@ -17,7 +17,13 @@ using Content.Server.Players.JobWhitelist; using Content.Server.Maps; using Content.Server.NodeContainer.NodeGroups; +<<<<<<< HEAD +======= +using Content.Server.Players; +using Content.Server.Players.JobWhitelist; +>>>>>>> c33644532d (Rate limit ahelps (#29219)) using Content.Server.Players.PlayTimeTracking; +using Content.Server.Players.RateLimiting; using Content.Server.Preferences.Managers; using Content.Server.ServerInfo; using Content.Server.ServerUpdates; @@ -114,6 +120,7 @@ public override void Init() _updateManager.Initialize(); _playTimeTracking.Initialize(); IoCManager.Resolve().Initialize(); + IoCManager.Resolve().Initialize(); } } diff --git a/Content.Server/GameTicking/Commands/RespawnCommand.cs b/Content.Server/GameTicking/Commands/RespawnCommand.cs index 4f101d0939..f7ea11baf1 100644 --- a/Content.Server/GameTicking/Commands/RespawnCommand.cs +++ b/Content.Server/GameTicking/Commands/RespawnCommand.cs @@ -1,4 +1,6 @@ -using Content.Shared.Mind; +using System.Linq; +using Content.Server.Administration; +using Content.Server.Mind; using Content.Shared.Players; using Robust.Server.Player; using Robust.Shared.Console; @@ -6,57 +8,72 @@ namespace Content.Server.GameTicking.Commands { - sealed class RespawnCommand : IConsoleCommand + sealed class RespawnCommand : LocalizedEntityCommands { - public string Command => "respawn"; - public string Description => "Respawns a player, kicking them back to the lobby."; - public string Help => "respawn [player]"; + [Dependency] private readonly IPlayerManager _player = default!; + [Dependency] private readonly IPlayerLocator _locator = default!; + [Dependency] private readonly GameTicker _gameTicker = default!; + [Dependency] private readonly MindSystem _mind = default!; - public void Execute(IConsoleShell shell, string argStr, string[] args) + public override string Command => "respawn"; + + public override async void Execute(IConsoleShell shell, string argStr, string[] args) { var player = shell.Player; if (args.Length > 1) { - shell.WriteLine("Must provide <= 1 argument."); + shell.WriteError(Loc.GetString("cmd-respawn-invalid-args")); return; } - var playerMgr = IoCManager.Resolve(); - var sysMan = IoCManager.Resolve(); - var ticker = sysMan.GetEntitySystem(); - var mind = sysMan.GetEntitySystem(); - NetUserId userId; if (args.Length == 0) { if (player == null) { - shell.WriteLine("If not a player, an argument must be given."); + shell.WriteError(Loc.GetString("cmd-respawn-no-player")); return; } userId = player.UserId; } - else if (!playerMgr.TryGetUserId(args[0], out userId)) + else { - shell.WriteLine("Unknown player"); - return; + var located = await _locator.LookupIdByNameOrIdAsync(args[0]); + + if (located == null) + { + shell.WriteError(Loc.GetString("cmd-respawn-unknown-player")); + return; + } + + userId = located.UserId; } - if (!playerMgr.TryGetSessionById(userId, out var targetPlayer)) + if (!_player.TryGetSessionById(userId, out var targetPlayer)) { - if (!playerMgr.TryGetPlayerData(userId, out var data)) + if (!_player.TryGetPlayerData(userId, out var data)) { - shell.WriteLine("Unknown player"); + shell.WriteError(Loc.GetString("cmd-respawn-unknown-player")); return; } - mind.WipeMind(data.ContentData()?.Mind); - shell.WriteLine("Player is not currently online, but they will respawn if they come back online"); + _mind.WipeMind(data.ContentData()?.Mind); + shell.WriteError(Loc.GetString("cmd-respawn-player-not-online")); return; } - ticker.Respawn(targetPlayer); + _gameTicker.Respawn(targetPlayer); + } + + public override CompletionResult GetCompletion(IConsoleShell shell, string[] args) + { + if (args.Length != 1) + return CompletionResult.Empty; + + var options = _player.Sessions.OrderBy(c => c.Name).Select(c => c.Name).ToArray(); + + return CompletionResult.FromHintOptions(options, Loc.GetString("cmd-respawn-player-completion")); } } } diff --git a/Content.Server/IoC/ServerContentIoC.cs b/Content.Server/IoC/ServerContentIoC.cs index d0f70c04cd..11cd4ff7b6 100644 --- a/Content.Server/IoC/ServerContentIoC.cs +++ b/Content.Server/IoC/ServerContentIoC.cs @@ -1,4 +1,3 @@ -using Content.Server._White.TTS; using Content.Server.Administration; using Content.Server.Administration.Logs; using Content.Server.Administration.Managers; @@ -16,7 +15,13 @@ using Content.Server.Players.JobWhitelist; using Content.Server.MoMMI; using Content.Server.NodeContainer.NodeGroups; +<<<<<<< HEAD +======= +using Content.Server.Players; +using Content.Server.Players.JobWhitelist; +>>>>>>> c33644532d (Rate limit ahelps (#29219)) using Content.Server.Players.PlayTimeTracking; +using Content.Server.Players.RateLimiting; using Content.Server.Preferences.Managers; using Content.Server.ServerInfo; using Content.Server.ServerUpdates; @@ -24,8 +29,10 @@ using Content.Server.Worldgen.Tools; using Content.Shared.Administration.Logs; using Content.Shared.Administration.Managers; +using Content.Shared.Chat; using Content.Shared.Kitchen; using Content.Shared.Players.PlayTimeTracking; +using Content.Shared.Players.RateLimiting; namespace Content.Server.IoC { @@ -34,6 +41,7 @@ internal static class ServerContentIoC public static void Register() { IoCManager.Register(); + IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); @@ -66,7 +74,12 @@ public static void Register() IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); - IoCManager.Register(); // WD EDIT + IoCManager.Register(); + IoCManager.Register(); +<<<<<<< HEAD + IoCManager.Register(); +======= +>>>>>>> c33644532d (Rate limit ahelps (#29219)) } } } diff --git a/Content.Server/Players/RateLimiting/PlayerRateLimitManager.cs b/Content.Server/Players/RateLimiting/PlayerRateLimitManager.cs new file mode 100644 index 0000000000..e61e3dee77 --- /dev/null +++ b/Content.Server/Players/RateLimiting/PlayerRateLimitManager.cs @@ -0,0 +1,290 @@ +using System.Runtime.InteropServices; +using Content.Server.Administration.Logs; +using Content.Shared.Database; +<<<<<<< HEAD +using Content.Shared.Players.RateLimiting; +======= +>>>>>>> c33644532d (Rate limit ahelps (#29219)) +using Robust.Server.Player; +using Robust.Shared.Configuration; +using Robust.Shared.Enums; +using Robust.Shared.Player; +using Robust.Shared.Timing; +using Robust.Shared.Utility; + +namespace Content.Server.Players.RateLimiting; + +<<<<<<< HEAD +public sealed class PlayerRateLimitManager : SharedPlayerRateLimitManager +======= +/// +/// General-purpose system to rate limit actions taken by clients, such as chat messages. +/// +/// +/// +/// Different categories of rate limits must be registered ahead of time by calling . +/// Once registered, you can simply call to count a rate-limited action for a player. +/// +/// +/// This system is intended for rate limiting player actions over short periods, +/// to ward against spam that can cause technical issues such as admin client load. +/// It should not be used for in-game actions or similar. +/// +/// +/// Rate limits are reset when a client reconnects. +/// This should not be an issue for the reasonably short rate limit periods this system is intended for. +/// +/// +/// +public sealed class PlayerRateLimitManager +>>>>>>> c33644532d (Rate limit ahelps (#29219)) +{ + [Dependency] private readonly IAdminLogManager _adminLog = default!; + [Dependency] private readonly IGameTiming _gameTiming = default!; + [Dependency] private readonly IConfigurationManager _cfg = default!; + [Dependency] private readonly IPlayerManager _playerManager = default!; + + private readonly Dictionary _registrations = new(); + private readonly Dictionary> _rateLimitData = new(); + +<<<<<<< HEAD + public override RateLimitStatus CountAction(ICommonSession player, string key) +======= + /// + /// Count and validate an action performed by a player against rate limits. + /// + /// The player performing the action. + /// The key string that was previously used to register a rate limit category. + /// Whether the action counted should be blocked due to surpassing rate limits or not. + /// + /// is not a connected player + /// OR is not a registered rate limit category. + /// + /// + public RateLimitStatus CountAction(ICommonSession player, string key) +>>>>>>> c33644532d (Rate limit ahelps (#29219)) + { + if (player.Status == SessionStatus.Disconnected) + throw new ArgumentException("Player is not connected"); + if (!_registrations.TryGetValue(key, out var registration)) + throw new ArgumentException($"Unregistered key: {key}"); + + var playerData = _rateLimitData.GetOrNew(player); + ref var datum = ref CollectionsMarshal.GetValueRefOrAddDefault(playerData, key, out _); + var time = _gameTiming.RealTime; + if (datum.CountExpires < time) + { + // Period expired, reset it. + datum.CountExpires = time + registration.LimitPeriod; + datum.Count = 0; + datum.Announced = false; + } + + datum.Count += 1; + + if (datum.Count <= registration.LimitCount) + return RateLimitStatus.Allowed; + + // Breached rate limits, inform admins if configured. +<<<<<<< HEAD + // Negative delays can be used to disable admin announcements. + if (registration.AdminAnnounceDelay is {TotalSeconds: >= 0} cvarAnnounceDelay) +======= + if (registration.AdminAnnounceDelay is { } cvarAnnounceDelay) +>>>>>>> c33644532d (Rate limit ahelps (#29219)) + { + if (datum.NextAdminAnnounce < time) + { + registration.Registration.AdminAnnounceAction!(player); + datum.NextAdminAnnounce = time + cvarAnnounceDelay; + } + } + + if (!datum.Announced) + { +<<<<<<< HEAD + registration.Registration.PlayerLimitedAction?.Invoke(player); +======= + registration.Registration.PlayerLimitedAction(player); +>>>>>>> c33644532d (Rate limit ahelps (#29219)) + _adminLog.Add( + registration.Registration.AdminLogType, + LogImpact.Medium, + $"Player {player} breached '{key}' rate limit "); + + datum.Announced = true; + } + + return RateLimitStatus.Blocked; + } + +<<<<<<< HEAD + public override void Register(string key, RateLimitRegistration registration) +======= + /// + /// Register a new rate limit category. + /// + /// + /// The key string that will be referred to later with . + /// Must be unique and should probably just be a constant somewhere. + /// + /// The data specifying the rate limit's parameters. + /// has already been registered. + /// is invalid. + public void Register(string key, RateLimitRegistration registration) +>>>>>>> c33644532d (Rate limit ahelps (#29219)) + { + if (_registrations.ContainsKey(key)) + throw new InvalidOperationException($"Key already registered: {key}"); + + var data = new RegistrationData + { + Registration = registration, + }; + + if ((registration.AdminAnnounceAction == null) != (registration.CVarAdminAnnounceDelay == null)) + { + throw new ArgumentException( + $"Must set either both {nameof(registration.AdminAnnounceAction)} and {nameof(registration.CVarAdminAnnounceDelay)} or neither"); + } + + _cfg.OnValueChanged( + registration.CVarLimitCount, + i => data.LimitCount = i, + invokeImmediately: true); + _cfg.OnValueChanged( + registration.CVarLimitPeriodLength, + i => data.LimitPeriod = TimeSpan.FromSeconds(i), + invokeImmediately: true); + + if (registration.CVarAdminAnnounceDelay != null) + { + _cfg.OnValueChanged( +<<<<<<< HEAD + registration.CVarAdminAnnounceDelay, +======= + registration.CVarLimitCount, +>>>>>>> c33644532d (Rate limit ahelps (#29219)) + i => data.AdminAnnounceDelay = TimeSpan.FromSeconds(i), + invokeImmediately: true); + } + + _registrations.Add(key, data); + } + +<<<<<<< HEAD + public override void Initialize() +======= + /// + /// Initialize the manager's functionality at game startup. + /// + public void Initialize() +>>>>>>> c33644532d (Rate limit ahelps (#29219)) + { + _playerManager.PlayerStatusChanged += PlayerManagerOnPlayerStatusChanged; + } + + private void PlayerManagerOnPlayerStatusChanged(object? sender, SessionStatusEventArgs e) + { + if (e.NewStatus == SessionStatus.Disconnected) + _rateLimitData.Remove(e.Session); + } + + private sealed class RegistrationData + { + public required RateLimitRegistration Registration { get; init; } + public TimeSpan LimitPeriod { get; set; } + public int LimitCount { get; set; } + public TimeSpan? AdminAnnounceDelay { get; set; } + } + + private struct RateLimitDatum + { + /// + /// Time stamp (relative to ) this rate limit period will expire at. + /// + public TimeSpan CountExpires; + + /// + /// How many actions have been done in the current rate limit period. + /// + public int Count; + + /// + /// Have we announced to the player that they've been blocked in this rate limit period? + /// + public bool Announced; + + /// + /// Time stamp (relative to ) of the + /// next time we can send an announcement to admins about rate limit breach. + /// + public TimeSpan NextAdminAnnounce; + } +} +<<<<<<< HEAD +======= + +/// +/// Contains all data necessary to register a rate limit with . +/// +public sealed class RateLimitRegistration +{ + /// + /// CVar that controls the period over which the rate limit is counted, measured in seconds. + /// + public required CVarDef CVarLimitPeriodLength { get; init; } + + /// + /// CVar that controls how many actions are allowed in a single rate limit period. + /// + public required CVarDef CVarLimitCount { get; init; } + + /// + /// An action that gets invoked when this rate limit has been breached by a player. + /// + /// + /// This can be used for informing players or taking administrative action. + /// + public required Action PlayerLimitedAction { get; init; } + + /// + /// CVar that controls the minimum delay between admin notifications, measured in seconds. + /// This can be omitted to have no admin notification system. + /// + /// + /// If set, must be set too. + /// + public CVarDef? CVarAdminAnnounceDelay { get; init; } + + /// + /// An action that gets invoked when a rate limit was breached and admins should be notified. + /// + /// + /// If set, must be set too. + /// + public Action? AdminAnnounceAction { get; init; } + + /// + /// Log type used to log rate limit violations to the admin logs system. + /// + public LogType AdminLogType { get; init; } = LogType.RateLimited; +} + +/// +/// Result of a rate-limited operation. +/// +/// +public enum RateLimitStatus : byte +{ + /// + /// The action was not blocked by the rate limit. + /// + Allowed, + + /// + /// The action was blocked by the rate limit. + /// + Blocked, +} +>>>>>>> c33644532d (Rate limit ahelps (#29219)) diff --git a/Content.Server/VoiceMask/VoiceMaskComponent.cs b/Content.Server/VoiceMask/VoiceMaskComponent.cs index d3116f94db..73a1a6658d 100644 --- a/Content.Server/VoiceMask/VoiceMaskComponent.cs +++ b/Content.Server/VoiceMask/VoiceMaskComponent.cs @@ -1,3 +1,4 @@ +using Content.Shared.Humanoid; using Content.Shared.Speech; using Robust.Shared.Prototypes; @@ -32,6 +33,12 @@ public sealed partial class VoiceMaskComponent : Component [DataField] public EntProtoId Action = "ActionChangeVoiceMask"; + // WD EDIT START + [DataField] + [ViewVariables(VVAccess.ReadWrite)] + public string VoiceId = SharedHumanoidAppearanceSystem.DefaultVoice; + // WD EDIT END + /// /// Reference to the action. /// diff --git a/Content.Server/VoiceMask/VoiceMaskSystem.cs b/Content.Server/VoiceMask/VoiceMaskSystem.cs index 98f6b18f53..c56bf1ed84 100644 --- a/Content.Server/VoiceMask/VoiceMaskSystem.cs +++ b/Content.Server/VoiceMask/VoiceMaskSystem.cs @@ -30,6 +30,8 @@ public override void Initialize() SubscribeLocalEvent(OnChangeVerb); SubscribeLocalEvent(OnEquip); SubscribeLocalEvent(OpenUI); + + InitializeTTS(); // WD EDIT } private void OnTransformSpeakerName(Entity entity, ref InventoryRelayedEvent args) diff --git a/Content.Server/_White/Chat/Systems/AnnouncementSpokeEvent.cs b/Content.Server/_White/Chat/Systems/AnnouncementSpokeEvent.cs new file mode 100644 index 0000000000..445f9b1a9f --- /dev/null +++ b/Content.Server/_White/Chat/Systems/AnnouncementSpokeEvent.cs @@ -0,0 +1,33 @@ +using Robust.Shared.Audio; +using Robust.Shared.Player; + +namespace Content.Server._White.Chat.Systems; + +public sealed class AnnouncementSpokeEvent : EntityEventArgs +{ + public readonly Filter Source; + public readonly string AnnouncementSound; + public readonly AudioParams AnnouncementSoundParams; + public readonly string Message; + + public AnnouncementSpokeEvent(Filter source, string announcementSound, AudioParams announcementSoundParams, string message) + { + Source = source; + Message = message; + AnnouncementSound = announcementSound; + AnnouncementSoundParams = announcementSoundParams; + } +} + +public sealed class RadioSpokeEvent : EntityEventArgs +{ + public readonly EntityUid Source; + public readonly string Message; + + + public RadioSpokeEvent(EntityUid source, string message) + { + Source = source; + Message = message; + } +} diff --git a/Content.Server/_White/TTS/TTSManager.cs b/Content.Server/_White/TTS/TTSManager.cs index e108f236e1..de77fe890f 100644 --- a/Content.Server/_White/TTS/TTSManager.cs +++ b/Content.Server/_White/TTS/TTSManager.cs @@ -1,11 +1,12 @@ -using System.Linq; +using System.Linq; +using System.Net; using System.Net.Http; using System.Net.Http.Json; +using System.Runtime.CompilerServices; using System.Text; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; -using System.Web; using Content.Shared._White; using Prometheus; using Robust.Shared.Configuration; @@ -15,33 +16,47 @@ namespace Content.Server._White.TTS; // ReSharper disable once InconsistentNaming public sealed class TTSManager { - [Dependency] private readonly IConfigurationManager _cfg = default!; - private static readonly Histogram RequestTimings = Metrics.CreateHistogram( "tts_req_timings", "Timings of TTS API requests", - new HistogramConfiguration + new HistogramConfiguration() { - LabelNames = new[] { "type" }, + LabelNames = new[] {"type"}, Buckets = Histogram.ExponentialBuckets(.1, 1.5, 10), }); - private static readonly Counter WantedCount = - Metrics.CreateCounter("tts_wanted_count", "Amount of wanted TTS audio."); + private static readonly Counter WantedCount = Metrics.CreateCounter( + "tts_wanted_count", + "Amount of wanted TTS audio."); - private static readonly Counter ReusedCount = - Metrics.CreateCounter("tts_reused_count", "Amount of reused TTS audio from cache."); + private static readonly Counter ReusedCount = Metrics.CreateCounter( + "tts_reused_count", + "Amount of reused TTS audio from cache."); - private static readonly Gauge CachedCount = Metrics.CreateGauge("tts_cached_count", "Amount of cached TTS audio."); + [Robust.Shared.IoC.Dependency] private readonly IConfigurationManager _cfg = default!; private readonly HttpClient _httpClient = new(); - private readonly Dictionary _cache = new(); private ISawmill _sawmill = default!; + // ReSharper disable once InconsistentNaming + public readonly Dictionary _cache = new(); + // ReSharper disable once InconsistentNaming + public readonly HashSet _cacheKeysSeq = new(); + // ReSharper disable once InconsistentNaming + public int _maxCachedCount = 200; + private string _apiUrl = string.Empty; + private string _apiToken = string.Empty; public void Initialize() { _sawmill = Logger.GetSawmill("tts"); + _cfg.OnValueChanged(WhiteCVars.TTSMaxCache, val => + { + _maxCachedCount = val; + ResetCache(); + }, true); + _cfg.OnValueChanged(WhiteCVars.TTSApiUrl, v => _apiUrl = v, true); + _cfg.OnValueChanged(WhiteCVars.TTSApiToken, v => _apiToken = v, true); } /// @@ -49,59 +64,59 @@ public void Initialize() /// /// Identifier of speaker /// SSML formatted text - /// OGG audio bytes + /// OGG audio bytes or null if failed public async Task ConvertTextToSpeech(string speaker, string text) { - var url = _cfg.GetCVar(WhiteCVars.TTSApiUrl); - if (string.IsNullOrWhiteSpace(url)) - { - _sawmill.Log(LogLevel.Error, nameof(TTSManager), "TTS Api url not specified"); - return null; - } - - var token = _cfg.GetCVar(WhiteCVars.TTSApiToken); - if (string.IsNullOrWhiteSpace(token)) - { - _sawmill.Log(LogLevel.Error, nameof(TTSManager), "TTS Api token not specified"); - return null; - } - - var maxCacheSize = _cfg.GetCVar(WhiteCVars.TTSMaxCache); WantedCount.Inc(); var cacheKey = GenerateCacheKey(speaker, text); if (_cache.TryGetValue(cacheKey, out var data)) { ReusedCount.Inc(); - _sawmill.Debug($"Use cached sound for '{text}' speech by '{speaker}' speaker"); + _sawmill.Verbose($"Use cached sound for '{text}' speech by '{speaker}' speaker"); return data; } + _sawmill.Verbose($"Generate new audio for '{text}' speech by '{speaker}' speaker"); + var body = new GenerateVoiceRequest { - ApiToken = token, + ApiToken = _apiToken, Text = text, - Speaker = speaker + Speaker = speaker, }; var reqTime = DateTime.UtcNow; try { - var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - var response = await _httpClient.PostAsJsonAsync(url, body, cts.Token); + var timeout = _cfg.GetCVar(WhiteCVars.TTSApiTimeout); + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeout)); + var response = await _httpClient.PostAsJsonAsync(_apiUrl, body, cts.Token); if (!response.IsSuccessStatusCode) - throw new Exception($"TTS request returned bad status code: {response.StatusCode}"); - - var soundData = await response.Content.ReadAsByteArrayAsync(cts.Token); - - if (_cache.Count > maxCacheSize) - _cache.Remove(_cache.Last().Key); - - _cache.Add(cacheKey, soundData); - CachedCount.Inc(); - - _sawmill.Debug( - $"Generated new sound for '{text}' speech by '{speaker}' speaker ({soundData.Length} bytes)"); - + { + if (response.StatusCode == HttpStatusCode.TooManyRequests) + { + _sawmill.Warning("TTS request was rate limited"); + return null; + } + + _sawmill.Error($"TTS request returned bad status code: {response.StatusCode}"); + return null; + } + + var json = await response.Content.ReadFromJsonAsync(cancellationToken: cts.Token); + var soundData = Convert.FromBase64String(json.Results.First().Audio); + + + _cache.TryAdd(cacheKey, soundData); + _cacheKeysSeq.Add(cacheKey); + if (_cache.Count > _maxCachedCount) + { + var firstKey = _cacheKeysSeq.First(); + _cache.Remove(firstKey); + _cacheKeysSeq.Remove(firstKey); + } + + _sawmill.Debug($"Generated new audio for '{text}' speech by '{speaker}' speaker ({soundData.Length} bytes)"); RequestTimings.WithLabels("Success").Observe((DateTime.UtcNow - reqTime).TotalSeconds); return soundData; @@ -109,13 +124,13 @@ public void Initialize() catch (TaskCanceledException) { RequestTimings.WithLabels("Timeout").Observe((DateTime.UtcNow - reqTime).TotalSeconds); - _sawmill.Warning($"Timeout of request generation new sound for '{text}' speech by '{speaker}' speaker"); + _sawmill.Error($"Timeout of request generation new audio for '{text}' speech by '{speaker}' speaker"); return null; } catch (Exception e) { RequestTimings.WithLabels("Error").Observe((DateTime.UtcNow - reqTime).TotalSeconds); - _sawmill.Warning($"Failed of request generation new sound for '{text}' speech by '{speaker}' speaker\n{e}"); + _sawmill.Error($"Failed of request generation new sound for '{text}' speech by '{speaker}' speaker\n{e}"); return null; } } @@ -123,19 +138,23 @@ public void Initialize() public void ResetCache() { _cache.Clear(); - CachedCount.Set(0); + _cacheKeysSeq.Clear(); } + [MethodImpl(MethodImplOptions.AggressiveOptimization)] private string GenerateCacheKey(string speaker, string text) { - var key = $"{speaker}/{text}"; - var keyData = Encoding.UTF8.GetBytes(key); - var bytes = System.Security.Cryptography.SHA256.HashData(keyData); - return Convert.ToHexString(bytes); + var keyData = Encoding.UTF8.GetBytes($"{speaker}/{text}"); + var hashBytes = System.Security.Cryptography.SHA256.HashData(keyData); + return Convert.ToHexString(hashBytes); } - private record GenerateVoiceRequest + private struct GenerateVoiceRequest { + public GenerateVoiceRequest() + { + } + [JsonPropertyName("api_token")] public string ApiToken { get; set; } = ""; diff --git a/Content.Server/_White/TTS/TTSManagerExtension.cs b/Content.Server/_White/TTS/TTSManagerExtension.cs new file mode 100644 index 0000000000..4363d4385d --- /dev/null +++ b/Content.Server/_White/TTS/TTSManagerExtension.cs @@ -0,0 +1,253 @@ +using System.Linq; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Content.Shared._White; +using Prometheus; +using Robust.Shared.Configuration; + +namespace Content.Server._White.TTS; + +// ReSharper disable once InconsistentNaming +public static class TTSManagerExtension +{ + private static readonly Histogram AnnounceRequestTimings = Metrics.CreateHistogram( + "tts_announce_req_timings", + "Timings announce of TTS API requests", + new HistogramConfiguration() + { + LabelNames = new[] {"type"}, + Buckets = Histogram.ExponentialBuckets(.1, 1.5, 10), + }); + + private static readonly Counter AnnounceWantedCount = Metrics.CreateCounter( + "tts_announce_wanted_count", + "Amount announce of wanted TTS audio."); + + private static readonly Counter AnnounceReusedCount = Metrics.CreateCounter( + "tts_announce_reused_count", + "Amount announce of reused TTS audio from cache."); + + private static readonly Histogram RadioRequestTimings = Metrics.CreateHistogram( + "tts_radio_req_timings", + "Timings radio of TTS API requests", + new HistogramConfiguration() + { + LabelNames = new[] {"type"}, + Buckets = Histogram.ExponentialBuckets(.1, 1.5, 10), + }); + + private static readonly Counter RadioWantedCount = Metrics.CreateCounter( + "tts_radio_wanted_count", + "Amount radio of wanted TTS audio."); + + private static readonly Counter RadioReusedCount = Metrics.CreateCounter( + "tts_radio_reused_count", + "Amount radio of reused TTS audio from cache."); + + private static readonly HttpClient _httpClient = new(); + + public static async Task RadioConvertTextToSpeech(this TTSManager _cfTtsManager, string speaker, string text) + { + // ReSharper disable once InconsistentNaming + var _sawmill = Logger.GetSawmill("tts"); + // ReSharper disable once InconsistentNaming + var _cfg = IoCManager.Resolve(); + + var url = _cfg.GetCVar(WhiteCVars.TTSApiUrl); + if (string.IsNullOrWhiteSpace(url)) + { + throw new Exception("TTS Api url not specified"); + } + + var token = _cfg.GetCVar(WhiteCVars.TTSApiToken); + if (string.IsNullOrWhiteSpace(token)) + { + throw new Exception("TTS Api token not specified"); + } + + RadioWantedCount.Inc(); + var cacheKey = GenerateCacheKey(speaker, text, "echo"); + if (_cfTtsManager._cache.TryGetValue(cacheKey, out var data)) + { + RadioReusedCount.Inc(); + _sawmill.Debug($"Use cached radio sound for '{text}' speech by '{speaker}' speaker"); + return data; + } + + var body = new GenerateVoiceRequest + { + ApiToken = token, + Text = text, + Speaker = speaker, + Effect = "Radio" + }; + + var reqTime = DateTime.UtcNow; + try + { + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(4)); + var response = await _httpClient.PostAsJsonAsync(url, body, cts.Token); + if (!response.IsSuccessStatusCode) + { + throw new Exception($"TTS request returned bad status code: {response.StatusCode}"); + } + + var json = await response.Content.ReadFromJsonAsync(); + var soundData = Convert.FromBase64String(json.Results.First().Audio); + + _cfTtsManager._cache.Add(cacheKey, soundData); + _cfTtsManager._cacheKeysSeq.Add(cacheKey); + + _sawmill.Debug($"Generated new radio sound for '{text}' speech by '{speaker}' speaker ({soundData.Length} bytes)"); + RadioRequestTimings.WithLabels("Success").Observe((DateTime.UtcNow - reqTime).TotalSeconds); + + return soundData; + } + catch (TaskCanceledException) + { + RadioRequestTimings.WithLabels("Timeout").Observe((DateTime.UtcNow - reqTime).TotalSeconds); + _sawmill.Error($"Timeout of request generation new radio sound for '{text}' speech by '{speaker}' speaker"); + throw new Exception("TTS request timeout"); + } + catch (Exception e) + { + RadioRequestTimings.WithLabels("Error").Observe((DateTime.UtcNow - reqTime).TotalSeconds); + _sawmill.Error($"Failed of request generation new radio sound for '{text}' speech by '{speaker}' speaker\n{e}"); + throw new Exception("TTS request failed"); + } + } + public static async Task AnnounceConvertTextToSpeech(this TTSManager _cfTtsManager, string speaker, string text) + { + // ReSharper disable once InconsistentNaming + var _sawmill = Logger.GetSawmill("tts"); + // ReSharper disable once InconsistentNaming + var _cfg = IoCManager.Resolve(); + + var url = _cfg.GetCVar(WhiteCVars.TTSApiUrl); + if (string.IsNullOrWhiteSpace(url)) + { + throw new Exception("TTS Api url not specified"); + } + + var token = _cfg.GetCVar(WhiteCVars.TTSApiToken); + if (string.IsNullOrWhiteSpace(token)) + { + throw new Exception("TTS Api token not specified"); + } + + AnnounceWantedCount.Inc(); + var cacheKey = GenerateCacheKey(speaker, text, "echo"); + if (_cfTtsManager._cache.TryGetValue(cacheKey, out var data)) + { + AnnounceReusedCount.Inc(); + _sawmill.Debug($"Use cached announce sound for '{text}' speech by '{speaker}' speaker"); + return data; + } + + var body = new GenerateVoiceRequest + { + ApiToken = token, + Text = text, + Speaker = speaker, + Effect = "Echo" + }; + + var reqTime = DateTime.UtcNow; + try + { + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var response = await _httpClient.PostAsJsonAsync(url, body, cts.Token); + if (!response.IsSuccessStatusCode) + { + throw new Exception($"TTS request returned bad status code: {response.StatusCode}"); + } + + var json = await response.Content.ReadFromJsonAsync(); + var soundData = Convert.FromBase64String(json.Results.First().Audio); + + _cfTtsManager._cache.Add(cacheKey, soundData); + _cfTtsManager._cacheKeysSeq.Add(cacheKey); + + _sawmill.Debug($"Generated new announce sound for '{text}' speech by '{speaker}' speaker ({soundData.Length} bytes)"); + AnnounceRequestTimings.WithLabels("Success").Observe((DateTime.UtcNow - reqTime).TotalSeconds); + + return soundData; + } + catch (TaskCanceledException) + { + AnnounceRequestTimings.WithLabels("Timeout").Observe((DateTime.UtcNow - reqTime).TotalSeconds); + _sawmill.Error($"Timeout of request generation new announce sound for '{text}' speech by '{speaker}' speaker"); + throw new Exception("TTS request timeout"); + } + catch (Exception e) + { + AnnounceRequestTimings.WithLabels("Error").Observe((DateTime.UtcNow - reqTime).TotalSeconds); + _sawmill.Error($"Failed of request generation new announce sound for '{text}' speech by '{speaker}' speaker\n{e}"); + throw new Exception("TTS request failed", e); + } + } + + private static string GenerateCacheKey(string speaker, string text, string effect = "") + { + var key = $"{speaker}/{text}/{effect}"; + byte[] keyData = Encoding.UTF8.GetBytes(key); + var bytes = System.Security.Cryptography.SHA1.HashData(keyData); + return Convert.ToHexString(bytes); + } + + private struct GenerateVoiceRequest + { + public GenerateVoiceRequest() + { + } + + [JsonPropertyName("api_token")] + public string ApiToken { get; set; } = ""; + + [JsonPropertyName("text")] + public string Text { get; set; } = ""; + + [JsonPropertyName("speaker")] + public string Speaker { get; set; } = ""; + + [JsonPropertyName("ssml")] + public bool SSML { get; private set; } = true; + + [JsonPropertyName("word_ts")] + public bool WordTS { get; private set; } = false; + + [JsonPropertyName("put_accent")] + public bool PutAccent { get; private set; } = true; + + [JsonPropertyName("put_yo")] + public bool PutYo { get; private set; } = false; + + [JsonPropertyName("sample_rate")] + public int SampleRate { get; private set; } = 24000; + + [JsonPropertyName("format")] + public string Format { get; private set; } = "ogg"; + + [JsonPropertyName("effect")] + public string Effect { get; set; } = "none"; + } + + private struct GenerateVoiceResponse + { + [JsonPropertyName("results")] + public List Results { get; set; } + + [JsonPropertyName("original_sha1")] + public string Hash { get; set; } + } + + private struct VoiceResult + { + [JsonPropertyName("audio")] + public string Audio { get; set; } + } +} diff --git a/Content.Server/_White/TTS/TTSSystem.Announce.cs b/Content.Server/_White/TTS/TTSSystem.Announce.cs new file mode 100644 index 0000000000..da1f657e64 --- /dev/null +++ b/Content.Server/_White/TTS/TTSSystem.Announce.cs @@ -0,0 +1,52 @@ +using System.Threading.Tasks; +using Content.Server._White.Chat.Systems; +using Content.Shared._White.TTS; + +namespace Content.Server._White.TTS; + +// ReSharper disable once InconsistentNaming +public sealed partial class TTSSystem +{ + private string _voiceId = "Announcer"; + + private async void OnAnnouncementSpoke(AnnouncementSpokeEvent args) + { + if (!_isEnabled || + args.Message.Length > MaxMessageChars * 2 || + !_prototypeManager.TryIndex(_voiceId, out var protoVoice)) + { + RaiseNetworkEvent(new AnnounceTTSEvent(new byte[]{}, args.AnnouncementSound, args.AnnouncementSoundParams), args.Source); + return; + } + + byte[]? soundData = null; + try + { + soundData = await GenerateTtsAnnouncement(args.Message, protoVoice.Speaker); + + } + catch (Exception) + { + // skip! + } + soundData ??= new byte[] { }; + RaiseNetworkEvent(new AnnounceTTSEvent(soundData, args.AnnouncementSound, args.AnnouncementSoundParams), args.Source); + } + + private async Task GenerateTtsAnnouncement(string text, string speaker) + { + var textSanitized = Sanitize(text); + if (textSanitized == "") return null; + if (char.IsLetter(textSanitized[^1])) + textSanitized += "."; + + var textSsml = ToSsmlText(textSanitized, SoundTraits.RateFast); + + var position = textSsml.LastIndexOf("Отправитель", StringComparison.InvariantCulture); + if (position != -1) + { + textSsml = textSsml[..position] + "" + textSsml[position..]; + } + return await _ttsManager.AnnounceConvertTextToSpeech(speaker, textSsml); + } +} diff --git a/Content.Server/_White/TTS/TTSSystem.Cache.cs b/Content.Server/_White/TTS/TTSSystem.Cache.cs new file mode 100644 index 0000000000..1c4450cda4 --- /dev/null +++ b/Content.Server/_White/TTS/TTSSystem.Cache.cs @@ -0,0 +1,37 @@ +using System.Threading.Tasks; +using Content.Shared._White.TTS; +using Robust.Shared.ContentPack; +using Robust.Shared.Utility; + +namespace Content.Server._White.TTS; + +// ReSharper disable once InconsistentNaming +public sealed partial class TTSSystem +{ + [Dependency] private readonly IResourceManager _resourceManager = default!; + + private ResPath GetCacheId(TTSVoicePrototype voicePrototype, string cacheId) + { + var resPath = new ResPath($"voicecache/{voicePrototype.ID}/{cacheId}.ogg").ToRootedPath(); + _resourceManager.UserData.CreateDir(resPath.Directory); + return resPath.ToRootedPath(); + } + private async Task GetFromCache(ResPath resPath) + { + if (!_resourceManager.UserData.Exists(resPath)) + { + return null; + } + + await using var reader = _resourceManager.UserData.OpenRead(resPath); + return reader.CopyToArray(); + } + + private async Task SaveVoiceCache(ResPath resPath, byte[] data) + { + await using var writer = _resourceManager.UserData.OpenWrite(resPath); + await writer.WriteAsync(data); + } + + +} diff --git a/Content.Server/_White/TTS/TTSSystem.Preview.cs b/Content.Server/_White/TTS/TTSSystem.Preview.cs new file mode 100644 index 0000000000..c06e9935a7 --- /dev/null +++ b/Content.Server/_White/TTS/TTSSystem.Preview.cs @@ -0,0 +1,60 @@ +using Content.Shared._White.TTS; +using Content.Shared.Players.RateLimiting; +using Robust.Shared.Player; +using Robust.Shared.Random; + +namespace Content.Server._White.TTS; + +// ReSharper disable once InconsistentNaming +public sealed partial class TTSSystem +{ + [Dependency] private readonly IRobustRandom _robustRandom = default!; + + private readonly List _sampleText = new() // TODO: Локализация? + { + "Съешь же ещё этих мягких французских булок, да выпей чаю.", + "Клоун, прекрати разбрасывать банановые кожурки офицерам под ноги!", + "Капитан, вы уверены что хотите назначить клоуна на должность главы персонала?", + "Эс Бэ! Тут человек в сером костюме, с тулбоксом и в маске! Помогите!!", + "Учёные, тут странная аномалия в баре! Она уже съела мима!", + "Я надеюсь что инженеры внимательно следят за сингулярностью...", + "Вы слышали эти странные крики в техах? Мне кажется туда ходить небезопасно.", + "Вы не видели Гамлета? Мне кажется он забегал к вам на кухню.", + "Здесь есть доктор? Человек умирает от отравленного пончика! Нужна помощь!", + "Вам нужно согласие и печать квартирмейстера, если вы хотите сделать заказ на партию дробовиков.", + "Возле эвакуационного шаттла разгерметизация! Инженеры, нам срочно нужна ваша помощь!", + "Бармен, налей мне самого крепкого вина, которое есть в твоих запасах!" + }; + + /// + /// Вообще не понимаю на какой хрен позволять пользователяем ддосить сервер ттса да и еще своим любым текстом -_- + /// + /// + private async void OnRequestPreviewTTS(RequestPreviewTTSEvent ev, EntitySessionEventArgs args) + { + if (!_isEnabled || + !_prototypeManager.TryIndex(ev.VoiceId, out var protoVoice)) + return; + + var txt = _robustRandom.Pick(_sampleText); + var cacheId = GetCacheId(protoVoice, $"{VoiceRequestType.Preview.ToString()}-{_sampleText.IndexOf(txt)}"); + + var cached = await GetFromCache(cacheId); + if (cached != null) + { + RaiseNetworkEvent(new PlayTTSEvent(cached), Filter.SinglePlayer(args.SenderSession)); + return; + } + + if (HandleRateLimit(args.SenderSession) != RateLimitStatus.Allowed) + return; + + var soundData = await GenerateTTS(txt, protoVoice.Speaker); + if (soundData is null) + return; + + RaiseNetworkEvent(new PlayTTSEvent(soundData), Filter.SinglePlayer(args.SenderSession), false); // not record for replay + + await SaveVoiceCache(cacheId, soundData); + } +} diff --git a/Content.Server/_White/TTS/TTSSystem.RateLimit.cs b/Content.Server/_White/TTS/TTSSystem.RateLimit.cs new file mode 100644 index 0000000000..e1a9af0546 --- /dev/null +++ b/Content.Server/_White/TTS/TTSSystem.RateLimit.cs @@ -0,0 +1,36 @@ +using Content.Server.Chat.Managers; +using Content.Server.Players.RateLimiting; +using Content.Shared._White; +using Content.Shared.Players.RateLimiting; +using Robust.Shared.Player; + +namespace Content.Server._White.TTS; + +// ReSharper disable once InconsistentNaming +public sealed partial class TTSSystem +{ + [Dependency] private readonly PlayerRateLimitManager _rateLimitManager = default!; + [Dependency] private readonly IChatManager _chat = default!; + + private const string RateLimitKey = "TTS"; + + private void RegisterRateLimits() + { + _rateLimitManager.Register(RateLimitKey, + new RateLimitRegistration( + WhiteCVars.TTSRateLimitPeriod, + WhiteCVars.TTSRateLimitCount, + RateLimitPlayerLimited) + ); + } + + private void RateLimitPlayerLimited(ICommonSession player) + { + _chat.DispatchServerMessage(player, Loc.GetString("tts-rate-limited"), suppressLog: true); + } + + private RateLimitStatus HandleRateLimit(ICommonSession player) + { + return _rateLimitManager.CountAction(player, RateLimitKey); + } +} diff --git a/Content.Server/_White/TTS/TTSSystem.SSML.cs b/Content.Server/_White/TTS/TTSSystem.SSML.cs new file mode 100644 index 0000000000..70acea51a6 --- /dev/null +++ b/Content.Server/_White/TTS/TTSSystem.SSML.cs @@ -0,0 +1,23 @@ +namespace Content.Server._White.TTS; + +// ReSharper disable once InconsistentNaming +public sealed partial class TTSSystem +{ + private string ToSsmlText(string text, SoundTraits traits = SoundTraits.None) + { + var result = text; + if (traits.HasFlag(SoundTraits.RateFast)) + result = $"{result}"; + if (traits.HasFlag(SoundTraits.PitchVerylow)) + result = $"{result}"; + return $"{result}"; + } + + [Flags] + private enum SoundTraits : ushort + { + None = 0, + RateFast = 1 << 0, + PitchVerylow = 1 << 1, + } +} diff --git a/Content.Server/_White/TTS/TTSSystem.Sanitize.cs b/Content.Server/_White/TTS/TTSSystem.Sanitize.cs index c799017529..e582d1da54 100644 --- a/Content.Server/_White/TTS/TTSSystem.Sanitize.cs +++ b/Content.Server/_White/TTS/TTSSystem.Sanitize.cs @@ -1,4 +1,4 @@ -using System.Text; +using System.Text; using System.Text.RegularExpressions; using Content.Server.Chat.Systems; @@ -7,243 +7,305 @@ namespace Content.Server._White.TTS; // ReSharper disable once InconsistentNaming public sealed partial class TTSSystem { - [GeneratedRegex("Ё")] - private static partial Regex RegexE(); - - [GeneratedRegex(@"[^a-zA-Zа-яА-ЯёЁ0-9,\-,+, ,?,!,.]")] - private static partial Regex SpecialCharactersRegex(); - - [GeneratedRegex("(?(OnTransformSpeech); - } - private void OnTransformSpeech(TransformSpeechEvent args) { - if (!_isEnabled) - return; + if (!_isEnabled) return; args.Message = args.Message.Replace("+", ""); } - private static string Sanitize(string text) + private string Sanitize(string text) { text = text.Trim(); - text = RegexE().Replace(text, "Е"); - text = SpecialCharactersRegex().Replace(text, ""); - text = MatchedWordsRegex().Replace(text, ReplaceMatchedWord); - text = FractionalNumbersRegex().Replace(text, " целых "); - text = WordsToNumbersRegex().Replace(text, ReplaceWord2Num); + text = Regex.Replace(text, @"[^a-zA-Zа-яА-ЯёЁ0-9,\-+?!. ]", ""); + text = Regex.Replace(text, @"[a-zA-Z]", ReplaceLat2Cyr, RegexOptions.Multiline | RegexOptions.IgnoreCase); + text = Regex.Replace(text, @"(? WordReplacement = new() + private string ReplaceWord2Num(Match word) { - { "нт", "Эн Тэ" }, - { "смо", "Эс Мэ О" }, - { "гп", "Гэ Пэ" }, - { "рд", "Эр Дэ" }, - { "гсб", "Гэ Эс Бэ" }, - { "гв", "Гэ Вэ" }, - { "нр", "Эн Эр" }, - { "срп", "Эс Эр Пэ" }, - { "цк", "Цэ Каа" }, - { "рнд", "Эр Эн Дэ" }, - { "сб", "Эс Бэ" }, - { "рцд", "Эр Цэ Дэ" }, - { "брпд", "Бэ Эр Пэ Дэ" }, - { "рпд", "Эр Пэ Дэ" }, - { "рпед", "Эр Пед" }, - { "тсф", "Тэ Эс Эф" }, - { "срт", "Эс Эр Тэ" }, - { "обр", "О Бэ Эр" }, - { "кпк", "Кэ Пэ Каа" }, - { "пда", "Пэ Дэ А" }, - { "id", "Ай Ди" }, - { "мщ", "Эм Ще" }, - { "вт", "Вэ Тэ" }, - { "ерп", "Йе Эр Пэ" }, - { "се", "Эс Йе" }, - { "апц", "А Пэ Цэ" }, - { "лкп", "Эл Ка Пэ" }, - { "см", "Эс Эм" }, - { "ека", "Йе Ка" }, - { "ка", "Кэ А" }, - { "бса", "Бэ Эс Аа" }, - { "тк", "Тэ Ка" }, - { "бфл", "Бэ Эф Эл" }, - { "бщ", "Бэ Щэ" }, - { "кк", "Кэ Ка" }, - { "ск", "Эс Ка" }, - { "зк", "Зэ Ка" }, - { "ерт", "Йе Эр Тэ" }, - { "вкд", "Вэ Ка Дэ" }, - { "нтр", "Эн Тэ Эр" }, - { "пнт", "Пэ Эн Тэ" }, - { "авд", "А Вэ Дэ" }, - { "пнв", "Пэ Эн Вэ" }, - { "ссд", "Эс Эс Дэ" }, - { "кпб", "Кэ Пэ Бэ" }, - { "сссп", "Эс Эс Эс Пэ" }, - { "крб", "Ка Эр Бэ" }, - { "бд", "Бэ Дэ" }, - { "сст", "Эс Эс Тэ" }, - { "скс", "Эс Ка Эс" }, - { "икн", "И Ка Эн" }, - { "нсс", "Эн Эс Эс" }, - { "емп", "Йе Эм Пэ" }, - { "бс", "Бэ Эс" }, - { "цкс", "Цэ Ка Эс" }, - { "срд", "Эс Эр Дэ" }, - { "жпс", "Джи Пи Эс" }, - { "gps", "Джи Пи Эс" }, - { "ннксс", "Эн Эн Ка Эс Эс" }, - { "ss", "Эс Эс" }, - { "сс", "Эс Эс" }, - { "тесла", "тэсла" }, - { "трейзен", "трэйзэн" }, - { "нанотрейзен", "нанотрэйзэн" }, - { "рпзд", "Эр Пэ Зэ Дэ" }, - { "кз", "Кэ Зэ" }, - }; + if (!long.TryParse(word.Value, out var number)) + return word.Value; + return NumberConverter.NumberToText(number); + } - #endregion + private static readonly IReadOnlyDictionary WordReplacement = + new Dictionary() + { + {"нт", "Эн Тэ"}, + {"смо", "Эс Мэ О"}, + {"гп", "Гэ Пэ"}, + {"рд", "Эр Дэ"}, + {"гсб", "Гэ Эс Бэ"}, + {"гв", "Гэ Вэ"}, + {"нр", "Эн Эр"}, + {"нра", "Эн Эра"}, + {"нру", "Эн Эру"}, + {"км", "Кэ Эм"}, + {"кма", "Кэ Эма"}, + {"кму", "Кэ Эму"}, + {"си", "Эс И"}, + {"срп", "Эс Эр Пэ"}, + {"цк", "Цэ Каа"}, + {"сцк", "Эс Цэ Каа"}, + {"пцк", "Пэ Цэ Каа"}, + {"оцк", "О Цэ Каа"}, + {"шцк", "Эш Цэ Каа"}, + {"ншцк", "Эн Эш Цэ Каа"}, + {"дсо", "Дэ Эс О"}, + {"рнд", "Эр Эн Дэ"}, + {"сб", "Эс Бэ"}, + {"рцд", "Эр Цэ Дэ"}, + {"брпд", "Бэ Эр Пэ Дэ"}, + {"рпд", "Эр Пэ Дэ"}, + {"рпед", "Эр Пед"}, + {"тсф", "Тэ Эс Эф"}, + {"срт", "Эс Эр Тэ"}, + {"обр", "О Бэ Эр"}, + {"кпк", "Кэ Пэ Каа"}, + {"пда", "Пэ Дэ А"}, + {"id", "Ай Ди"}, + {"мщ", "Эм Ще"}, + {"вт", "Вэ Тэ"}, + {"wt", "Вэ Тэ"}, + {"ерп", "Йе Эр Пэ"}, + {"се", "Эс Йе"}, + {"апц", "А Пэ Цэ"}, + {"лкп", "Эл Ка Пэ"}, + {"см", "Эс Эм"}, + {"ека", "Йе Ка"}, + {"ка", "Кэ А"}, + {"бса", "Бэ Эс Аа"}, + {"тк", "Тэ Ка"}, + {"бфл", "Бэ Эф Эл"}, + {"бщ", "Бэ Щэ"}, + {"кк", "Кэ Ка"}, + {"ск", "Эс Ка"}, + {"зк", "Зэ Ка"}, + {"ерт", "Йе Эр Тэ"}, + {"вкд", "Вэ Ка Дэ"}, + {"нтр", "Эн Тэ Эр"}, + {"пнт", "Пэ Эн Тэ"}, + {"авд", "А Вэ Дэ"}, + {"пнв", "Пэ Эн Вэ"}, + {"ссд", "Эс Эс Дэ"}, + {"крс", "Ка Эр Эс"}, + {"кпб", "Кэ Пэ Бэ"}, + {"сссп", "Эс Эс Эс Пэ"}, + {"крб", "Ка Эр Бэ"}, + {"бд", "Бэ Дэ"}, + {"сст", "Эс Эс Тэ"}, + {"скс", "Эс Ка Эс"}, + {"икн", "И Ка Эн"}, + {"нсс", "Эн Эс Эс"}, + {"емп", "Йе Эм Пэ"}, + {"бс", "Бэ Эс"}, + {"цкс", "Цэ Ка Эс"}, + {"срд", "Эс Эр Дэ"}, + {"жпс", "Джи Пи Эс"}, + {"gps", "Джи Пи Эс"}, + {"ннксс", "Эн Эн Ка Эс Эс"}, + {"ss", "Эс Эс"}, + {"тесла", "тэсла"}, + {"трейзен", "трэйзэн"}, + {"нанотрейзен", "нанотрэйзэн"}, + {"рпзд", "Эр Пэ Зэ Дэ"}, + {"кз", "Кэ Зэ"}, + {"рхбз", "Эр Хэ Бэ Зэ"}, + {"рхбзз", "Эр Хэ Бэ Зэ Зэ"}, + {"днк", "Дэ Эн Ка"}, + {"мк", "Эм Ка"}, + {"mk", "Эм Ка"}, + {"рпг", "Эр Пэ Гэ"}, + {"с4", "Си 4"}, // cyrillic + {"c4", "Си 4"}, // latinic + {"бсс", "Бэ Эс Эс"}, + {"сии", "Эс И И"}, + {"ии", "И И"}, + {"опз", "О Пэ Зэ"}, + }; + + private static readonly IReadOnlyDictionary ReverseTranslit = + new Dictionary() + { + {"a", "а"}, + {"b", "б"}, + {"v", "в"}, + {"g", "г"}, + {"d", "д"}, + {"e", "е"}, + {"je", "ё"}, + {"zh", "ж"}, + {"z", "з"}, + {"i", "и"}, + {"y", "й"}, + {"k", "к"}, + {"l", "л"}, + {"m", "м"}, + {"n", "н"}, + {"o", "о"}, + {"p", "п"}, + {"r", "р"}, + {"s", "с"}, + {"t", "т"}, + {"u", "у"}, + {"f", "ф"}, + {"h", "х"}, + {"c", "ц"}, + {"x", "кс"}, + {"ch", "ч"}, + {"sh", "ш"}, + {"jsh", "щ"}, + {"hh", "ъ"}, + {"ih", "ы"}, + {"jh", "ь"}, + {"eh", "э"}, + {"ju", "ю"}, + {"ja", "я"}, + }; } // Source: https://codelab.ru/s/csharp/digits2phrase public static class NumberConverter { private static readonly string[] Frac20Male = - [ + { "", "один", "два", "три", "четыре", "пять", "шесть", "семь", "восемь", "девять", "десять", "одиннадцать", "двенадцать", "тринадцать", "четырнадцать", "пятнадцать", "шестнадцать", "семнадцать", "восемнадцать", "девятнадцать" - ]; + }; private static readonly string[] Frac20Female = - [ + { "", "одна", "две", "три", "четыре", "пять", "шесть", "семь", "восемь", "девять", "десять", "одиннадцать", "двенадцать", "тринадцать", "четырнадцать", "пятнадцать", "шестнадцать", "семнадцать", "восемнадцать", "девятнадцать" - ]; + }; - private static readonly string[] Hunds = - [ - "", "сто", "двести", "триста", "четыреста", - "пятьсот", "шестьсот", "семьсот", "восемьсот", "девятьсот" - ]; + private static readonly string[] Hunds = + { + "", "сто", "двести", "триста", "четыреста", + "пятьсот", "шестьсот", "семьсот", "восемьсот", "девятьсот" + }; - private static readonly string[] Tens = - [ - "", "десять", "двадцать", "тридцать", "сорок", "пятьдесят", - "шестьдесят", "семьдесят", "восемьдесят", "девяносто" - ]; + private static readonly string[] Tens = + { + "", "десять", "двадцать", "тридцать", "сорок", "пятьдесят", + "шестьдесят", "семьдесят", "восемьдесят", "девяносто" + }; - public static string NumberToText(long value, bool male = true) + public static string NumberToText(long value, bool male = true) { - if (value >= (long) Math.Pow(10, 15)) - return string.Empty; + if (value >= (long)Math.Pow(10, 15)) + return String.Empty; if (value == 0) return "ноль"; - var str = new StringBuilder(); + var str = new StringBuilder(); - if (value < 0) - { - str.Append("минус"); - value = -value; - } + if (value < 0) + { + str.Append("минус"); + value = -value; + } value = AppendPeriod(value, 1000000000000, str, "триллион", "триллиона", "триллионов", true); value = AppendPeriod(value, 1000000000, str, "миллиард", "миллиарда", "миллиардов", true); value = AppendPeriod(value, 1000000, str, "миллион", "миллиона", "миллионов", true); value = AppendPeriod(value, 1000, str, "тысяча", "тысячи", "тысяч", false); - var hundreds = (int) (value / 100); - if (hundreds != 0) - AppendWithSpace(str, Hunds[hundreds]); + var hundreds = (int)(value / 100); + if (hundreds != 0) + AppendWithSpace(str, Hunds[hundreds]); - var less100 = (int) (value % 100); + var less100 = (int)(value % 100); var frac20 = male ? Frac20Male : Frac20Female; - if (less100 < 20) - AppendWithSpace(str, frac20[less100]); - else - { - var tens = less100 / 10; - AppendWithSpace(str, Tens[tens]); - var less10 = less100 % 10; - if (less10 != 0) - str.Append(" " + frac20[less100 % 10]); - } - - return str.ToString(); - } - - private static void AppendWithSpace(StringBuilder stringBuilder, string str) - { - if (stringBuilder.Length > 0) - stringBuilder.Append(' '); - stringBuilder.Append(str); - } - - private static long AppendPeriod(long value, long power, StringBuilder str, string declension1, string declension2, string declension5, bool male) - { - var thousands = (int) (value / power); - if (thousands <= 0) - return value; - - AppendWithSpace(str, NumberToText(thousands, male, declension1, declension2, declension5)); - return value % power; - } - - private static string NumberToText(long value, bool male, string valueDeclensionFor1, string valueDeclensionFor2, string valueDeclensionFor5) - { - return NumberToText(value, male) + " " + GetDeclension((int) (value % 10), - valueDeclensionFor1, - valueDeclensionFor2, - valueDeclensionFor5); - } - - private static string GetDeclension(int val, string one, string two, string five) - { - var t = val % 100 > 20 - ? val % 10 - : val % 20; - - return t switch - { - 1 => one, - 2 or 3 or 4 => two, - _ => five - }; - } + if (less100 < 20) + AppendWithSpace(str, frac20[less100]); + else + { + var tens = less100 / 10; + AppendWithSpace(str, Tens[tens]); + var less10 = less100 % 10; + if (less10 != 0) + str.Append(" " + frac20[less100%10]); + } + + return str.ToString(); + } + + private static void AppendWithSpace(StringBuilder stringBuilder, string str) + { + if (stringBuilder.Length > 0) + stringBuilder.Append(" "); + stringBuilder.Append(str); + } + + private static long AppendPeriod( + long value, + long power, + StringBuilder str, + string declension1, + string declension2, + string declension5, + bool male) + { + var thousands = (int)(value / power); + if (thousands > 0) + { + AppendWithSpace(str, NumberToText(thousands, male, declension1, declension2, declension5)); + return value % power; + } + return value; + } + + private static string NumberToText( + long value, + bool male, + string valueDeclensionFor1, + string valueDeclensionFor2, + string valueDeclensionFor5) + { + return + NumberToText(value, male) + + " " + + GetDeclension((int)(value % 10), valueDeclensionFor1, valueDeclensionFor2, valueDeclensionFor5); + } + + private static string GetDeclension(int val, string one, string two, string five) + { + var t = (val % 100 > 20) ? val % 10 : val % 20; + + switch (t) + { + case 1: + return one; + case 2: + case 3: + case 4: + return two; + default: + return five; + } + } } diff --git a/Content.Server/_White/TTS/TTSSystem.cs b/Content.Server/_White/TTS/TTSSystem.cs index 3ba5930e8c..5f267540c5 100644 --- a/Content.Server/_White/TTS/TTSSystem.cs +++ b/Content.Server/_White/TTS/TTSSystem.cs @@ -1,21 +1,14 @@ -using System.Linq; -using System.Numerics; +using System.Threading; using System.Threading.Tasks; using Content.Server.Chat.Systems; -using Content.Server.Light.Components; -using Content.Server.Station.Components; -using Content.Server.Station.Systems; +using Content.Server._White.Chat.Systems; using Content.Shared._White; using Content.Shared._White.TTS; -using Content.Shared._White.TTS.Events; using Content.Shared.GameTicking; -using Robust.Server.Player; +using Content.Shared.Radio; using Robust.Shared.Configuration; -using Robust.Shared.Network; using Robust.Shared.Player; using Robust.Shared.Prototypes; -using Robust.Shared.Random; -using Robust.Shared.Utility; namespace Content.Server._White.TTS; @@ -26,185 +19,247 @@ public sealed partial class TTSSystem : EntitySystem [Dependency] private readonly IPrototypeManager _prototypeManager = default!; [Dependency] private readonly TTSManager _ttsManager = default!; [Dependency] private readonly SharedTransformSystem _xforms = default!; - [Dependency] private readonly IRobustRandom _random = default!; - [Dependency] private readonly IServerNetManager _netMgr = default!; - [Dependency] private readonly IPlayerManager _playerManager = default!; - [Dependency] private readonly TTSPitchRateSystem _ttsPitchRateSystem = default!; - [Dependency] private readonly StationSystem _stationSystem = default!; - [Dependency] private readonly EntityLookupSystem _lookup = default!; private const int MaxMessageChars = 100 * 2; // same as SingleBubbleCharLimit * 2 - private bool _isEnabled; - private string _apiUrl = string.Empty; - - private readonly string[] _whisperWords = ["тсс", "псс", "ччч", "ссч", "сфч", "тст"]; + private bool _isEnabled = false; public override void Initialize() { _cfg.OnValueChanged(WhiteCVars.TTSEnabled, v => _isEnabled = v, true); - _cfg.OnValueChanged(WhiteCVars.TTSApiUrl, url => _apiUrl = url, true); + _cfg.OnValueChanged(WhiteCVars.TTSAnnounceVoiceId, v => _voiceId = v, true); - SubscribeLocalEvent(OnEntitySpoke); + SubscribeLocalEvent(OnTransformSpeech); + SubscribeLocalEvent(OnRoundRestartCleanup); - SubscribeLocalEvent(OnAnnounceRequest); + SubscribeLocalEvent(OnAnnouncementSpoke); + SubscribeNetworkEvent(OnRequestPreviewTTS); + SubscribeLocalEvent(OnTtsInitialized); + SubscribeLocalEvent(OnEntitySpoke); - SubscribeLocalEvent(OnRoundRestartCleanup); + RegisterRateLimits(); + } - _netMgr.RegisterNetMessage(OnRequestTTS); + private void OnTtsInitialized(Entity ent, ref MapInitEvent args) + { + if (ent.Comp.VoicePrototypeId == null && _prototypeManager.TryGetRandom(_robustRandom, out var newTtsVoice)) + { + ent.Comp.VoicePrototypeId = newTtsVoice.ID; + } + } + + + private void OnRoundRestartCleanup(RoundRestartCleanupEvent ev) + { + _ttsManager.ResetCache(); } - private async void OnEntitySpoke(EntityUid uid, TTSComponent component, EntitySpokeEvent args) + private async void OnEntitySpoke(EntityUid uid, TTSComponent component, EntitySpokeLanguageEvent args) { - if (!_isEnabled || string.IsNullOrEmpty(_apiUrl) || args.Message.Length > MaxMessageChars) + var voiceId = component.VoicePrototypeId; + if (!_isEnabled || + args.Message.Length > MaxMessageChars || + voiceId == null) return; - var voiceId = component.Prototype; var voiceEv = new TransformSpeakerVoiceEvent(uid, voiceId); RaiseLocalEvent(uid, voiceEv); voiceId = voiceEv.VoiceId; - if (!_prototypeManager.TryIndex(voiceId, out var protoVoice)) + if (voiceId == null || !_prototypeManager.TryIndex(voiceId.Value, out var protoVoice)) return; - var message = FormattedMessage.RemoveMarkup(args.Message); - - var soundData = await GenerateTTS(uid, message, protoVoice.Speaker); - if (soundData is null) - return; + if (args.IsWhisper) + { + if (args.OrgMsg.Count > 0 || args.ObsMsg.Count > 0) + { + if(args.OrgMsg.Count > 0) + HandleWhisper(uid, args.Message, args.ObfuscatedMessage!, protoVoice.Speaker, args.OrgMsg); + if(args.ObsMsg.Count > 0 && args is { LangMessage: not null, ObfuscatedLangMessage: not null }) + HandleWhisper(uid, args.LangMessage, args.ObfuscatedLangMessage, protoVoice.Speaker, args.ObsMsg); - var ttsEvent = new PlayTTSEvent(GetNetEntity(uid), soundData, false); + return; + } + HandleWhisper(uid, args.Message, args.ObfuscatedMessage, protoVoice.Speaker, null); - // Say - if (!args.IsWhisper) - { - RaiseNetworkEvent(ttsEvent, Filter.Pvs(uid), false); return; } - // Whisper - var chosenWhisperText = _random.Pick(_whisperWords); - var obfSoundData = await GenerateTTS(uid, chosenWhisperText, protoVoice.Speaker); - if (obfSoundData is null) - return; - var obfTtsEvent = new PlayTTSEvent(GetNetEntity(uid), obfSoundData, false); - var xformQuery = GetEntityQuery(); - var sourcePos = _xforms.GetWorldPosition(xformQuery.GetComponent(uid), xformQuery); - var receptions = Filter.Pvs(uid).Recipients; - - foreach (var session in receptions) + if (args.OrgMsg.Count > 0 || args.ObsMsg.Count > 0) { - if (!session.AttachedEntity.HasValue) - continue; - var xform = xformQuery.GetComponent(session.AttachedEntity.Value); - var distance = (sourcePos - _xforms.GetWorldPosition(xform, xformQuery)).LengthSquared(); - if (distance > ChatSystem.VoiceRange * ChatSystem.VoiceRange) - continue; - - EntityEventArgs actualEvent = distance > ChatSystem.WhisperClearRange - ? obfTtsEvent - : ttsEvent; - - RaiseNetworkEvent(actualEvent, Filter.SinglePlayer(session), false); + if(args.OrgMsg.Count > 0) + HandleSay(uid, args.Message, protoVoice.Speaker, args.OrgMsg); + if(args.ObsMsg.Count > 0) + HandleSay(uid, args.ObfuscatedMessage, protoVoice.Speaker, args.ObsMsg); + return; } + HandleSay(uid, args.Message, protoVoice.Speaker, null); } - private async void OnAnnounceRequest(TtsAnnouncementEvent ev) + private async void HandleSay(EntityUid uid, string message, string speaker, Filter? filter) { - if (!_prototypeManager.TryIndex(ev.VoiceId, out var ttsPrototype)) - return; - var message = FormattedMessage.RemoveMarkup(ev.Message); - var soundData = await GenerateTTS(null, message, ttsPrototype.Speaker, speechRate: "slow"); - if (soundData == null) - return; - Filter filter; - if (ev.Global) - filter = Filter.Broadcast(); - else + var soundData = await GenerateTTS(message, speaker); + if (soundData is null) return; + RaiseNetworkEvent(new PlayTTSEvent(soundData, GetNetEntity(uid)), filter ?? Filter.Pvs(uid)); + } + + private async void HandleWhisper(EntityUid uid, string message, string obfMessage, string speaker, Filter? filter) + { + var netEntity = GetNetEntity(uid); + + PlayTTSEvent fullTtsEvent; + PlayTTSEvent? obfTtsEvent = null; + { - var station = _stationSystem.GetOwningStation(ev.Source); - if (station == null || !EntityManager.TryGetComponent(station, out var stationDataComp)) + var fullSoundData = await GenerateTTS(message, speaker, true); + if (fullSoundData is null) return; - filter = _stationSystem.GetInStation(stationDataComp); + fullTtsEvent = new PlayTTSEvent(fullSoundData, netEntity, true); + if (message == obfMessage) + { + obfTtsEvent = fullTtsEvent; + } + else + { + var obfSoundData = await GenerateTTS(obfMessage, speaker, true); + if (obfSoundData is not null) + { + obfTtsEvent = new PlayTTSEvent(obfSoundData, netEntity, true); + } + } } - foreach (var player in filter.Recipients) + // TODO: Check obstacles + var xformQuery = GetEntityQuery(); + var sourcePos = _xforms.GetWorldPosition(xformQuery.GetComponent(uid), xformQuery); + var receptions = (filter ?? Filter.Pvs(uid)).Recipients; + foreach (var session in receptions) { - if (player.AttachedEntity == null) + if (!xformQuery.TryComp(session.AttachedEntity, out var xform)) continue; - // Get emergency lights in range to broadcast from - var entities = _lookup.GetEntitiesInRange(player.AttachedEntity.Value, 30f) - .Where(HasComp) - .ToList(); + var distance = (sourcePos - _xforms.GetWorldPosition(xform, xformQuery)).Length(); + if (distance > ChatSystem.VoiceRange * ChatSystem.VoiceRange) + continue; - if (entities.Count == 0) - return; + if(distance <= ChatSystem.WhisperClearRange) + RaiseNetworkEvent(fullTtsEvent, session); + else if(obfTtsEvent!= null) + RaiseNetworkEvent(obfTtsEvent, session); + } + } - // Get closest emergency light - var entity = entities.First(); - var range = new Vector2(100f); - foreach (var item in entities) - { - var itemSource = _xforms.GetWorldPosition(Transform(item)); - var playerSource = _xforms.GetWorldPosition(Transform(player.AttachedEntity.Value)); + private readonly Dictionary> _ttsTasks = new(); + private readonly SemaphoreSlim _lock = new(1, 1); + + // ReSharper disable once InconsistentNaming + private async Task GenerateTTS(string text, string speaker, bool isWhisper = false) + { + var textSanitized = Sanitize(text); + if (textSanitized == "") return null; + if (char.IsLetter(textSanitized[^1])) + textSanitized += "."; - var distance = playerSource - itemSource; + var ssmlTraits = SoundTraits.RateFast; + if (isWhisper) + ssmlTraits = SoundTraits.PitchVerylow; + var textSsml = ToSsmlText(textSanitized, ssmlTraits); - if (range.Length() <= distance.Length()) - continue; + // Создаем уникальный ключ на основе всех аргументов + var taskKey = $"{textSanitized}_{speaker}_{isWhisper}"; - range = distance; - entity = item; + // Блокируем доступ к словарю, чтобы избежать гонки + await _lock.WaitAsync(); + try + { + // Если задача уже выполняется для этого набора аргументов, ждем её завершения + if (_ttsTasks.TryGetValue(taskKey, out var existingTask)) + { + return await existingTask; } - RaiseNetworkEvent(new PlayTTSEvent(GetNetEntity(entity), soundData, true), Filter.SinglePlayer(player), - false); + // Создаем задачу и сохраняем её в словарь + var newTask = _ttsManager.ConvertTextToSpeech(speaker, textSsml); + _ttsTasks[taskKey] = newTask; + } + finally + { + _lock.Release(); } - } - - private void OnRoundRestartCleanup(RoundRestartCleanupEvent ev) - { - _ttsManager.ResetCache(); - } - - private async void OnRequestTTS(MsgRequestTTS ev) - { - var url = _cfg.GetCVar(WhiteCVars.TTSApiUrl); - if (string.IsNullOrWhiteSpace(url)) - return; - - if (!_playerManager.TryGetSessionByChannel(ev.MsgChannel, out var session) || - !_prototypeManager.TryIndex(ev.VoiceId, out var protoVoice)) - return; - var soundData = await GenerateTTS(GetEntity(ev.Uid), ev.Text, protoVoice.Speaker); - if (soundData != null) - RaiseNetworkEvent(new PlayTTSEvent(ev.Uid, soundData, false), Filter.SinglePlayer(session), false); + try + { + // Ожидаем завершения задачи + return await _ttsTasks[taskKey]; + } + finally + { + // Удаляем задачу из словаря независимо от результата + await _lock.WaitAsync(); + try + { + _ttsTasks.Remove(taskKey); + } + finally + { + _lock.Release(); + } + } } +} - private async Task GenerateTTS(EntityUid? uid, string text, string speaker, string? speechRate = null, string? speechPitch = null) +public sealed class EntitySpokeLanguageEvent: EntityEventArgs +{ + public readonly string? ObfuscatedLangMessage; + public readonly string? LangMessage; + public readonly bool IsWhisper; + public readonly Filter OrgMsg; + public readonly Filter ObsMsg; + public readonly EntityUid Source; + public readonly string Message; + public readonly string OriginalMessage; + public readonly string ObfuscatedMessage; // not null if this was a whisper + + /// + /// If the entity was trying to speak into a radio, this was the channel they were trying to access. If a radio + /// message gets sent on this channel, this should be set to null to prevent duplicate messages. + /// + public RadioChannelPrototype? Channel; + + public EntitySpokeLanguageEvent( + Filter orgMsg, + Filter obsMsg, + EntityUid source, + string message, + string originalMessage, + RadioChannelPrototype? channel, + bool isWhisper, + string obfuscatedMessage, + string? langMessage = null, + string? obfuscatedLangMessage = null) { - var textSanitized = Sanitize(text); - if (textSanitized == "") - return null; - - textSanitized = _ttsPitchRateSystem.GetFormattedSpeechText(uid, textSanitized, speechRate, speechPitch); - return await _ttsManager.ConvertTextToSpeech(speaker, textSanitized); + ObfuscatedLangMessage = obfuscatedLangMessage; + LangMessage = langMessage; + IsWhisper = isWhisper; + OrgMsg = orgMsg; + ObsMsg = obsMsg; + Source = source; + Message = message; + OriginalMessage = originalMessage; + Channel = channel; + ObfuscatedMessage = obfuscatedMessage; } } -public sealed class TransformSpeakerVoiceEvent(EntityUid sender, string voiceId) : EntityEventArgs +public sealed class TransformSpeakerVoiceEvent : EntityEventArgs { - public EntityUid Sender = sender; - public ProtoId VoiceId = voiceId; -} + public EntityUid Sender; + public string VoiceId; -public sealed class TtsAnnouncementEvent(string message, string voiceId, EntityUid source, bool global) : EntityEventArgs -{ - public readonly string Message = message; - public readonly bool Global = global; - public readonly ProtoId VoiceId = voiceId; - public readonly EntityUid Source = source; + public TransformSpeakerVoiceEvent(EntityUid sender, string voiceId) + { + Sender = sender; + VoiceId = voiceId; + } } diff --git a/Content.Server/_White/TTS/VoiceMaskSystem.TTS.cs b/Content.Server/_White/TTS/VoiceMaskSystem.TTS.cs new file mode 100644 index 0000000000..9cae2d4fd7 --- /dev/null +++ b/Content.Server/_White/TTS/VoiceMaskSystem.TTS.cs @@ -0,0 +1,31 @@ +using Content.Server._White.TTS; +using Content.Shared._White.TTS; + +namespace Content.Server.VoiceMask; + +public partial class VoiceMaskSystem +{ + // ReSharper disable once InconsistentNaming + private void InitializeTTS() + { + SubscribeLocalEvent(OnSpeakerVoiceTransform); + SubscribeLocalEvent(OnChangeVoice); + } + + private void OnSpeakerVoiceTransform(EntityUid uid, VoiceMaskComponent component, TransformSpeakerVoiceEvent args) + { + args.VoiceId = component.VoiceId; + } + + private void OnChangeVoice(Entity entity, ref VoiceMaskChangeVoiceMessage msg) + { + if (msg.Voice is { } id && !_proto.HasIndex(id)) + return; + + entity.Comp.VoiceId = msg.Voice; + + _popupSystem.PopupEntity(Loc.GetString("voice-mask-voice-popup-success"), entity); + + UpdateUI(entity); + } +} diff --git a/Content.Shared.Database/LogType.cs b/Content.Shared.Database/LogType.cs index 689d030448..555f1d28cd 100644 --- a/Content.Shared.Database/LogType.cs +++ b/Content.Shared.Database/LogType.cs @@ -99,9 +99,16 @@ public enum LogType AtmosTemperatureChanged = 88, DeviceNetwork = 89, StoreRefund = 90, + /// + /// User was rate-limited for some spam action. + /// + /// + /// This is a default value used by PlayerRateLimitManager, though users can use different log types. + /// + RateLimited = 91, // WD EDIT - AspectAnnounced = 91, - AspectStarted = 92, - AspectStopped = 93, + AspectAnnounced = 92, + AspectStarted = 93, + AspectStopped = 94, // WD EDIT } diff --git a/Content.Shared/CCVar/CCVars.cs b/Content.Shared/CCVar/CCVars.cs index d6cc176315..f64146f3a0 100644 --- a/Content.Shared/CCVar/CCVars.cs +++ b/Content.Shared/CCVar/CCVars.cs @@ -956,6 +956,59 @@ public static readonly CVarDef public static readonly CVarDef AdminAfkTime = CVarDef.Create("admin.afk_time", 600f, CVar.SERVERONLY); + /// + /// If true, admins are able to connect even if + /// would otherwise block regular players. + /// + public static readonly CVarDef AdminBypassMaxPlayers = + CVarDef.Create("admin.bypass_max_players", true, CVar.SERVERONLY); + + /// + /// Determine if custom rank names are used. + /// If it is false, it'd use the actual rank name regardless of the individual's title. + /// + /// + /// + public static readonly CVarDef AdminUseCustomNamesAdminRank = + CVarDef.Create("admin.use_custom_names_admin_rank", true, CVar.SERVERONLY); + + /* + * AHELP + */ + + /// + /// Ahelp rate limit values are accounted in periods of this size (seconds). + /// After the period has passed, the count resets. + /// + /// + public static readonly CVarDef AhelpRateLimitPeriod = + CVarDef.Create("ahelp.rate_limit_period", 2f, CVar.SERVERONLY); + + /// + /// How many ahelp messages are allowed in a single rate limit period. + /// + /// + public static readonly CVarDef AhelpRateLimitCount = + CVarDef.Create("ahelp.rate_limit_count", 10, CVar.SERVERONLY); + + /// + /// Should the administrator's position be displayed in ahelp. + /// If it is is false, only the admin's ckey will be displayed in the ahelp. + /// + /// + /// + public static readonly CVarDef AhelpAdminPrefix = + CVarDef.Create("ahelp.admin_prefix", false, CVar.SERVERONLY); + + /// + /// Should the administrator's position be displayed in the webhook. + /// If it is is false, only the admin's ckey will be displayed in webhook. + /// + /// + /// + public static readonly CVarDef AhelpAdminPrefixWebhook = + CVarDef.Create("ahelp.admin_prefix_webhook", false, CVar.SERVERONLY); + /* * Explosions */ @@ -1890,8 +1943,8 @@ public static readonly CVarDef /// After the period has passed, the count resets. /// /// - public static readonly CVarDef ChatRateLimitPeriod = - CVarDef.Create("chat.rate_limit_period", 2, CVar.SERVERONLY); + public static readonly CVarDef ChatRateLimitPeriod = + CVarDef.Create("chat.rate_limit_period", 2f, CVar.SERVERONLY); /// /// How many chat messages are allowed in a single rate limit period. @@ -1913,7 +1966,8 @@ public static readonly CVarDef CVarDef.Create("chat.rate_limit_announce_admins", true, CVar.SERVERONLY); /// - /// Minimum delay (in seconds) between announcements from . + /// Minimum delay (in seconds) between notifying admins about chat message rate limit violations. + /// A negative value disables admin announcements. /// public static readonly CVarDef ChatRateLimitAnnounceAdminsDelay = CVarDef.Create("chat.rate_limit_announce_admins_delay", 15, CVar.SERVERONLY); @@ -2127,6 +2181,34 @@ public static readonly CVarDef public static readonly CVarDef DefaultWalk = CVarDef.Create("control.default_walk", true, CVar.CLIENT | CVar.REPLICATED | CVar.ARCHIVE); + /* + * Interactions + */ + + // The rationale behind the default limit is simply that I can easily get to 7 interactions per second by just + // trying to spam toggle a light switch or lever (though the UseDelay component limits the actual effect of the + // interaction). I don't want to accidentally spam admins with alerts just because somebody is spamming a + // key manually, nor do we want to alert them just because the player is having network issues and the server + // receives multiple interactions at once. But we also want to try catch people with modified clients that spam + // many interactions on the same tick. Hence, a very short period, with a relatively high count. + + /// + /// Maximum number of interactions that a player can perform within seconds + /// + public static readonly CVarDef InteractionRateLimitCount = + CVarDef.Create("interaction.rate_limit_count", 5, CVar.SERVER | CVar.REPLICATED); + + /// + public static readonly CVarDef InteractionRateLimitPeriod = + CVarDef.Create("interaction.rate_limit_period", 0.5f, CVar.SERVER | CVar.REPLICATED); + + /// + /// Minimum delay (in seconds) between notifying admins about interaction rate limit violations. A negative + /// value disables admin announcements. + /// + public static readonly CVarDef InteractionRateLimitAnnounceAdminsDelay = + CVarDef.Create("interaction.rate_limit_announce_admins_delay", 120, CVar.SERVERONLY); + /* * STORAGE */ diff --git a/Content.Shared/Chat/ISharedChatManager.cs b/Content.Shared/Chat/ISharedChatManager.cs new file mode 100644 index 0000000000..39c1d85dd2 --- /dev/null +++ b/Content.Shared/Chat/ISharedChatManager.cs @@ -0,0 +1,8 @@ +namespace Content.Shared.Chat; + +public interface ISharedChatManager +{ + void Initialize(); + void SendAdminAlert(string message); + void SendAdminAlert(EntityUid player, string message); +} diff --git a/Content.Shared/Chat/SharedChatSystem.cs b/Content.Shared/Chat/SharedChatSystem.cs index 46ec2ab80d..7922226f3e 100644 --- a/Content.Shared/Chat/SharedChatSystem.cs +++ b/Content.Shared/Chat/SharedChatSystem.cs @@ -24,6 +24,11 @@ public abstract class SharedChatSystem : EntitySystem public const char WhisperPrefix = ','; public const char TelepathicPrefix = '='; //Nyano - Summary: Adds the telepathic channel's prefix. public const char DefaultChannelKey = 'h'; + // WD EDIT START + public const int VoiceRange = 10; // how far voice goes in world units + public const int WhisperClearRange = 2; // how far whisper goes while still being understandable, in world units + public const int WhisperMuffledRange = 5; // how far whisper goes at all, in world units + // WD EDIT END [ValidatePrototypeId] public const string CommonChannel = "Common"; diff --git a/Content.Shared/Humanoid/SharedHumanoidAppearanceSystem.cs b/Content.Shared/Humanoid/SharedHumanoidAppearanceSystem.cs index b5d91bdd58..e2c8972c91 100644 --- a/Content.Shared/Humanoid/SharedHumanoidAppearanceSystem.cs +++ b/Content.Shared/Humanoid/SharedHumanoidAppearanceSystem.cs @@ -330,7 +330,7 @@ public void SetTTSVoice( return; humanoid.Voice = voiceId; - comp.Prototype = voiceId; + comp.VoicePrototypeId = voiceId; if (sync) Dirty(uid, humanoid); diff --git a/Content.Shared/Interaction/SharedInteractionSystem.cs b/Content.Shared/Interaction/SharedInteractionSystem.cs index 3feac1ab96..4405035dd0 100644 --- a/Content.Shared/Interaction/SharedInteractionSystem.cs +++ b/Content.Shared/Interaction/SharedInteractionSystem.cs @@ -2,6 +2,8 @@ using System.Linq; using Content.Shared.ActionBlocker; using Content.Shared.Administration.Logs; +using Content.Shared.CCVar; +using Content.Shared.Chat; using Content.Shared.CombatMode; using Content.Shared.Database; using Content.Shared.Ghost; @@ -16,6 +18,7 @@ using Content.Shared.Movement.Components; using Content.Shared.Movement.Pulling.Systems; using Content.Shared.Physics; +using Content.Shared.Players.RateLimiting; using Content.Shared.Popups; using Content.Shared.Storage; using Content.Shared.Tag; @@ -24,6 +27,7 @@ using Content.Shared.Verbs; using Content.Shared.Wall; using JetBrains.Annotations; +using Robust.Shared.Configuration; using Robust.Shared.Containers; using Robust.Shared.Input; using Robust.Shared.Input.Binding; @@ -65,6 +69,9 @@ public abstract partial class SharedInteractionSystem : EntitySystem [Dependency] private readonly IRobustRandom _random = default!; [Dependency] private readonly TagSystem _tagSystem = default!; [Dependency] private readonly SharedUserInterfaceSystem _ui = default!; + [Dependency] private readonly SharedPlayerRateLimitManager _rateLimit = default!; + [Dependency] private readonly IConfigurationManager _cfg = default!; + [Dependency] private readonly ISharedChatManager _chat = default!; private EntityQuery _ignoreUiRangeQuery; private EntityQuery _fixtureQuery; @@ -81,8 +88,8 @@ public abstract partial class SharedInteractionSystem : EntitySystem public const float InteractionRange = 1.5f; public const float InteractionRangeSquared = InteractionRange * InteractionRange; - public const float MaxRaycastRange = 100f; + public const string RateLimitKey = "Interaction"; public delegate bool Ignored(EntityUid entity); @@ -124,9 +131,22 @@ public override void Initialize() new PointerInputCmdHandler(HandleTryPullObject)) .Register(); + _rateLimit.Register(RateLimitKey, + new RateLimitRegistration(CCVars.InteractionRateLimitPeriod, + CCVars.InteractionRateLimitCount, + null, + CCVars.InteractionRateLimitAnnounceAdminsDelay, + RateLimitAlertAdmins) + ); + InitializeBlocking(); } + private void RateLimitAlertAdmins(ICommonSession session) + { + _chat.SendAdminAlert(Loc.GetString("interaction-rate-limit-admin-announcement", ("player", session.Name))); + } + public override void Shutdown() { CommandBinds.Unregister(); @@ -1218,8 +1238,11 @@ public bool CanAccessEquipment(EntityUid user, EntityUid target) return InRangeUnobstructed(user, wearer) && _containerSystem.IsInSameOrParentContainer(user, wearer); } - protected bool ValidateClientInput(ICommonSession? session, EntityCoordinates coords, - EntityUid uid, [NotNullWhen(true)] out EntityUid? userEntity) + protected bool ValidateClientInput( + ICommonSession? session, + EntityCoordinates coords, + EntityUid uid, + [NotNullWhen(true)] out EntityUid? userEntity) { userEntity = null; @@ -1249,7 +1272,7 @@ protected bool ValidateClientInput(ICommonSession? session, EntityCoordinates co return false; } - return true; + return _rateLimit.CountAction(session!, RateLimitKey) == RateLimitStatus.Allowed; } /// diff --git a/Content.Shared/Players/RateLimiting/RateLimitRegistration.cs b/Content.Shared/Players/RateLimiting/RateLimitRegistration.cs new file mode 100644 index 0000000000..6bcf15d30b --- /dev/null +++ b/Content.Shared/Players/RateLimiting/RateLimitRegistration.cs @@ -0,0 +1,76 @@ +using Content.Shared.Database; +using Robust.Shared.Configuration; +using Robust.Shared.Player; + +namespace Content.Shared.Players.RateLimiting; + +/// +/// Contains all data necessary to register a rate limit with . +/// +public sealed class RateLimitRegistration( + CVarDef cVarLimitPeriodLength, + CVarDef cVarLimitCount, + Action? playerLimitedAction, + CVarDef? cVarAdminAnnounceDelay = null, + Action? adminAnnounceAction = null, + LogType adminLogType = LogType.RateLimited) +{ + /// + /// CVar that controls the period over which the rate limit is counted, measured in seconds. + /// + public readonly CVarDef CVarLimitPeriodLength = cVarLimitPeriodLength; + + /// + /// CVar that controls how many actions are allowed in a single rate limit period. + /// + public readonly CVarDef CVarLimitCount = cVarLimitCount; + + /// + /// An action that gets invoked when this rate limit has been breached by a player. + /// + /// + /// This can be used for informing players or taking administrative action. + /// + public readonly Action? PlayerLimitedAction = playerLimitedAction; + + /// + /// CVar that controls the minimum delay between admin notifications, measured in seconds. + /// This can be omitted to have no admin notification system. + /// If the cvar is set to 0, there every breach will be reported. + /// If the cvar is set to a negative number, admin announcements are disabled. + /// + /// + /// If set, must be set too. + /// + public readonly CVarDef? CVarAdminAnnounceDelay = cVarAdminAnnounceDelay; + + /// + /// An action that gets invoked when a rate limit was breached and admins should be notified. + /// + /// + /// If set, must be set too. + /// + public readonly Action? AdminAnnounceAction = adminAnnounceAction; + + /// + /// Log type used to log rate limit violations to the admin logs system. + /// + public readonly LogType AdminLogType = adminLogType; +} + +/// +/// Result of a rate-limited operation. +/// +/// +public enum RateLimitStatus : byte +{ + /// + /// The action was not blocked by the rate limit. + /// + Allowed, + + /// + /// The action was blocked by the rate limit. + /// + Blocked, +} diff --git a/Content.Shared/Players/RateLimiting/SharedPlayerRateLimitManager.cs b/Content.Shared/Players/RateLimiting/SharedPlayerRateLimitManager.cs new file mode 100644 index 0000000000..addb1dee37 --- /dev/null +++ b/Content.Shared/Players/RateLimiting/SharedPlayerRateLimitManager.cs @@ -0,0 +1,55 @@ +using Robust.Shared.Player; + +namespace Content.Shared.Players.RateLimiting; + +/// +/// General-purpose system to rate limit actions taken by clients, such as chat messages. +/// +/// +/// +/// Different categories of rate limits must be registered ahead of time by calling . +/// Once registered, you can simply call to count a rate-limited action for a player. +/// +/// +/// This system is intended for rate limiting player actions over short periods, +/// to ward against spam that can cause technical issues such as admin client load. +/// It should not be used for in-game actions or similar. +/// +/// +/// Rate limits are reset when a client reconnects. +/// This should not be an issue for the reasonably short rate limit periods this system is intended for. +/// +/// +/// +public abstract class SharedPlayerRateLimitManager +{ + /// + /// Count and validate an action performed by a player against rate limits. + /// + /// The player performing the action. + /// The key string that was previously used to register a rate limit category. + /// Whether the action counted should be blocked due to surpassing rate limits or not. + /// + /// is not a connected player + /// OR is not a registered rate limit category. + /// + /// + public abstract RateLimitStatus CountAction(ICommonSession player, string key); + + /// + /// Register a new rate limit category. + /// + /// + /// The key string that will be referred to later with . + /// Must be unique and should probably just be a constant somewhere. + /// + /// The data specifying the rate limit's parameters. + /// has already been registered. + /// is invalid. + public abstract void Register(string key, RateLimitRegistration registration); + + /// + /// Initialize the manager's functionality at game startup. + /// + public abstract void Initialize(); +} diff --git a/Content.Shared/_White/CVars.cs b/Content.Shared/_White/CVars.cs index 6d932d3cdf..7752c795d4 100644 --- a/Content.Shared/_White/CVars.cs +++ b/Content.Shared/_White/CVars.cs @@ -70,5 +70,27 @@ public static readonly CVarDef public static readonly CVarDef TTSApiTimeout = CVarDef.Create("tts.api_timeout", 5, CVar.SERVERONLY | CVar.ARCHIVE); + /// + /// VoiceId for Announcement TTS + /// + // ReSharper disable once InconsistentNaming + public static readonly CVarDef TTSAnnounceVoiceId = + CVarDef.Create("tts.announce_voice", "Announcer", CVar.SERVERONLY | CVar.ARCHIVE); + + /// + /// Tts rate limit values are accounted in periods of this size (seconds). + /// After the period has passed, the count resets. + /// + // ReSharper disable once InconsistentNaming + public static readonly CVarDef TTSRateLimitPeriod = + CVarDef.Create("tts.rate_limit_period", 2f, CVar.SERVERONLY); + + /// + /// How many tts preview messages are allowed in a single rate limit period. + /// + // ReSharper disable once InconsistentNaming + public static readonly CVarDef TTSRateLimitCount = + CVarDef.Create("tts.rate_limit_count", 3, CVar.SERVERONLY); + #endregion } diff --git a/Content.Shared/_White/TTS/Events/PlayTTSEvent.cs b/Content.Shared/_White/TTS/Events/PlayTTSEvent.cs deleted file mode 100644 index 741a7389a2..0000000000 --- a/Content.Shared/_White/TTS/Events/PlayTTSEvent.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Robust.Shared.Serialization; - -namespace Content.Shared._White.TTS.Events; - -[Serializable, NetSerializable] -// ReSharper disable once InconsistentNaming -public sealed class PlayTTSEvent(NetEntity uid, byte[] data, bool boostVolume) : EntityEventArgs -{ - public NetEntity Uid { get; } = uid; - - public byte[] Data { get; } = data; - - public bool BoostVolume { get; } = boostVolume; -} diff --git a/Content.Shared/_White/TTS/Events/RequestTTSEvent.cs b/Content.Shared/_White/TTS/Events/RequestTTSEvent.cs deleted file mode 100644 index 02ec705c2f..0000000000 --- a/Content.Shared/_White/TTS/Events/RequestTTSEvent.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Robust.Shared.Serialization; - -namespace Content.Shared._White.TTS.Events; - -[Serializable, NetSerializable] -// ReSharper disable once InconsistentNaming -public sealed class RequestTTSEvent(string text) : EntityEventArgs -{ - public string Text { get; } = text; -} diff --git a/Content.Shared/_White/TTS/MsgRequestTTS.cs b/Content.Shared/_White/TTS/MsgRequestTTS.cs deleted file mode 100644 index d5e72be6fa..0000000000 --- a/Content.Shared/_White/TTS/MsgRequestTTS.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Lidgren.Network; -using Robust.Shared.Network; -using Robust.Shared.Prototypes; -using Robust.Shared.Serialization; - -namespace Content.Shared._White.TTS; - -// ReSharper disable once InconsistentNaming -public sealed class MsgRequestTTS : NetMessage -{ - public override MsgGroups MsgGroup => MsgGroups.Command; - - public NetEntity Uid { get; set; } = NetEntity.Invalid; - public string Text { get; set; } = string.Empty; - public ProtoId VoiceId { get; set; } = string.Empty; - - public override void ReadFromBuffer(NetIncomingMessage buffer, IRobustSerializer serializer) - { - Uid = new NetEntity(buffer.ReadInt32()); - Text = buffer.ReadString(); - VoiceId = buffer.ReadString(); - } - - public override void WriteToBuffer(NetOutgoingMessage buffer, IRobustSerializer serializer) - { - buffer.Write((int)Uid); - buffer.Write(Text); - buffer.Write(VoiceId); - } -} diff --git a/Content.Shared/_White/TTS/TTSComponent.cs b/Content.Shared/_White/TTS/TTSComponent.cs index b180fe3c88..6b328e38a0 100644 --- a/Content.Shared/_White/TTS/TTSComponent.cs +++ b/Content.Shared/_White/TTS/TTSComponent.cs @@ -1,11 +1,19 @@ +using Robust.Shared.GameStates; using Robust.Shared.Prototypes; namespace Content.Shared._White.TTS; -[RegisterComponent, AutoGenerateComponentState] +/// +/// Apply TTS for entity chat say messages +/// +[RegisterComponent, NetworkedComponent] // ReSharper disable once InconsistentNaming public sealed partial class TTSComponent : Component { - [DataField, AutoNetworkedField] - public ProtoId Prototype { get; set; } = "Eugene"; + /// + /// Prototype of used voice for TTS. + /// + [ViewVariables(VVAccess.ReadWrite)] + [DataField("voice")] + public ProtoId? VoicePrototypeId { get; set; } } diff --git a/Content.Shared/_White/TTS/TTSPitchRateSystem.cs b/Content.Shared/_White/TTS/TTSPitchRateSystem.cs deleted file mode 100644 index 6ce460e575..0000000000 --- a/Content.Shared/_White/TTS/TTSPitchRateSystem.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Content.Shared.Humanoid; -using Content.Shared.Humanoid.Prototypes; -using Robust.Shared.Prototypes; - -namespace Content.Shared._White.TTS; - -// ReSharper disable once InconsistentNaming -public sealed class TTSPitchRateSystem : EntitySystem -{ - public readonly Dictionary, TTSPitchRate> SpeciesPitches = new() - { - ["SlimePerson"] = new TTSPitchRate("high"), - ["Arachnid"] = new TTSPitchRate("x-high", "x-fast"), - ["Dwarf"] = new TTSPitchRate("high", "slow"), - ["Human"] = new TTSPitchRate(), - ["Diona"] = new TTSPitchRate("x-low", "x-slow"), - ["Reptilian"] = new TTSPitchRate("low", "slow"), - }; - - public string GetFormattedSpeechText(EntityUid? uid, string text, string? speechRate = null, string? speechPitch = null) - { - var ssml = text; - if (TryComp(uid, out var humanoid)) - { - var species = SpeciesPitches.GetValueOrDefault(humanoid.Species); - if (species != null) - { - speechRate ??= species.Rate; - speechPitch ??= species.Pitch; - } - } - - if (speechRate != null) - ssml = $"{ssml}"; - if (speechPitch != null) - ssml = $"{ssml}"; - - return $"{ssml}"; - } -} - -// ReSharper disable once InconsistentNaming -public record TTSPitchRate(string Pitch = "medium", string Rate = "medium"); diff --git a/Content.Shared/_White/TTS/TTSSerializable.cs b/Content.Shared/_White/TTS/TTSSerializable.cs new file mode 100644 index 0000000000..6d36a577e2 --- /dev/null +++ b/Content.Shared/_White/TTS/TTSSerializable.cs @@ -0,0 +1,49 @@ +using Robust.Shared.Audio; +using Robust.Shared.Serialization; + +namespace Content.Shared._White.TTS; + +public enum VoiceRequestType +{ + None, + Preview +} + +[Serializable, NetSerializable] +// ReSharper disable once InconsistentNaming +public sealed class PlayTTSEvent(byte[] data, NetEntity? sourceUid = null, bool isWhisper = false) : EntityEventArgs +{ + public byte[] Data { get; } = data; + public NetEntity? SourceUid { get; } = sourceUid; + public bool IsWhisper { get; } = isWhisper; +} + +// ReSharper disable once InconsistentNaming +[Serializable, NetSerializable] +public sealed class RequestGlobalTTSEvent(VoiceRequestType text, string voiceId) : EntityEventArgs +{ + public VoiceRequestType Text { get;} = text; + public string VoiceId { get; } = voiceId; +} + +// ReSharper disable once InconsistentNaming +[Serializable, NetSerializable] +public sealed class RequestPreviewTTSEvent(string voiceId) : EntityEventArgs +{ + public string VoiceId { get; } = voiceId; +} + +[Serializable, NetSerializable] +public sealed class VoiceMaskChangeVoiceMessage(string voice) : BoundUserInterfaceMessage +{ + public string Voice = voice; +} + +// ReSharper disable once InconsistentNaming +[Serializable, NetSerializable] +public sealed class AnnounceTTSEvent(byte[] data, string announcementSound, AudioParams announcementParams) : EntityEventArgs +{ + public byte[] Data { get; } = data; + public string AnnouncementSound { get; } = announcementSound; + public AudioParams AnnouncementParams{ get; } = announcementParams; +} diff --git a/Content.Shared/_White/TTS/TTSVoicePrototype.cs b/Content.Shared/_White/TTS/TTSVoicePrototype.cs index 976d0f5157..dd9a58243f 100644 --- a/Content.Shared/_White/TTS/TTSVoicePrototype.cs +++ b/Content.Shared/_White/TTS/TTSVoicePrototype.cs @@ -1,33 +1,34 @@ -using Content.Shared.Humanoid; +using Content.Shared.Humanoid; using Robust.Shared.Prototypes; namespace Content.Shared._White.TTS; +/// +/// Prototype represent available TTS voices +/// [Prototype("ttsVoice")] // ReSharper disable once InconsistentNaming -public sealed class TTSVoicePrototype : IPrototype +public sealed partial class TTSVoicePrototype : IPrototype { [IdDataField] public string ID { get; } = default!; - [DataField] + [DataField("name")] public string Name { get; } = string.Empty; - [DataField(required: true)] - public Sex Sex { get; } + [DataField("sex", required: true)] + public Sex Sex { get; } = default!; - [ViewVariables(VVAccess.ReadWrite), DataField(required: true)] + [ViewVariables(VVAccess.ReadWrite)] + [DataField("speaker", required: true)] public string Speaker { get; } = string.Empty; /// - /// Whether the voice is available in the character editor. + /// Whether the species is available "at round start" (In the character editor) /// - [DataField] + [DataField("roundStart")] public bool RoundStart { get; } = true; - [DataField] - public bool SponsorOnly { get; } - - [DataField] - public bool BorgVoice { get; } + [DataField("sponsorOnly")] + public bool SponsorOnly { get; } = false; } diff --git a/Resources/Locale/en-US/administration/bwoink.ftl b/Resources/Locale/en-US/administration/bwoink.ftl index 94d3328bde..3f55df6d38 100644 --- a/Resources/Locale/en-US/administration/bwoink.ftl +++ b/Resources/Locale/en-US/administration/bwoink.ftl @@ -12,3 +12,10 @@ bwoink-system-typing-indicator = {$players} {$count -> } typing... admin-bwoink-play-sound = Bwoink? +<<<<<<< HEAD +======= + +bwoink-title-none-selected = None selected + +bwoink-system-rate-limited = System: you are sending messages too quickly. +>>>>>>> c33644532d (Rate limit ahelps (#29219)) diff --git a/Resources/Locale/en-US/administration/commands/erase.ftl b/Resources/Locale/en-US/administration/commands/erase.ftl new file mode 100644 index 0000000000..e9f995f03e --- /dev/null +++ b/Resources/Locale/en-US/administration/commands/erase.ftl @@ -0,0 +1,7 @@ +# erase +cmd-erase-desc = Erase a player's entity if it exists and all their chat messages +cmd-erase-help = erase +cmd-erase-invalid-args = Invalid number of arguments +cmd-erase-player-not-found = Player not found + +cmd-erase-player-completion = diff --git a/Resources/Locale/en-US/administration/commands/respawn.ftl b/Resources/Locale/en-US/administration/commands/respawn.ftl new file mode 100644 index 0000000000..6aab854ee4 --- /dev/null +++ b/Resources/Locale/en-US/administration/commands/respawn.ftl @@ -0,0 +1,9 @@ +cmd-respawn-desc = Respawns a player, kicking them back to the lobby. +cmd-respawn-help = respawn [player or UserId] + +cmd-respawn-invalid-args = Must provide <= 1 argument. +cmd-respawn-no-player = If not a player, an argument must be given. +cmd-respawn-unknown-player = Unknown player +cmd-respawn-player-not-online = Player is not currently online, but they will respawn if they come back online + +cmd-respawn-player-completion = diff --git a/Resources/Locale/en-US/interaction/interaction-system.ftl b/Resources/Locale/en-US/interaction/interaction-system.ftl index a4c380abca..3c0c3ae8b4 100644 --- a/Resources/Locale/en-US/interaction/interaction-system.ftl +++ b/Resources/Locale/en-US/interaction/interaction-system.ftl @@ -1,2 +1,3 @@ shared-interaction-system-in-range-unobstructed-cannot-reach = You can't reach there! -interaction-system-user-interaction-cannot-reach = You can't reach there! \ No newline at end of file +interaction-system-user-interaction-cannot-reach = You can't reach there! +interaction-rate-limit-admin-announcement = Player { $player } breached interaction rate limits. They may be using macros, auto-clickers, or a modified client. Though they may just be spamming buttons or having network issues.