using System.Collections.Frozen; using System.Text.RegularExpressions; using Content.Shared.ActionBlocker; using Content.Shared.Chat.Prototypes; using Content.Shared.Popups; using Content.Shared.Radio; using Content.Shared.Speech; using Content.Shared.Whitelist; using Robust.Shared.Audio; using Robust.Shared.Audio.Systems; using Robust.Shared.Console; using Robust.Shared.Network; using Robust.Shared.Player; using Robust.Shared.Prototypes; using Robust.Shared.Random; using Robust.Shared.Utility; namespace Content.Shared.Chat; public abstract partial class SharedChatSystem : EntitySystem { public const char RadioCommonPrefix = ';'; public const char RadioChannelPrefix = ':'; public const char RadioChannelAltPrefix = '.'; public const char LocalPrefix = '>'; public const char ConsolePrefix = '/'; public const char DeadPrefix = '\\'; public const char LOOCPrefix = '('; public const char OOCPrefix = '['; public const char EmotesPrefix = '@'; public const char EmotesAltPrefix = '*'; public const char AdminPrefix = ']'; public const char WhisperPrefix = ','; public const char DefaultChannelKey = 'h'; 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 public static readonly SoundSpecifier DefaultAnnouncementSound = new SoundPathSpecifier("/Audio/Announcements/announce.ogg"); public static readonly ProtoId CommonChannel = "Common"; public static readonly string DefaultChannelPrefix = $"{RadioChannelPrefix}{DefaultChannelKey}"; public static readonly ProtoId DefaultSpeechVerb = "Default"; [Dependency] private readonly IPrototypeManager _prototypeManager = default!; [Dependency] private readonly SharedPopupSystem _popup = default!; [Dependency] private readonly EntityWhitelistSystem _whitelist = default!; [Dependency] private readonly ActionBlockerSystem _actionBlocker = default!; [Dependency] private readonly SharedAudioSystem _audio = default!; [Dependency] private readonly IRobustRandom _random = default!; [Dependency] private readonly INetManager _net = default!; /// /// Cache of the keycodes for faster lookup. /// private FrozenDictionary _keyCodes = default!; public override void Initialize() { base.Initialize(); DebugTools.Assert(_prototypeManager.HasIndex(CommonChannel)); SubscribeLocalEvent(OnPrototypeReload); CacheRadios(); CacheEmotes(); } protected virtual void OnPrototypeReload(PrototypesReloadedEventArgs obj) { if (obj.WasModified()) CacheRadios(); if (obj.WasModified()) CacheEmotes(); } private void CacheRadios() { _keyCodes = _prototypeManager.EnumeratePrototypes() .ToFrozenDictionary(x => x.KeyCode); } /// /// Attempts to find an applicable for a speaking entity's message. /// If one is not found, returns . /// public SpeechVerbPrototype GetSpeechVerb(EntityUid source, string message, SpeechComponent? speech = null) { if (!Resolve(source, ref speech, false)) return _prototypeManager.Index(DefaultSpeechVerb); // check for a suffix-applicable speech verb SpeechVerbPrototype? current = null; foreach (var (str, id) in speech.SuffixSpeechVerbs) { var proto = _prototypeManager.Index(id); if (message.EndsWith(Loc.GetString(str)) && proto.Priority >= (current?.Priority ?? 0)) { current = proto; } } // if no applicable suffix verb return the normal one used by the entity return current ?? _prototypeManager.Index(speech.SpeechVerb); } /// /// Splits the input message into a radio prefix part and the rest to preserve it during sanitization. /// /// /// This is primarily for the chat emote sanitizer, which can match against ":b" as an emote, which is a valid radio keycode. /// public void GetRadioKeycodePrefix(EntityUid source, string input, out string output, out string prefix) { prefix = string.Empty; output = input; // If the string is less than 2, then it's probably supposed to be an emote. // No one is sending empty radio messages! if (input.Length <= 2) return; if (!(input.StartsWith(RadioChannelPrefix) || input.StartsWith(RadioChannelAltPrefix))) return; if (!_keyCodes.TryGetValue(char.ToLower(input[1]), out _)) return; prefix = input[..2]; output = input[2..]; } /// /// Attempts to resolve radio prefixes in chat messages (e.g., remove a leading ":e" and resolve the requested /// channel. Returns true if a radio message was attempted, even if the channel is invalid. /// /// Source of the message /// The message to be modified /// The modified message /// The channel that was requested, if any /// Whether or not to generate an informative pop-up message. /// public bool TryProcessRadioMessage( EntityUid source, string input, out string output, out RadioChannelPrototype? channel, bool quiet = false) { output = input.Trim(); channel = null; if (input.Length == 0) return false; if (input.StartsWith(RadioCommonPrefix)) { output = SanitizeMessageCapital(input[1..].TrimStart()); channel = _prototypeManager.Index(CommonChannel); return true; } if (!(input.StartsWith(RadioChannelPrefix) || input.StartsWith(RadioChannelAltPrefix))) return false; if (input.Length < 2 || char.IsWhiteSpace(input[1])) { output = SanitizeMessageCapital(input[1..].TrimStart()); if (!quiet) _popup.PopupEntity(Loc.GetString("chat-manager-no-radio-key"), source, source); return true; } var channelKey = input[1]; channelKey = char.ToLower(channelKey); output = SanitizeMessageCapital(input[2..].TrimStart()); if (channelKey == DefaultChannelKey) { var ev = new GetDefaultRadioChannelEvent(); RaiseLocalEvent(source, ev); if (ev.Channel != null) _prototypeManager.TryIndex(ev.Channel, out channel); return true; } if (!_keyCodes.TryGetValue(channelKey, out channel) && !quiet) { var msg = Loc.GetString("chat-manager-no-such-channel", ("key", channelKey)); _popup.PopupEntity(msg, source, source); } return true; } public string SanitizeMessageCapital(string message) { if (string.IsNullOrEmpty(message)) return message; // Capitalize first letter message = OopsConcat(char.ToUpper(message[0]).ToString(), message.Remove(0, 1)); return message; } private static string OopsConcat(string a, string b) { // This exists to prevent Roslyn being clever and compiling something that fails sandbox checks. return a + b; } public string SanitizeMessageCapitalizeTheWordI(string message, string theWordI = "i") { if (string.IsNullOrEmpty(message)) return message; for ( var index = message.IndexOf(theWordI); index != -1; index = message.IndexOf(theWordI, index + 1) ) { // Stops the code If It's tryIng to capItalIze the letter I In the mIddle of words // Repeating the code twice is the simplest option if (index + 1 < message.Length && char.IsLetter(message[index + 1])) continue; if (index - 1 >= 0 && char.IsLetter(message[index - 1])) continue; var beforeTarget = message.Substring(0, index); var target = message.Substring(index, theWordI.Length); var afterTarget = message.Substring(index + theWordI.Length); message = beforeTarget + target.ToUpper() + afterTarget; } return message; } public static string SanitizeAnnouncement(string message, int maxLength = 0, int maxNewlines = 2) { var trimmed = message.Trim(); if (maxLength > 0 && trimmed.Length > maxLength) { trimmed = $"{message[..maxLength]}..."; } // No more than max newlines, other replaced to spaces if (maxNewlines > 0) { var chars = trimmed.ToCharArray(); var newlines = 0; for (var i = 0; i < chars.Length; i++) { if (chars[i] != '\n') continue; if (newlines >= maxNewlines) chars[i] = ' '; newlines++; } return new string(chars); } return trimmed; } public static string InjectTagInsideTag(ChatMessage message, string outerTag, string innerTag, string? tagParameter) { var rawmsg = message.WrappedMessage; var tagStart = rawmsg.IndexOf($"[{outerTag}]"); var tagEnd = rawmsg.IndexOf($"[/{outerTag}]"); if (tagStart < 0 || tagEnd < 0) //If the outer tag is not found, the injection is not performed return rawmsg; tagStart += outerTag.Length + 2; string innerTagProcessed = tagParameter != null ? $"[{innerTag}={tagParameter}]" : $"[{innerTag}]"; rawmsg = rawmsg.Insert(tagEnd, $"[/{innerTag}]"); rawmsg = rawmsg.Insert(tagStart, innerTagProcessed); return rawmsg; } /// /// Injects a tag around all found instances of a specific string in a ChatMessage. /// Excludes strings inside other tags and brackets. /// public static string InjectTagAroundString(ChatMessage message, string targetString, string tag, string? tagParameter) { var rawmsg = message.WrappedMessage; rawmsg = Regex.Replace(rawmsg, "(?i)(" + targetString + ")(?-i)(?![^[]*])", $"[{tag}={tagParameter}]$1[/{tag}]"); return rawmsg; } public static string GetStringInsideTag(ChatMessage message, string tag) { var rawmsg = message.WrappedMessage; var tagStart = rawmsg.IndexOf($"[{tag}]"); var tagEnd = rawmsg.IndexOf($"[/{tag}]"); if (tagStart < 0 || tagEnd < 0) return ""; tagStart += tag.Length + 2; return rawmsg.Substring(tagStart, tagEnd - tagStart); } protected virtual void SendEntityEmote( EntityUid source, string action, ChatTransmitRange range, string? nameOverride, bool hideLog = false, bool checkEmote = true, bool ignoreActionBlocker = false, NetUserId? author = null ) { } /// /// Sends an in-character chat message to relevant clients. /// /// The entity that is speaking. /// The message being spoken or emoted. /// The chat type. /// Whether or not this message should appear in the chat window. /// Whether or not this message should appear in the adminlog window. /// /// The player doing the speaking. /// The name to use for the speaking entity. Usually this should just be modified via . If this is set, the event will not get raised. /// Whether or not should be parsed with consideration of radio channel prefix text at start the start. /// If set to true, action blocker will not be considered for whether an entity can send this message. public virtual void TrySendInGameICMessage( EntityUid source, string message, InGameICChatType desiredType, bool hideChat, bool hideLog = false, IConsoleShell? shell = null, ICommonSession? player = null, string? nameOverride = null, bool checkRadioPrefix = true, bool ignoreActionBlocker = false) { } /// /// Sends an in-character chat message to relevant clients. /// /// The entity that is speaking. /// The message being spoken or emoted. /// The chat type. /// Conceptual range of transmission, if it shows in the chat window, if it shows to far-away ghosts or ghosts at all... /// Disables the admin log for this message if true. Used for entities that are not players, like vendors, cloning, etc. /// /// The player doing the speaking. /// The name to use for the speaking entity. Usually this should just be modified via . If this is set, the event will not get raised. /// If set to true, action blocker will not be considered for whether an entity can send this message. public virtual void TrySendInGameICMessage( EntityUid source, string message, InGameICChatType desiredType, ChatTransmitRange range, bool hideLog = false, IConsoleShell? shell = null, ICommonSession? player = null, string? nameOverride = null, bool checkRadioPrefix = true, bool ignoreActionBlocker = false ) { } /// /// Sends an out-of-character chat message to relevant clients. /// /// The entity that is speaking. /// The message being spoken or emoted. /// The chat type. /// Whether or not to show the message in the chat window. /// /// The player doing the speaking. public virtual void TrySendInGameOOCMessage( EntityUid source, string message, InGameOOCChatType type, bool hideChat, IConsoleShell? shell = null, ICommonSession? player = null ) { } /// /// Dispatches an announcement to all. /// /// The contents of the message. /// The sender (Communications Console in Communications Console Announcement). /// Play the announcement sound. /// Sound to play. /// Optional color for the announcement message. public virtual void DispatchGlobalAnnouncement( string message, string? sender = null, bool playSound = true, SoundSpecifier? announcementSound = null, Color? colorOverride = null ) { } /// /// Dispatches an announcement to players selected by filter. /// /// Filter to select players who will recieve the announcement. /// The contents of the message. /// The entity making the announcement (used to determine the station). /// The sender (Communications Console in Communications Console Announcement). /// Play the announcement sound. /// Sound to play. /// Optional color for the announcement message. public virtual void DispatchFilteredAnnouncement( Filter filter, string message, EntityUid? source = null, string? sender = null, bool playSound = true, SoundSpecifier? announcementSound = null, Color? colorOverride = null) { } /// /// Dispatches an announcement on a specific station. /// /// The entity making the announcement (used to determine the station). /// The contents of the message. /// The sender (Communications Console in Communications Console Announcement). /// Play the announcement sound. /// Sound to play. /// Optional color for the announcement message. public virtual void DispatchStationAnnouncement( EntityUid source, string message, string? sender = null, bool playDefaultSound = true, SoundSpecifier? announcementSound = null, Color? colorOverride = null) { } } /// /// Controls transmission of chat. /// public enum ChatTransmitRange : byte { /// Acts normal, ghosts can hear across the map, etc. Normal, /// Normal but ghosts are still range-limited. GhostRangeLimit, /// Hidden from the chat window. HideChat, /// Ghosts can't hear or see it at all. Regular players can if in-range. NoGhosts } /// /// InGame IC chat is for chat that is specifically ingame (not lobby) but is also in character, i.e. speaking. /// // ReSharper disable once InconsistentNaming public enum InGameICChatType : byte { Speak, Emote, Whisper } /// /// InGame OOC chat is for chat that is specifically ingame (not lobby) but is OOC, like deadchat or LOOC. /// public enum InGameOOCChatType : byte { Looc, Dead }