using System.Collections.Frozen; using System.Linq; using System.Text.RegularExpressions; using Content.Shared.ActionBlocker; using Content.Shared.CCVar; 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.Configuration; 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 bool ChatNameLinks { get; private set; } public static readonly string DefaultChannelPrefix = $"{RadioChannelPrefix}{DefaultChannelKey}"; public static readonly ProtoId DefaultSpeechVerb = "Default"; [Dependency] protected IConfigurationManager Config = default!; [Dependency] private INetManager _net = default!; [Dependency] protected IRobustRandom Random = default!; [Dependency] private ISharedPlayerManager _player = default!; [Dependency] private ActionBlockerSystem _actionBlocker = default!; [Dependency] private EntityWhitelistSystem _whitelist = default!; [Dependency] private SharedAudioSystem _audio = default!; [Dependency] private SharedPopupSystem _popup = default!; /// /// Cache of the keycodes for faster lookup. /// private FrozenDictionary _keyCodes = default!; public override void Initialize() { base.Initialize(); DebugTools.Assert(ProtoMan.HasIndex(CommonChannel)); SubscribeLocalEvent(OnPrototypeReload); SubscribeAllEvent(OnChatMessageLinkClicked); CacheRadios(); CacheEmotes(); Subs.CVar(Config, CCVars.ChatNameLinks, v => ChatNameLinks = v, true); } protected virtual void OnPrototypeReload(PrototypesReloadedEventArgs obj) { if (obj.WasModified()) CacheRadios(); if (obj.WasModified()) CacheEmotes(); } private void OnChatMessageLinkClicked(ChatLinkClickedRequestEvent msg, EntitySessionEventArgs args) { if (!ChatNameLinks) return; if (GetEntity(msg.Target) is not { Valid: true } target || !Exists(target)) return; if (args.SenderSession.AttachedEntity is not { Valid: true } ent) return; ClickMessageSender(target, ent); } private void CacheRadios() { _keyCodes = ProtoMan.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 ProtoMan.Index(DefaultSpeechVerb); // check for a suffix-applicable speech verb SpeechVerbPrototype? current = null; foreach (var (str, id) in speech.SuffixSpeechVerbs) { var proto = ProtoMan.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 ?? ProtoMan.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 = ProtoMan.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) ProtoMan.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; } /// /// Injects a tag inside the first found instance of a specific string in a . /// public static string InjectTagInsideTag(ChatMessage message, string outerTag, string innerTag, string? tagValue = null, params (string Key, string Value)[]? tagParameters) { 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; rawmsg = rawmsg.Insert(tagEnd, $"[/{innerTag}]"); if (tagValue != null) innerTag = $"{innerTag}=\"{FormattedMessage.EscapeText(tagValue)}\""; var innerTagProcessed = tagParameters == null ? $"[{innerTag}]" : $"[{innerTag} {string.Join(" ", tagParameters.Select(t => $"{FormattedMessage.EscapeText(t.Key)}=\"{FormattedMessage.RemoveMarkupPermissive(t.Value)}\""))}]"; 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; // TODO: Figure out if there's any way we can cache this, and if not then rewrite this to not use regex. #pragma warning disable RA0026 rawmsg = Regex.Replace(rawmsg, "(?i)(" + targetString + ")(?-i)(?![^[]*])", $"[{tag}={tagParameter}]$1[/{tag}]"); #pragma warning restore RA0026 return rawmsg; } /// public bool CanClickMessageSender(NetEntity target, EntityUid? ent = null) { return CanClickMessageSender(GetEntity(target), ent); } /// /// Checks whether an entity can click a chat message link. /// /// Target of the message link /// Entity that is attempting to click the chat message, defaults to attached player entity if null. /// True if the entity is able to click the link public bool CanClickMessageSender(EntityUid target, EntityUid? ent = null) { ent ??= _player.LocalEntity; if (ent == null) return false; if (!CanClick(target, ent.Value)) return false; var ev = new ClickEntityLinkEvent(target, true); RaiseLocalEvent(ent.Value, ref ev); return ev.Handled; } private bool CanClick(EntityUid target, EntityUid ent) { if (!ChatNameLinks) return false; if (ent == target) return false; return true; } /// /// Teleports an entity to a target via /// /// Target we are attempted to teleport to /// Entity that is attempting to warp /// True if warp was successful. public void ClickMessageSender(EntityUid target, EntityUid? ent = null) { ent ??= _player.LocalEntity; if (ent == null) return; if (!CanClick(target, ent.Value)) return; var ev = new ClickEntityLinkEvent(target, false); RaiseLocalEvent(ent.Value, ref ev); } 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. /// 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, 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. /// Optional signature shown below the announcement message. public virtual void DispatchGlobalAnnouncement( string message, string? sender = null, bool playSound = true, SoundSpecifier? announcementSound = null, Color? colorOverride = null, string? signature = 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. /// Optional signature shown below 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, string? signature = 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. /// Optional signature shown below the announcement message. public virtual void DispatchStationAnnouncement( EntityUid source, string message, string? sender = null, bool playDefaultSound = true, SoundSpecifier? announcementSound = null, Color? colorOverride = null, string? signature = 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 }