using System.Linq; using System.Text; using Content.Shared.Chat; using Content.Shared.Ghost; using Content.Shared.Players; using Content.Shared.Speech.Prototypes; 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.Server.Chat.Systems; public sealed partial class ChatSystem { 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 message, string wrappedMessage, EntityUid source, ChatTransmitRange range, NetUserId? author = null) { foreach (var (session, data) in GetRecipients(source, VoiceRange)) { var entRange = MessageRangeCheck(session, data, range); if (entRange == MessageRangeCheckResult.Disallowed) continue; var entHideChat = entRange == MessageRangeCheckResult.HideChat; _chatManager.ChatMessageToOne(channel, message, wrappedMessage, source, entHideChat, session.Channel, author: author); } _replay.RecordServerMessage(new ChatMessage(channel, message, wrappedMessage, GetNetEntity(source), null, MessageRangeHideChatForReplay(range))); } /// /// 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); // Sanitize it first as it might change the word order _sanitizer.TrySanitizeEmoteShorthands(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) { var ev = new TransformSpeechEvent(sender, message); RaiseLocalEvent(sender, ev, true); 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; } public static readonly ProtoId 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; } /// /// 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 transformSource = Transform(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 = Transform(playerEntity); if (transformEntity.MapID != sourceMapId) continue; var observer = _ghostHearingQuery.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) { } private string ObfuscateMessageReadability(string message, float chance) { 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(); } }