Files
tbd-station-14/Content.Server/Chat/Systems/ChatSystem.cs
Pieter-Jan Briers 0c97520276 Fix usages of TryIndex() (#39124)
* Fix usages of TryIndex()

Most usages of TryIndex() were using it incorrectly. Checking whether prototype IDs specified in prototypes actually existed before using them. This is not appropriate as it's just hiding bugs that should be getting caught by the YAML linter and other tools. (#39115)

This then resulted in TryIndex() getting modified to log errors (94f98073b0), which is incorrect as it causes false-positive errors in proper uses of the API: external data validation. (#39098)

This commit goes through and checks every call site of TryIndex() to see whether they were correct. Most call sites were replaced with the new Resolve(), which is suitable for these "defensive programming" use cases.

Fixes #39115

Breaking change: while doing this I noticed IdCardComponent and related systems were erroneously using ProtoId<AccessLevelPrototype> for job prototypes. This has been corrected.

* fix tests

---------

Co-authored-by: slarticodefast <161409025+slarticodefast@users.noreply.github.com>
2025-09-09 18:17:56 +02:00

993 lines
39 KiB
C#

using System.Globalization;
using System.Linq;
using System.Text;
using Content.Server.Administration.Logs;
using Content.Server.Administration.Managers;
using Content.Server.Chat.Managers;
using Content.Server.GameTicking;
using Content.Server.Speech.EntitySystems;
using Content.Server.Speech.Prototypes;
using Content.Server.Station.Systems;
using Content.Shared.ActionBlocker;
using Content.Shared.Administration;
using Content.Shared.CCVar;
using Content.Shared.Chat;
using Content.Shared.Database;
using Content.Shared.Examine;
using Content.Shared.Ghost;
using Content.Shared.IdentityManagement;
using Content.Shared.Mobs.Systems;
using Content.Shared.Players;
using Content.Shared.Players.RateLimiting;
using Content.Shared.Radio;
using Content.Shared.Station.Components;
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.Player;
using Robust.Shared.Prototypes;
using Robust.Shared.Random;
using Robust.Shared.Replays;
using Robust.Shared.Utility;
namespace Content.Server.Chat.Systems;
// TODO refactor whatever active warzone this class and chatmanager have become
/// <summary>
/// ChatSystem is responsible for in-simulation chat handling, such as whispering, speaking, emoting, etc.
/// ChatSystem depends on ChatManager to actually send the messages.
/// </summary>
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 ReplacementAccentSystem _wordreplacement = default!;
[Dependency] private readonly EntityWhitelistSystem _whitelistSystem = default!;
[Dependency] private readonly ExamineSystemShared _examineSystem = default!;
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<GameRunLevelChangedEvent>(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:
case GameRunLevel.PreRoundLobby:
if (!_configurationManager.GetCVar(CCVars.OocEnableDuringRound))
_configurationManager.SetCVar(CCVars.OocEnabled, true);
break;
}
}
/// <summary>
/// Sends an in-character chat message to relevant clients.
/// </summary>
/// <param name="source">The entity that is speaking</param>
/// <param name="message">The message being spoken or emoted</param>
/// <param name="desiredType">The chat type</param>
/// <param name="hideChat">Whether or not this message should appear in the chat window</param>
/// <param name="hideLog">Whether or not this message should appear in the adminlog window</param>
/// <param name="shell"></param>
/// <param name="player">The player doing the speaking</param>
/// <param name="nameOverride">The name to use for the speaking entity. Usually this should just be modified via <see cref="TransformSpeakerNameEvent"/>. If this is set, the event will not get raised.</param>
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);
}
/// <summary>
/// Sends an in-character chat message to relevant clients.
/// </summary>
/// <param name="source">The entity that is speaking</param>
/// <param name="message">The message being spoken or emoted</param>
/// <param name="desiredType">The chat type</param>
/// <param name="range">Conceptual range of transmission, if it shows in the chat window, if it shows to far-away ghosts or ghosts at all...</param>
/// <param name="shell"></param>
/// <param name="player">The player doing the speaking</param>
/// <param name="nameOverride">The name to use for the speaking entity. Usually this should just be modified via <see cref="TransformSpeakerNameEvent"/>. If this is set, the event will not get raised.</param>
/// <param name="ignoreActionBlocker">If set to true, action blocker will not be considered for whether an entity can send this message.</param>
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
)
{
if (HasComp<GhostComponent>(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..];
}
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, ignoreActionBlocker);
}
// This can happen if the entire string is sanitized out.
if (string.IsNullOrEmpty(message))
return;
// 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, hideLog, ignoreActionBlocker);
return;
}
}
// Otherwise, send whatever type.
switch (desiredType)
{
case InGameICChatType.Speak:
SendEntitySpeak(source, message, range, nameOverride, hideLog, ignoreActionBlocker);
break;
case InGameICChatType.Whisper:
SendEntityWhisper(source, message, range, null, nameOverride, hideLog, ignoreActionBlocker);
break;
case InGameICChatType.Emote:
SendEntityEmote(source, message, range, nameOverride, hideLog: hideLog, ignoreActionBlocker: ignoreActionBlocker);
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 admin with Moderator perms, send dead messages to dead chat
if ((_adminManager.IsAdmin(player) && _adminManager.HasAdminFlag(player, AdminFlags.Moderator)) // Override if admin
|| _deadLoocEnabled
|| (!HasComp<GhostComponent>(source) && !_mobStateSystem.IsDead(source))) // Check that player is not dead
{
}
else
sendType = InGameOOCChatType.Dead;
// If crit player LOOC is disabled, don't send the message at all.
if (!_critLoocEnabled && _mobStateSystem.IsCritical(source))
return;
switch (sendType)
{
case InGameOOCChatType.Dead:
SendDeadChat(source, player, message, hideChat);
break;
case InGameOOCChatType.Looc:
SendLOOC(source, player, message, hideChat);
break;
}
}
#region Announcements
/// <summary>
/// Dispatches an announcement to all.
/// </summary>
/// <param name="message">The contents of the message</param>
/// <param name="sender">The sender (Communications Console in Communications Console Announcement)</param>
/// <param name="playSound">Play the announcement sound</param>
/// <param name="colorOverride">Optional color for the announcement message</param>
public void DispatchGlobalAnnouncement(
string message,
string? sender = null,
bool playSound = true,
SoundSpecifier? announcementSound = null,
Color? colorOverride = null
)
{
sender ??= Loc.GetString("chat-manager-sender-announcement");
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 ?? DefaultAnnouncementSound, Filter.Broadcast(), true, AudioParams.Default.WithVolume(-2f));
}
_adminLogger.Add(LogType.Chat, LogImpact.Low, $"Global station announcement from {sender}: {message}");
}
/// <summary>
/// Dispatches an announcement to players selected by filter.
/// </summary>
/// <param name="filter">Filter to select players who will recieve the announcement</param>
/// <param name="message">The contents of the message</param>
/// <param name="source">The entity making the announcement (used to determine the station)</param>
/// <param name="sender">The sender (Communications Console in Communications Console Announcement)</param>
/// <param name="playDefaultSound">Play the announcement sound</param>
/// <param name="announcementSound">Sound to play</param>
/// <param name="colorOverride">Optional color for the announcement message</param>
public void DispatchFilteredAnnouncement(
Filter filter,
string message,
EntityUid? source = null,
string? sender = null,
bool playSound = true,
SoundSpecifier? announcementSound = null,
Color? colorOverride = null)
{
sender ??= Loc.GetString("chat-manager-sender-announcement");
var wrappedMessage = Loc.GetString("chat-manager-sender-announcement-wrap-message", ("sender", sender), ("message", FormattedMessage.EscapeText(message)));
_chatManager.ChatMessageToManyFiltered(filter, ChatChannel.Radio, message, wrappedMessage, source ?? default, false, true, colorOverride);
if (playSound)
{
_audio.PlayGlobal(announcementSound ?? DefaultAnnouncementSound, filter, true, AudioParams.Default.WithVolume(-2f));
}
_adminLogger.Add(LogType.Chat, LogImpact.Low, $"Station Announcement from {sender}: {message}");
}
/// <summary>
/// Dispatches an announcement on a specific station
/// </summary>
/// <param name="source">The entity making the announcement (used to determine the station)</param>
/// <param name="message">The contents of the message</param>
/// <param name="sender">The sender (Communications Console in Communications Console Announcement)</param>
/// <param name="playDefaultSound">Play the announcement sound</param>
/// <param name="colorOverride">Optional color for the announcement message</param>
public void DispatchStationAnnouncement(
EntityUid source,
string message,
string? sender = null,
bool playDefaultSound = true,
SoundSpecifier? announcementSound = null,
Color? colorOverride = null)
{
sender ??= Loc.GetString("chat-manager-sender-announcement");
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 (!TryComp<StationDataComponent>(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 ?? 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,
bool hideLog = false,
bool ignoreActionBlocker = false
)
{
if (!_actionBlocker.CanSpeak(source) && !ignoreActionBlocker)
return;
var message = TransformSpeech(source, originalMessage);
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;
// Check for a speech verb override
if (nameEv.SpeechVerb != null && _prototypeManager.Resolve(nameEv.SpeechVerb, out var proto))
speech = proto;
}
name = FormattedMessage.EscapeText(name);
var wrappedMessage = Loc.GetString(speech.Bold ? "chat-manager-entity-say-bold-wrap-message" : "chat-manager-entity-say-wrap-message",
("entityName", name),
("verb", Loc.GetString(_random.Pick(speech.SpeechVerbStrings))),
("fontType", speech.FontId),
("fontSize", speech.FontSize),
("message", FormattedMessage.EscapeText(message)));
SendInVoiceRange(ChatChannel.Local, message, wrappedMessage, source, range);
var ev = new EntitySpokeEvent(source, message, null, null);
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<ActorComponent>(source) || hideLog)
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,
bool hideLog = false,
bool ignoreActionBlocker = false
)
{
if (!_actionBlocker.CanSpeak(source) && !ignoreActionBlocker)
return;
var message = TransformSpeech(source, FormattedMessage.RemoveMarkupOrThrow(originalMessage));
if (message.Length == 0)
return;
var obfuscatedMessage = ObfuscateMessageReadability(message, 0.2f);
// 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 wrappedMessage = Loc.GetString("chat-manager-entity-whisper-wrap-message",
("entityName", name), ("message", FormattedMessage.EscapeText(message)));
var wrappedobfuscatedMessage = Loc.GetString("chat-manager-entity-whisper-wrap-message",
("entityName", nameIdentity), ("message", FormattedMessage.EscapeText(obfuscatedMessage)));
var wrappedUnknownMessage = Loc.GetString("chat-manager-entity-whisper-unknown-wrap-message",
("message", FormattedMessage.EscapeText(obfuscatedMessage)));
foreach (var (session, data) in GetRecipients(source, WhisperMuffledRange))
{
EntityUid listener;
if (session.AttachedEntity is not { Valid: true } playerEntity)
continue;
listener = session.AttachedEntity.Value;
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.
if (data.Range <= WhisperClearRange || data.Observer)
_chatManager.ChatMessageToOne(ChatChannel.Whisper, message, wrappedMessage, source, false, session.Channel);
//If listener is too far, they only hear fragments of the message
else if (_examineSystem.InRangeUnOccluded(source, listener, WhisperMuffledRange))
_chatManager.ChatMessageToOne(ChatChannel.Whisper, obfuscatedMessage, wrappedobfuscatedMessage, source, false, session.Channel);
//If listener is too far and has no line of sight, they can't identify the whisperer's identity
else
_chatManager.ChatMessageToOne(ChatChannel.Whisper, obfuscatedMessage, wrappedUnknownMessage, source, false, session.Channel);
}
_replay.RecordServerMessage(new ChatMessage(ChatChannel.Whisper, message, wrappedMessage, GetNetEntity(source), null, MessageRangeHideChatForReplay(range)));
var ev = new EntitySpokeEvent(source, message, channel, obfuscatedMessage);
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,
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.RemoveMarkupOrThrow(action)));
if (checkEmote &&
!TryEmoteChatInput(source, action))
return;
SendInVoiceRange(ChatChannel.Emotes, action, wrappedMessage, 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.IsCritical(source))
return;
var wrappedMessage = Loc.GetString("chat-manager-entity-looc-wrap-message",
("entityName", name),
("message", FormattedMessage.EscapeText(message)));
SendInVoiceRange(ChatChannel.LOOC, message, wrappedMessage, source, hideChat ? ChatTransmitRange.HideChat : ChatTransmitRange.Normal, player.UserId);
_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
}
/// <summary>
/// If hideChat should be set as far as replays are concerned.
/// </summary>
private bool MessageRangeHideChatForReplay(ChatTransmitRange range)
{
return range == ChatTransmitRange.HideChat;
}
/// <summary>
/// Checks if a target as returned from GetRecipients should receive the message.
/// Keep in mind data.Range is -1 for out of range observers.
/// </summary>
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;
}
/// <summary>
/// Sends a chat message to the given players in range of the source entity.
/// </summary>
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)));
}
/// <summary>
/// Returns true if the given player is 'allowed' to send the given message, false otherwise.
/// </summary>
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(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<INetChannel> GetDeadChatClients()
{
return Filter.Empty()
.AddWhereAttachedEntity(HasComp<GhostComponent>)
.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<ReplacementAccentPrototype> 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;
}
/// <summary>
/// Returns list of players and ranges for all players withing some range. Also returns observers with a range of -1.
/// </summary>
private Dictionary<ICommonSession, ICChatRecipientData> GetRecipients(EntityUid source, float voiceGetRange)
{
// TODO proper speech occlusion
var recipients = new Dictionary<ICommonSession, ICChatRecipientData>();
var ghostHearing = GetEntityQuery<GhostHearingComponent>();
var xforms = GetEntityQuery<TransformComponent>();
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)
{
}
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<char> charOptions, int length)
{
var sb = new StringBuilder();
for (var i = 0; i < length; i++)
{
sb.Append(_random.Pick(charOptions));
}
return sb.ToString();
}
#endregion
}
/// <summary>
/// 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).
/// </summary>
public record ExpandICChatRecipientsEvent(EntityUid Source, float VoiceRange, Dictionary<ICommonSession, ChatSystem.ICChatRecipientData> Recipients)
{
}
/// <summary>
/// Raised broadcast in order to transform speech.transmit
/// </summary>
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;
}
}
/// <summary>
/// Raised on an entity when it speaks, either through 'say' or 'whisper'.
/// </summary>
public sealed class EntitySpokeEvent : EntityEventArgs
{
public readonly EntityUid Source;
public readonly string Message;
public readonly string? ObfuscatedMessage; // not null if this was a whisper
/// <summary>
/// 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.
/// </summary>
public RadioChannelPrototype? Channel;
public EntitySpokeEvent(EntityUid source, string message, RadioChannelPrototype? channel, string? obfuscatedMessage)
{
Source = source;
Message = message;
Channel = channel;
ObfuscatedMessage = obfuscatedMessage;
}
}
/// <summary>
/// InGame IC chat is for chat that is specifically ingame (not lobby) but is also in character, i.e. speaking.
/// </summary>
// ReSharper disable once InconsistentNaming
public enum InGameICChatType : byte
{
Speak,
Emote,
Whisper
}
/// <summary>
/// InGame OOC chat is for chat that is specifically ingame (not lobby) but is OOC, like deadchat or LOOC.
/// </summary>
public enum InGameOOCChatType : byte
{
Looc,
Dead
}
/// <summary>
/// Controls transmission of chat.
/// </summary>
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
}