using System.Globalization; using System.Linq; using System.Text; using Content.Server._White.TTS; using Content.Server.Administration.Logs; using Content.Server.Administration.Managers; using Content.Server.Chat.Managers; using Content.Server.GameTicking; using Content.Server.Players.RateLimiting; using Content.Server.Language; using Content.Server.Speech.Components; using Content.Server.Speech.EntitySystems; using Content.Server.Station.Components; using Content.Server.Station.Systems; using Content.Shared.ActionBlocker; using Content.Shared.CCVar; using Content.Shared.Chat; using Content.Shared.Database; using Content.Shared.Ghost; using Content.Shared.Language; using Content.Shared.IdentityManagement; using Content.Shared.Interaction; 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.Whitelist; using Robust.Server.Player; using Robust.Shared.Audio; using Robust.Shared.Audio.Systems; using Robust.Shared.Configuration; using Robust.Shared.Console; using Robust.Shared.Network; using Robust.Shared.Physics; using Robust.Shared.Player; using Robust.Shared.Prototypes; using Robust.Shared.Random; using Robust.Shared.Replays; using Robust.Shared.Utility; using Content.Server.Shuttles.Components; using Robust.Shared.Physics.Components; using Robust.Shared.Physics.Dynamics.Joints; namespace Content.Server.Chat.Systems; // Dear contributor. When I was introducing changes to this system only god and I knew what I was doing. // Now only god knows. Please don't touch this code ever again. If you do have to, increment this counter as a warning for others: // TOTAL_HOURS_WASTED_HERE_EE = 19 // TODO refactor whatever active warzone this class and chatmanager have become /// /// ChatSystem is responsible for in-simulation chat handling, such as whispering, speaking, emoting, etc. /// ChatSystem depends on ChatManager to actually send the messages. /// public sealed partial class ChatSystem : SharedChatSystem { [Dependency] private readonly IReplayRecordingManager _replay = default!; [Dependency] private readonly IConfigurationManager _configurationManager = default!; [Dependency] private readonly IChatManager _chatManager = default!; [Dependency] private readonly IChatSanitizationManager _sanitizer = default!; [Dependency] private readonly IAdminManager _adminManager = default!; [Dependency] private readonly IPlayerManager _playerManager = default!; [Dependency] private readonly IPrototypeManager _prototypeManager = default!; [Dependency] private readonly IRobustRandom _random = default!; [Dependency] private readonly IAdminLogManager _adminLogger = default!; [Dependency] private readonly ActionBlockerSystem _actionBlocker = default!; [Dependency] private readonly StationSystem _stationSystem = default!; [Dependency] private readonly MobStateSystem _mobStateSystem = default!; [Dependency] private readonly SharedAudioSystem _audio = default!; [Dependency] private readonly SharedInteractionSystem _interactionSystem = default!; [Dependency] private readonly ReplacementAccentSystem _wordreplacement = default!; [Dependency] private readonly LanguageSystem _language = default!; [Dependency] private readonly TelepathicChatSystem _telepath = default!; [Dependency] private readonly EntityWhitelistSystem _whitelistSystem = default!; 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 const string DefaultAnnouncementSound = "/Audio/Announcements/announce.ogg"; public const float DefaultObfuscationFactor = 0.2f; // Percentage of symbols in a whispered message that can be seen even by "far" listeners public readonly Color DefaultSpeakColor = Color.White; private bool _loocEnabled = true; private bool _deadLoocEnabled; private bool _critLoocEnabled; private readonly bool _adminLoocEnabled = true; public override void Initialize() { base.Initialize(); CacheEmotes(); Subs.CVar(_configurationManager, CCVars.LoocEnabled, OnLoocEnabledChanged, true); Subs.CVar(_configurationManager, CCVars.DeadLoocEnabled, OnDeadLoocEnabledChanged, true); Subs.CVar(_configurationManager, CCVars.CritLoocEnabled, OnCritLoocEnabledChanged, true); SubscribeLocalEvent(OnGameChange); } private void OnLoocEnabledChanged(bool val) { if (_loocEnabled == val) return; _loocEnabled = val; _chatManager.DispatchServerAnnouncement( Loc.GetString(val ? "chat-manager-looc-chat-enabled-message" : "chat-manager-looc-chat-disabled-message")); } private void OnDeadLoocEnabledChanged(bool val) { if (_deadLoocEnabled == val) return; _deadLoocEnabled = val; _chatManager.DispatchServerAnnouncement( Loc.GetString(val ? "chat-manager-dead-looc-chat-enabled-message" : "chat-manager-dead-looc-chat-disabled-message")); } private void OnCritLoocEnabledChanged(bool val) { if (_critLoocEnabled == val) return; _critLoocEnabled = val; _chatManager.DispatchServerAnnouncement( Loc.GetString(val ? "chat-manager-crit-looc-chat-enabled-message" : "chat-manager-crit-looc-chat-disabled-message")); } private void OnGameChange(GameRunLevelChangedEvent ev) { switch (ev.New) { case GameRunLevel.InRound: if (!_configurationManager.GetCVar(CCVars.OocEnableDuringRound)) _configurationManager.SetCVar(CCVars.OocEnabled, false); break; case GameRunLevel.PostRound: if (!_configurationManager.GetCVar(CCVars.OocEnableDuringRound)) _configurationManager.SetCVar(CCVars.OocEnabled, true); break; } } /// /// 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. public 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) { TrySendInGameICMessage(source, message, desiredType, hideChat ? ChatTransmitRange.HideChat : ChatTransmitRange.Normal, hideLog, shell, player, nameOverride, checkRadioPrefix, ignoreActionBlocker); } /// /// 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... /// /// 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 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, LanguagePrototype? languageOverride = null ) { if (HasComp(source)) { // Ghosts can only send dead chat messages, so we'll forward it to InGame OOC. TrySendInGameOOCMessage(source, message, InGameOOCChatType.Dead, range == ChatTransmitRange.HideChat, shell, player); return; } if (player != null && _chatManager.HandleRateLimit(player) != RateLimitStatus.Allowed) return; // Sus if (player?.AttachedEntity is { Valid: true } entity && source != entity) { return; } if (!CanSendInGame(message, shell, player)) return; ignoreActionBlocker = CheckIgnoreSpeechBlocker(source, ignoreActionBlocker); // this method is a disaster // every second i have to spend working with this code is fucking agony // scientists have to wonder how any of this was merged // coding any game admin feature that involves chat code is pure torture // changing even 10 lines of code feels like waterboarding myself // and i dont feel like vibe checking 50 code paths // so we set this here // todo free me from chat code if (player != null) { _chatManager.EnsurePlayer(player.UserId).AddEntity(GetNetEntity(source)); } if (desiredType == InGameICChatType.Speak && message.StartsWith(LocalPrefix)) { // prevent radios and remove prefix. checkRadioPrefix = false; message = message[1..]; } var language = languageOverride ?? _language.GetLanguage(source); bool shouldCapitalize = (desiredType != InGameICChatType.Emote); bool shouldPunctuate = _configurationManager.GetCVar(CCVars.ChatPunctuation); // Capitalizing the word I only happens in English, so we check language here bool shouldCapitalizeTheWordI = (!CultureInfo.CurrentCulture.IsNeutralCulture && CultureInfo.CurrentCulture.Parent.Name == "en") || (CultureInfo.CurrentCulture.IsNeutralCulture && CultureInfo.CurrentCulture.Name == "en"); message = SanitizeInGameICMessage(source, message, out var emoteStr, shouldCapitalize, shouldPunctuate, shouldCapitalizeTheWordI); // Was there an emote in the message? If so, send it. if (player != null && emoteStr != message && emoteStr != null) { SendEntityEmote(source, emoteStr, range, nameOverride, language, ignoreActionBlocker); } // This can happen if the entire string is sanitized out. if (string.IsNullOrEmpty(message)) return; // This is really terrible. I hate myself for doing this. if (language.SpeechOverride.ChatTypeOverride is { } chatTypeOverride) desiredType = chatTypeOverride; // This message may have a radio prefix, and should then be whispered to the resolved radio channel if (checkRadioPrefix) { if (TryProccessRadioMessage(source, message, out var modMessage, out var channel)) { SendEntityWhisper(source, modMessage, range, channel, nameOverride, language, hideLog, ignoreActionBlocker); return; } } message = FormattedMessage.EscapeText(message); // Otherwise, send whatever type. switch (desiredType) { case InGameICChatType.Speak: SendEntitySpeak(source, message, range, nameOverride, language, hideLog, ignoreActionBlocker); break; case InGameICChatType.Whisper: SendEntityWhisper(source, message, range, null, nameOverride, language, hideLog, ignoreActionBlocker); break; case InGameICChatType.Emote: SendEntityEmote(source, message, range, nameOverride, language, hideLog: hideLog, ignoreActionBlocker: ignoreActionBlocker); break; //Nyano - Summary: case adds the telepathic chat sending ability. case InGameICChatType.Telepathic: _telepath.SendTelepathicChat(source, message, range == ChatTransmitRange.HideChat); break; } } public void TrySendInGameOOCMessage( EntityUid source, string message, InGameOOCChatType type, bool hideChat, IConsoleShell? shell = null, ICommonSession? player = null ) { if (!CanSendInGame(message, shell, player)) return; 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 // in-game IC messages. if (player?.AttachedEntity is not { Valid: true } entity || source != entity) return; message = SanitizeInGameOOCMessage(message); var sendType = type; // If dead player LOOC is disabled, unless you are an aghost, send dead messages to dead chat if (!_adminManager.IsAdmin(player) && !_deadLoocEnabled && (HasComp(source) || _mobStateSystem.IsDead(source))) sendType = InGameOOCChatType.Dead; // If crit player LOOC is disabled, don't send the message at all. if (!_critLoocEnabled && _mobStateSystem.IsHardCritical(source)) return; switch (sendType) { case InGameOOCChatType.Dead: SendDeadChat(source, player, message, hideChat); break; case InGameOOCChatType.Looc: SendLOOC(source, player, message, hideChat); break; } } #region Announcements /// /// Dispatches an announcement to all. /// /// The contents of the message /// The sender (Communications Console in Communications Console Announcement) /// Play the announcement sound /// Optional color for the announcement message public void DispatchGlobalAnnouncement( string message, string sender = "Central Command", bool playSound = true, SoundSpecifier? announcementSound = null, Color? colorOverride = null ) { var wrappedMessage = Loc.GetString("chat-manager-sender-announcement-wrap-message", ("sender", sender), ("message", FormattedMessage.EscapeText(message))); _chatManager.ChatMessageToAll(ChatChannel.Radio, message, wrappedMessage, default, false, true, colorOverride); if (playSound) { _audio.PlayGlobal(announcementSound != null ? announcementSound.ToString() : DefaultAnnouncementSound, Filter.Broadcast(), true, AudioParams.Default.WithVolume(-2f)); } _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Global station announcement from {sender}: {message}"); } /// /// 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 /// Optional color for the announcement message public void DispatchStationAnnouncement( EntityUid source, string message, string sender = "Central Command", bool playDefaultSound = true, SoundSpecifier? announcementSound = null, Color? colorOverride = null) { var wrappedMessage = Loc.GetString("chat-manager-sender-announcement-wrap-message", ("sender", sender), ("message", FormattedMessage.EscapeText(message))); var station = _stationSystem.GetOwningStation(source); if (station == null) { // you can't make a station announcement without a station return; } if (!EntityManager.TryGetComponent(station, out var stationDataComp)) return; var filter = _stationSystem.GetInStation(stationDataComp); _chatManager.ChatMessageToManyFiltered(filter, ChatChannel.Radio, message, wrappedMessage, source, false, true, colorOverride); if (playDefaultSound) { _audio.PlayGlobal(announcementSound != null ? announcementSound.ToString() : DefaultAnnouncementSound, filter, true, AudioParams.Default.WithVolume(-2f)); } _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Station Announcement on {station} from {sender}: {message}"); } #endregion #region Private API private void SendEntitySpeak( EntityUid source, string originalMessage, ChatTransmitRange range, string? nameOverride, LanguagePrototype language, bool hideLog = false, bool ignoreActionBlocker = false ) { if (!_actionBlocker.CanSpeak(source) && !ignoreActionBlocker) return; // The original message var message = TransformSpeech(source, FormattedMessage.RemoveMarkupPermissive(originalMessage), language); if (message.Length == 0) return; var speech = GetSpeechVerb(source, message); // get the entity's apparent name (if no override provided). string name; if (nameOverride != null) { name = nameOverride; } else { var nameEv = new TransformSpeakerNameEvent(source, Name(source)); RaiseLocalEvent(source, nameEv); name = nameEv.VoiceName ?? Name(source); // Check for a speech verb override if (nameEv.SpeechVerb != null && _prototypeManager.TryIndex(nameEv.SpeechVerb, out var proto)) speech = proto; } name = FormattedMessage.EscapeText(name); // The chat message wrapped in a "x says y" string var wrappedMessage = WrapPublicMessage(source, name, message, language: language); // The chat message obfuscated via language obfuscation var obfuscated = SanitizeInGameICMessage(source, _language.ObfuscateSpeech(message, language), out var emoteStr, true, _configurationManager.GetCVar(CCVars.ChatPunctuation), (!CultureInfo.CurrentCulture.IsNeutralCulture && CultureInfo.CurrentCulture.Parent.Name == "en") || (CultureInfo.CurrentCulture.IsNeutralCulture && CultureInfo.CurrentCulture.Name == "en")); // The language-obfuscated message wrapped in a "x says y" string var wrappedObfuscated = WrapPublicMessage(source, name, obfuscated, language: language); SendInVoiceRange(ChatChannel.Local, name, message, wrappedMessage, obfuscated, wrappedObfuscated, source, range, languageOverride: language); var ev = new EntitySpokeEvent(source, message, null, false, language); RaiseLocalEvent(source, ev, true); // To avoid logging any messages sent by entities that are not players, like vendors, cloning, etc. // Also doesn't log if hideLog is true. if (!HasComp(source) || hideLog == true) return; if (originalMessage == message) { if (name != Name(source)) _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Say from {ToPrettyString(source):user} as {name}: {originalMessage}."); else _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Say from {ToPrettyString(source):user}: {originalMessage}."); } else { if (name != Name(source)) _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Say from {ToPrettyString(source):user} as {name}, original: {originalMessage}, transformed: {message}."); else _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Say from {ToPrettyString(source):user}, original: {originalMessage}, transformed: {message}."); } } private void SendEntityWhisper( EntityUid source, string originalMessage, ChatTransmitRange range, RadioChannelPrototype? channel, string? nameOverride, LanguagePrototype language, bool hideLog = false, bool ignoreActionBlocker = false ) { if (!_actionBlocker.CanSpeak(source) && !ignoreActionBlocker) return; var message = TransformSpeech(source, FormattedMessage.RemoveMarkupPermissive(originalMessage), language); if (message.Length == 0) return; // get the entity's name by visual identity (if no override provided). string nameIdentity = FormattedMessage.EscapeText(nameOverride ?? Identity.Name(source, EntityManager)); // get the entity's name by voice (if no override provided). string name; if (nameOverride != null) { name = nameOverride; } else { var nameEv = new TransformSpeakerNameEvent(source, Name(source)); RaiseLocalEvent(source, nameEv); name = nameEv.VoiceName; } name = FormattedMessage.EscapeText(name); var languageObfuscatedMessage = SanitizeInGameICMessage(source, _language.ObfuscateSpeech(message, language), out var emoteStr, true, _configurationManager.GetCVar(CCVars.ChatPunctuation), (!CultureInfo.CurrentCulture.IsNeutralCulture && CultureInfo.CurrentCulture.Parent.Name == "en") || (CultureInfo.CurrentCulture.IsNeutralCulture && CultureInfo.CurrentCulture.Name == "en")); foreach (var (session, data) in GetRecipients(source, Transform(source).GridUid == null ? 0.3f : WhisperMuffledRange)) { if (session.AttachedEntity is not { Valid: true } listener) continue; if (Transform(session.AttachedEntity.Value).GridUid != Transform(source).GridUid && !CheckAttachedGrids(source, session.AttachedEntity.Value)) continue; if (MessageRangeCheck(session, data, range) != MessageRangeCheckResult.Full) continue; // Won't get logged to chat, and ghosts are too far away to see the pop-up, so we just won't send it to them. var canUnderstandLanguage = _language.CanUnderstand(listener, language.ID); // How the entity perceives the message depends on whether it can understand its language var perceivedMessage = canUnderstandLanguage ? message : languageObfuscatedMessage; // Result is the intermediate message derived from the perceived one via obfuscation // Wrapped message is the result wrapped in an "x says y" string string result, wrappedMessage; if (data.Range <= (TryComp(listener, out var modifier) ? modifier.WhisperListeningRange : WhisperClearRange)) // WWDP-Edit { // Scenario 1: the listener can clearly understand the message result = perceivedMessage; wrappedMessage = WrapWhisperMessage(source, "chat-manager-entity-whisper-wrap-message", name, result, language); } else if (_interactionSystem.InRangeUnobstructed(source, listener, WhisperMuffledRange, Shared.Physics.CollisionGroup.Opaque)) { // Scenario 2: if the listener is too far, they only hear fragments of the message result = ObfuscateMessageReadability(perceivedMessage); wrappedMessage = WrapWhisperMessage(source, "chat-manager-entity-whisper-wrap-message", nameIdentity, result, language); } else { // Scenario 3: If listener is too far and has no line of sight, they can't identify the whisperer's identity result = ObfuscateMessageReadability(perceivedMessage); wrappedMessage = WrapWhisperMessage(source, "chat-manager-entity-whisper-unknown-wrap-message", string.Empty, result, language); } _chatManager.ChatMessageToOne(ChatChannel.Whisper, result, wrappedMessage, source, false, session.Channel); } var replayWrap = WrapWhisperMessage(source, "chat-manager-entity-whisper-wrap-message", name, message, language); _replay.RecordServerMessage(new ChatMessage(ChatChannel.Whisper, message, replayWrap, GetNetEntity(source), null, MessageRangeHideChatForReplay(range))); var ev = new EntitySpokeEvent(source, message, channel, true, language); RaiseLocalEvent(source, ev, true); if (!hideLog) if (originalMessage == message) { if (name != Name(source)) _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Whisper from {ToPrettyString(source):user} as {name}: {originalMessage}."); else _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Whisper from {ToPrettyString(source):user}: {originalMessage}."); } else { if (name != Name(source)) _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Whisper from {ToPrettyString(source):user} as {name}, original: {originalMessage}, transformed: {message}."); else _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Whisper from {ToPrettyString(source):user}, original: {originalMessage}, transformed: {message}."); } } private void SendEntityEmote( EntityUid source, string action, ChatTransmitRange range, string? nameOverride, LanguagePrototype language, bool hideLog = false, bool checkEmote = true, bool ignoreActionBlocker = false, NetUserId? author = null ) { if (!_actionBlocker.CanEmote(source) && !ignoreActionBlocker) return; // get the entity's apparent name (if no override provided). var ent = Identity.Entity(source, EntityManager); string name = FormattedMessage.EscapeText(nameOverride ?? Name(ent)); // Emotes use Identity.Name, since it doesn't actually involve your voice at all. var wrappedMessage = Loc.GetString("chat-manager-entity-me-wrap-message", ("entityName", name), ("entity", ent), ("message", FormattedMessage.RemoveMarkupPermissive(action))); if (checkEmote) TryEmoteChatInput(source, action); SendInVoiceRange(ChatChannel.Emotes, name, action, wrappedMessage, obfuscated: "", obfuscatedWrappedMessage: "", source, range, author); if (!hideLog) if (name != Name(source)) _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Emote from {ToPrettyString(source):user} as {name}: {action}"); else _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Emote from {ToPrettyString(source):user}: {action}"); } // ReSharper disable once InconsistentNaming private void SendLOOC(EntityUid source, ICommonSession player, string message, bool hideChat) { var name = FormattedMessage.EscapeText(Identity.Name(source, EntityManager)); if (_adminManager.IsAdmin(player)) { if (!_adminLoocEnabled) return; } else if (!_loocEnabled) return; // If crit player LOOC is disabled, don't send the message at all. if (!_critLoocEnabled && _mobStateSystem.IsHardCritical(source)) return; var wrappedMessage = Loc.GetString("chat-manager-entity-looc-wrap-message", ("entityName", name), ("message", FormattedMessage.EscapeText(message))); SendInVoiceRange(ChatChannel.LOOC, name, message, wrappedMessage, obfuscated: string.Empty, obfuscatedWrappedMessage: string.Empty, // will be skipped anyway source, hideChat ? ChatTransmitRange.HideChat : ChatTransmitRange.Normal, player.UserId, languageOverride: LanguageSystem.Universal); _adminLogger.Add(LogType.Chat, LogImpact.Low, $"LOOC from {player:Player}: {message}"); } private void SendDeadChat(EntityUid source, ICommonSession player, string message, bool hideChat) { var clients = GetDeadChatClients(); var playerName = Name(source); string wrappedMessage; if (_adminManager.IsAdmin(player)) { wrappedMessage = Loc.GetString("chat-manager-send-admin-dead-chat-wrap-message", ("adminChannelName", Loc.GetString("chat-manager-admin-channel-name")), ("userName", player.Channel.UserName), ("message", FormattedMessage.EscapeText(message))); _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Admin dead chat from {player:Player}: {message}"); } else { wrappedMessage = Loc.GetString("chat-manager-send-dead-chat-wrap-message", ("deadChannelName", Loc.GetString("chat-manager-dead-channel-name")), ("playerName", (playerName)), ("message", FormattedMessage.EscapeText(message))); _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Dead chat from {player:Player}: {message}"); } _chatManager.ChatMessageToMany(ChatChannel.Dead, message, wrappedMessage, source, hideChat, true, clients.ToList(), author: player.UserId); } #endregion #region Utility private enum MessageRangeCheckResult { Disallowed, HideChat, Full } /// /// If hideChat should be set as far as replays are concerned. /// private bool MessageRangeHideChatForReplay(ChatTransmitRange range) { return range == ChatTransmitRange.HideChat; } /// /// Checks if a target as returned from GetRecipients should receive the message. /// Keep in mind data.Range is -1 for out of range observers. /// private MessageRangeCheckResult MessageRangeCheck(ICommonSession session, ICChatRecipientData data, ChatTransmitRange range) { var initialResult = MessageRangeCheckResult.Full; switch (range) { case ChatTransmitRange.Normal: initialResult = MessageRangeCheckResult.Full; break; case ChatTransmitRange.GhostRangeLimit: initialResult = (data.Observer && data.Range < 0 && !_adminManager.IsAdmin(session)) ? MessageRangeCheckResult.HideChat : MessageRangeCheckResult.Full; break; case ChatTransmitRange.HideChat: initialResult = MessageRangeCheckResult.HideChat; break; case ChatTransmitRange.NoGhosts: initialResult = (data.Observer && !_adminManager.IsAdmin(session)) ? MessageRangeCheckResult.Disallowed : MessageRangeCheckResult.Full; break; } var insistHideChat = data.HideChatOverride ?? false; var insistNoHideChat = !(data.HideChatOverride ?? true); if (insistHideChat && initialResult == MessageRangeCheckResult.Full) return MessageRangeCheckResult.HideChat; if (insistNoHideChat && initialResult == MessageRangeCheckResult.HideChat) return MessageRangeCheckResult.Full; return initialResult; } /// /// Sends a chat message to the given players in range of the source entity. /// private void SendInVoiceRange(ChatChannel channel, string name, string message, string wrappedMessage, string obfuscated, string obfuscatedWrappedMessage, EntityUid source, ChatTransmitRange range, NetUserId? author = null, LanguagePrototype? languageOverride = null) { var language = languageOverride ?? _language.GetLanguage(source); // WD EDIT START var orgMsg = new HashSet(); var obsMsg = new HashSet(); // WD EDIT END foreach (var (session, data) in GetRecipients(source, Transform(source).GridUid == null ? 0.3f : VoiceRange)) { if (session.AttachedEntity != null && Transform(session.AttachedEntity.Value).GridUid != Transform(source).GridUid && !CheckAttachedGrids(source, session.AttachedEntity.Value)) continue; var entRange = MessageRangeCheck(session, data, range); if (entRange == MessageRangeCheckResult.Disallowed) continue; var entHideChat = entRange == MessageRangeCheckResult.HideChat; if (session.AttachedEntity is not { Valid: true } playerEntity) continue; EntityUid listener = session.AttachedEntity.Value; // If the channel does not support languages, or the entity can understand the message, send the original message, otherwise send the obfuscated version if (channel == ChatChannel.LOOC || channel == ChatChannel.Emotes || _language.CanUnderstand(listener, language.ID)) { orgMsg.Add(session); // WD EDIT _chatManager.ChatMessageToOne(channel, message, wrappedMessage, source, entHideChat, session.Channel, author: author); } else { obsMsg.Add(session); // WD EDIT _chatManager.ChatMessageToOne(channel, obfuscated, obfuscatedWrappedMessage, source, entHideChat, session.Channel, author: author); } } _replay.RecordServerMessage(new ChatMessage(channel, message, wrappedMessage, GetNetEntity(source), null, MessageRangeHideChatForReplay(range))); // WD EDIT START if ((orgMsg.Count > 0 || obsMsg.Count > 0) && (channel & ChatChannel.Local) != 0) { RaiseLocalEvent(source, new EntitySpokeLanguageEvent( Filter.Empty().AddPlayers(orgMsg), Filter.Empty().AddPlayers(obsMsg), source, message, wrappedMessage, null, false, obfuscated) ); } // WD EDIT END } /// /// Returns true if the given player is 'allowed' to send the given message, false otherwise. /// private bool CanSendInGame(string message, IConsoleShell? shell = null, ICommonSession? player = null) { // Non-players don't have to worry about these restrictions. if (player == null) return true; var mindContainerComponent = player.ContentData()?.Mind; if (mindContainerComponent == null) { shell?.WriteError("You don't have a mind!"); return false; } if (player.AttachedEntity is not { Valid: true } _) { shell?.WriteError("You don't have an entity!"); return false; } return !_chatManager.MessageCharacterLimit(player, message); } // ReSharper disable once InconsistentNaming private string SanitizeInGameICMessage(EntityUid source, string message, out string? emoteStr, bool capitalize = true, bool punctuate = false, bool capitalizeTheWordI = true) { var newMessage = SanitizeMessageReplaceWords(message.Trim()); GetRadioKeycodePrefix(source, newMessage, out newMessage, out var prefix); _sanitizer.TrySanitizeOutSmilies(newMessage, source, out newMessage, out emoteStr); if (capitalize) newMessage = SanitizeMessageCapital(newMessage); if (capitalizeTheWordI) newMessage = SanitizeMessageCapitalizeTheWordI(newMessage, "i"); if (punctuate) newMessage = SanitizeMessagePeriod(newMessage); return prefix + newMessage; } private string SanitizeInGameOOCMessage(string message) { var newMessage = message.Trim(); newMessage = FormattedMessage.EscapeText(newMessage); return newMessage; } public string TransformSpeech(EntityUid sender, string message, LanguagePrototype language) { if (!language.SpeechOverride.RequireSpeech) return message; // Do not apply speech accents if there's no speech involved. var ev = new TransformSpeechEvent(sender, message); RaiseLocalEvent(ev); return ev.Message; } public bool CheckIgnoreSpeechBlocker(EntityUid sender, bool ignoreBlocker) { if (ignoreBlocker) return ignoreBlocker; var ev = new CheckIgnoreSpeechBlockerEvent(sender, ignoreBlocker); RaiseLocalEvent(sender, ev, true); return ev.IgnoreBlocker; } private IEnumerable GetDeadChatClients() { return Filter.Empty() .AddWhereAttachedEntity(HasComp) .Recipients .Union(_adminManager.ActiveAdmins) .Select(p => p.Channel); } private string SanitizeMessagePeriod(string message) { if (string.IsNullOrEmpty(message)) return message; // Adds a period if the last character is a letter. if (char.IsLetter(message[^1])) message += "."; return message; } [ValidatePrototypeId] public const string ChatSanitize_Accent = "chatsanitize"; public string SanitizeMessageReplaceWords(string message) { if (string.IsNullOrEmpty(message)) return message; var msg = message; msg = _wordreplacement.ApplyReplacements(msg, ChatSanitize_Accent); return msg; } /// /// Wraps a message sent by the specified entity into an "x says y" string. /// public string WrapPublicMessage(EntityUid source, string name, string message, LanguagePrototype? language = null) { var wrapId = GetSpeechVerb(source, message).Bold ? "chat-manager-entity-say-bold-wrap-message" : "chat-manager-entity-say-wrap-message"; return WrapMessage(wrapId, InGameICChatType.Speak, source, name, message, language); } /// /// Wraps a message whispered by the specified entity into an "x whispers y" string. /// public string WrapWhisperMessage(EntityUid source, LocId defaultWrap, string entityName, string message, LanguagePrototype? language = null) { return WrapMessage(defaultWrap, InGameICChatType.Whisper, source, entityName, message, language); } /// /// Wraps a message sent by the specified entity into the specified wrap string. /// public string WrapMessage(LocId wrapId, InGameICChatType chatType, EntityUid source, string entityName, string message, LanguagePrototype? language) { language ??= _language.GetLanguage(source); if (language.SpeechOverride.MessageWrapOverrides.TryGetValue(chatType, out var wrapOverride)) wrapId = wrapOverride; var speech = GetSpeechVerb(source, message); var verbId = language.SpeechOverride.SpeechVerbOverrides is { } verbsOverride ? _random.Pick(verbsOverride).ToString() : _random.Pick(speech.SpeechVerbStrings); var color = DefaultSpeakColor; if (language.SpeechOverride.Color is { } colorOverride) color = Color.InterpolateBetween(color, colorOverride, colorOverride.A); var languageDisplay = language.IsVisibleLanguage ? Loc.GetString("chat-manager-language-prefix", ("language", language.ChatName)) : ""; return Loc.GetString(wrapId, ("color", color), ("entityName", entityName), ("verb", Loc.GetString(verbId)), ("fontType", language.SpeechOverride.FontId ?? speech.FontId), ("fontSize", language.SpeechOverride.FontSize ?? speech.FontSize), ("message", message), ("language", languageDisplay)); } /// /// Returns list of players and ranges for all players withing some range. Also returns observers with a range of -1. /// private Dictionary GetRecipients(EntityUid source, float voiceGetRange) { // TODO proper speech occlusion var recipients = new Dictionary(); var ghostHearing = GetEntityQuery(); var xforms = GetEntityQuery(); var transformSource = xforms.GetComponent(source); var sourceMapId = transformSource.MapID; var sourceCoords = transformSource.Coordinates; foreach (var player in _playerManager.Sessions) { if (player.AttachedEntity is not { Valid: true } playerEntity) continue; var transformEntity = xforms.GetComponent(playerEntity); if (transformEntity.MapID != sourceMapId) continue; var observer = ghostHearing.HasComponent(playerEntity); // even if they are a ghost hearer, in some situations we still need the range if (sourceCoords.TryDistance(EntityManager, transformEntity.Coordinates, out var distance) && distance < voiceGetRange) { recipients.Add(player, new ICChatRecipientData(distance, observer)); continue; } if (observer) recipients.Add(player, new ICChatRecipientData(-1, true)); } RaiseLocalEvent(new ExpandICChatRecipientsEvent(source, voiceGetRange, recipients)); return recipients; } public readonly record struct ICChatRecipientData(float Range, bool Observer, bool? HideChatOverride = null) { } public string ObfuscateMessageReadability(string message, float chance = DefaultObfuscationFactor) { var modifiedMessage = new StringBuilder(message); for (var i = 0; i < message.Length; i++) { if (char.IsWhiteSpace((modifiedMessage[i]))) { continue; } if (_random.Prob(1 - chance)) { modifiedMessage[i] = '~'; } } return modifiedMessage.ToString(); } public string BuildGibberishString(IReadOnlyList charOptions, int length) { var sb = new StringBuilder(); for (var i = 0; i < length; i++) { sb.Append(_random.Pick(charOptions)); } return sb.ToString(); } private bool CheckAttachedGrids(EntityUid source, EntityUid receiver) { if (!TryComp(Transform(source).GridUid, out var sourceJoints) || !TryComp(Transform(receiver).GridUid, out var receiverJoints)) return false; foreach (var (id, _) in sourceJoints.GetJoints) if (receiverJoints.GetJoints.ContainsKey(id)) return true; return false; } #endregion } /// /// This event is raised before chat messages are sent out to clients. This enables some systems to send the chat /// messages to otherwise out-of view entities (e.g. for multiple viewports from cameras). /// public record ExpandICChatRecipientsEvent(EntityUid Source, float VoiceRange, Dictionary Recipients) { } /// /// Raised broadcast in order to transform speech.transmit /// public sealed class TransformSpeechEvent : EntityEventArgs { public EntityUid Sender; public string Message; public TransformSpeechEvent(EntityUid sender, string message) { Sender = sender; Message = message; } } public sealed class CheckIgnoreSpeechBlockerEvent : EntityEventArgs { public EntityUid Sender; public bool IgnoreBlocker; public CheckIgnoreSpeechBlockerEvent(EntityUid sender, bool ignoreBlocker) { Sender = sender; IgnoreBlocker = ignoreBlocker; } } /// /// Raised on an entity when it speaks, either through 'say' or 'whisper'. /// public sealed class EntitySpokeEvent : EntityEventArgs { public readonly EntityUid Source; public readonly string Message; public readonly bool IsWhisper; public readonly LanguagePrototype Language; /// /// 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 EntitySpokeEvent(EntityUid source, string message, RadioChannelPrototype? channel, bool isWhisper, LanguagePrototype language) { Source = source; Message = message; Channel = channel; IsWhisper = isWhisper; Language = language; } }