ChatSystem.cs 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997
  1. using System.Globalization;
  2. using System.Linq;
  3. using System.Text;
  4. using Content.Server.Administration.Logs;
  5. using Content.Server.Administration.Managers;
  6. using Content.Server.Chat.Managers;
  7. using Content.Server.GameTicking;
  8. using Content.Server.Players.RateLimiting;
  9. using Content.Server.Speech.Prototypes;
  10. using Content.Server.Speech.EntitySystems;
  11. using Content.Server.Station.Components;
  12. using Content.Server.Station.Systems;
  13. using Content.Shared.ActionBlocker;
  14. using Content.Shared.Administration;
  15. using Content.Shared.CCVar;
  16. using Content.Shared.Chat;
  17. using Content.Shared.Database;
  18. using Content.Shared.Examine;
  19. using Content.Shared.Ghost;
  20. using Content.Shared.IdentityManagement;
  21. using Content.Shared.Mobs.Systems;
  22. using Content.Shared.Players;
  23. using Content.Shared.Players.RateLimiting;
  24. using Content.Shared.Radio;
  25. using Content.Shared.Whitelist;
  26. using Robust.Server.Player;
  27. using Robust.Shared.Audio;
  28. using Robust.Shared.Audio.Systems;
  29. using Robust.Shared.Configuration;
  30. using Robust.Shared.Console;
  31. using Robust.Shared.Network;
  32. using Robust.Shared.Player;
  33. using Robust.Shared.Prototypes;
  34. using Robust.Shared.Random;
  35. using Robust.Shared.Replays;
  36. using Robust.Shared.Utility;
  37. namespace Content.Server.Chat.Systems;
  38. // TODO refactor whatever active warzone this class and chatmanager have become
  39. /// <summary>
  40. /// ChatSystem is responsible for in-simulation chat handling, such as whispering, speaking, emoting, etc.
  41. /// ChatSystem depends on ChatManager to actually send the messages.
  42. /// </summary>
  43. public sealed partial class ChatSystem : SharedChatSystem
  44. {
  45. [Dependency] private readonly IReplayRecordingManager _replay = default!;
  46. [Dependency] private readonly IConfigurationManager _configurationManager = default!;
  47. [Dependency] private readonly IChatManager _chatManager = default!;
  48. [Dependency] private readonly IChatSanitizationManager _sanitizer = default!;
  49. [Dependency] private readonly IAdminManager _adminManager = default!;
  50. [Dependency] private readonly IPlayerManager _playerManager = default!;
  51. [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
  52. [Dependency] private readonly IRobustRandom _random = default!;
  53. [Dependency] private readonly IAdminLogManager _adminLogger = default!;
  54. [Dependency] private readonly ActionBlockerSystem _actionBlocker = default!;
  55. [Dependency] private readonly StationSystem _stationSystem = default!;
  56. [Dependency] private readonly MobStateSystem _mobStateSystem = default!;
  57. [Dependency] private readonly SharedAudioSystem _audio = default!;
  58. [Dependency] private readonly ReplacementAccentSystem _wordreplacement = default!;
  59. [Dependency] private readonly EntityWhitelistSystem _whitelistSystem = default!;
  60. [Dependency] private readonly ExamineSystemShared _examineSystem = default!;
  61. public const int VoiceRange = 10; // how far voice goes in world units
  62. public const int WhisperClearRange = 2; // how far whisper goes while still being understandable, in world units
  63. public const int WhisperMuffledRange = 5; // how far whisper goes at all, in world units
  64. public const string DefaultAnnouncementSound = "/Audio/Announcements/announce.ogg";
  65. private bool _loocEnabled = true;
  66. private bool _deadLoocEnabled;
  67. private bool _critLoocEnabled;
  68. private readonly bool _adminLoocEnabled = true;
  69. public override void Initialize()
  70. {
  71. base.Initialize();
  72. CacheEmotes();
  73. Subs.CVar(_configurationManager, CCVars.LoocEnabled, OnLoocEnabledChanged, true);
  74. Subs.CVar(_configurationManager, CCVars.DeadLoocEnabled, OnDeadLoocEnabledChanged, true);
  75. Subs.CVar(_configurationManager, CCVars.CritLoocEnabled, OnCritLoocEnabledChanged, true);
  76. SubscribeLocalEvent<GameRunLevelChangedEvent>(OnGameChange);
  77. }
  78. private void OnLoocEnabledChanged(bool val)
  79. {
  80. if (_loocEnabled == val) return;
  81. _loocEnabled = val;
  82. _chatManager.DispatchServerAnnouncement(
  83. Loc.GetString(val ? "chat-manager-looc-chat-enabled-message" : "chat-manager-looc-chat-disabled-message"));
  84. }
  85. private void OnDeadLoocEnabledChanged(bool val)
  86. {
  87. if (_deadLoocEnabled == val) return;
  88. _deadLoocEnabled = val;
  89. _chatManager.DispatchServerAnnouncement(
  90. Loc.GetString(val ? "chat-manager-dead-looc-chat-enabled-message" : "chat-manager-dead-looc-chat-disabled-message"));
  91. }
  92. private void OnCritLoocEnabledChanged(bool val)
  93. {
  94. if (_critLoocEnabled == val)
  95. return;
  96. _critLoocEnabled = val;
  97. _chatManager.DispatchServerAnnouncement(
  98. Loc.GetString(val ? "chat-manager-crit-looc-chat-enabled-message" : "chat-manager-crit-looc-chat-disabled-message"));
  99. }
  100. private void OnGameChange(GameRunLevelChangedEvent ev)
  101. {
  102. switch (ev.New)
  103. {
  104. case GameRunLevel.InRound:
  105. if (!_configurationManager.GetCVar(CCVars.OocEnableDuringRound))
  106. _configurationManager.SetCVar(CCVars.OocEnabled, false);
  107. break;
  108. case GameRunLevel.PostRound:
  109. case GameRunLevel.PreRoundLobby:
  110. if (!_configurationManager.GetCVar(CCVars.OocEnableDuringRound))
  111. _configurationManager.SetCVar(CCVars.OocEnabled, true);
  112. break;
  113. }
  114. }
  115. /// <summary>
  116. /// Sends an in-character chat message to relevant clients.
  117. /// </summary>
  118. /// <param name="source">The entity that is speaking</param>
  119. /// <param name="message">The message being spoken or emoted</param>
  120. /// <param name="desiredType">The chat type</param>
  121. /// <param name="hideChat">Whether or not this message should appear in the chat window</param>
  122. /// <param name="hideLog">Whether or not this message should appear in the adminlog window</param>
  123. /// <param name="shell"></param>
  124. /// <param name="player">The player doing the speaking</param>
  125. /// <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>
  126. public void TrySendInGameICMessage(
  127. EntityUid source,
  128. string message,
  129. InGameICChatType desiredType,
  130. bool hideChat, bool hideLog = false,
  131. IConsoleShell? shell = null,
  132. ICommonSession? player = null, string? nameOverride = null,
  133. bool checkRadioPrefix = true,
  134. bool ignoreActionBlocker = false)
  135. {
  136. TrySendInGameICMessage(source, message, desiredType, hideChat ? ChatTransmitRange.HideChat : ChatTransmitRange.Normal, hideLog, shell, player, nameOverride, checkRadioPrefix, ignoreActionBlocker);
  137. }
  138. /// <summary>
  139. /// Sends an in-character chat message to relevant clients.
  140. /// </summary>
  141. /// <param name="source">The entity that is speaking</param>
  142. /// <param name="message">The message being spoken or emoted</param>
  143. /// <param name="desiredType">The chat type</param>
  144. /// <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>
  145. /// <param name="shell"></param>
  146. /// <param name="player">The player doing the speaking</param>
  147. /// <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>
  148. /// <param name="ignoreActionBlocker">If set to true, action blocker will not be considered for whether an entity can send this message.</param>
  149. public void TrySendInGameICMessage(
  150. EntityUid source,
  151. string message,
  152. InGameICChatType desiredType,
  153. ChatTransmitRange range,
  154. bool hideLog = false,
  155. IConsoleShell? shell = null,
  156. ICommonSession? player = null,
  157. string? nameOverride = null,
  158. bool checkRadioPrefix = true,
  159. bool ignoreActionBlocker = false
  160. )
  161. {
  162. if (HasComp<GhostComponent>(source))
  163. {
  164. // Ghosts can only send dead chat messages, so we'll forward it to InGame OOC.
  165. TrySendInGameOOCMessage(source, message, InGameOOCChatType.Dead, range == ChatTransmitRange.HideChat, shell, player);
  166. return;
  167. }
  168. if (player != null && _chatManager.HandleRateLimit(player) != RateLimitStatus.Allowed)
  169. return;
  170. // Sus
  171. if (player?.AttachedEntity is { Valid: true } entity && source != entity)
  172. {
  173. return;
  174. }
  175. if (!CanSendInGame(message, shell, player))
  176. return;
  177. ignoreActionBlocker = CheckIgnoreSpeechBlocker(source, ignoreActionBlocker);
  178. // this method is a disaster
  179. // every second i have to spend working with this code is fucking agony
  180. // scientists have to wonder how any of this was merged
  181. // coding any game admin feature that involves chat code is pure torture
  182. // changing even 10 lines of code feels like waterboarding myself
  183. // and i dont feel like vibe checking 50 code paths
  184. // so we set this here
  185. // todo free me from chat code
  186. if (player != null)
  187. {
  188. _chatManager.EnsurePlayer(player.UserId).AddEntity(GetNetEntity(source));
  189. }
  190. if (desiredType == InGameICChatType.Speak && message.StartsWith(LocalPrefix))
  191. {
  192. // prevent radios and remove prefix.
  193. checkRadioPrefix = false;
  194. message = message[1..];
  195. }
  196. bool shouldCapitalize = (desiredType != InGameICChatType.Emote);
  197. bool shouldPunctuate = _configurationManager.GetCVar(CCVars.ChatPunctuation);
  198. // Capitalizing the word I only happens in English, so we check language here
  199. bool shouldCapitalizeTheWordI = (!CultureInfo.CurrentCulture.IsNeutralCulture && CultureInfo.CurrentCulture.Parent.Name == "en")
  200. || (CultureInfo.CurrentCulture.IsNeutralCulture && CultureInfo.CurrentCulture.Name == "en");
  201. message = SanitizeInGameICMessage(source, message, out var emoteStr, shouldCapitalize, shouldPunctuate, shouldCapitalizeTheWordI);
  202. // Was there an emote in the message? If so, send it.
  203. if (player != null && emoteStr != message && emoteStr != null)
  204. {
  205. SendEntityEmote(source, emoteStr, range, nameOverride, ignoreActionBlocker);
  206. }
  207. // This can happen if the entire string is sanitized out.
  208. if (string.IsNullOrEmpty(message))
  209. return;
  210. // This message may have a radio prefix, and should then be whispered to the resolved radio channel
  211. if (checkRadioPrefix)
  212. {
  213. if (TryProccessRadioMessage(source, message, out var modMessage, out var channel))
  214. {
  215. SendEntityWhisper(source, modMessage, range, channel, nameOverride, hideLog, ignoreActionBlocker);
  216. return;
  217. }
  218. }
  219. // Otherwise, send whatever type.
  220. switch (desiredType)
  221. {
  222. case InGameICChatType.Speak:
  223. SendEntitySpeak(source, message, range, nameOverride, hideLog, ignoreActionBlocker);
  224. break;
  225. case InGameICChatType.Whisper:
  226. SendEntityWhisper(source, message, range, null, nameOverride, hideLog, ignoreActionBlocker);
  227. break;
  228. case InGameICChatType.Emote:
  229. SendEntityEmote(source, message, range, nameOverride, hideLog: hideLog, ignoreActionBlocker: ignoreActionBlocker);
  230. break;
  231. }
  232. }
  233. public void TrySendInGameOOCMessage(
  234. EntityUid source,
  235. string message,
  236. InGameOOCChatType type,
  237. bool hideChat,
  238. IConsoleShell? shell = null,
  239. ICommonSession? player = null
  240. )
  241. {
  242. if (!CanSendInGame(message, shell, player))
  243. return;
  244. if (player != null && _chatManager.HandleRateLimit(player) != RateLimitStatus.Allowed)
  245. return;
  246. // It doesn't make any sense for a non-player to send in-game OOC messages, whereas non-players may be sending
  247. // in-game IC messages.
  248. if (player?.AttachedEntity is not { Valid: true } entity || source != entity)
  249. return;
  250. message = SanitizeInGameOOCMessage(message);
  251. var sendType = type;
  252. // If dead player LOOC is disabled, unless you are an admin with Moderator perms, send dead messages to dead chat
  253. if ((_adminManager.IsAdmin(player) && _adminManager.HasAdminFlag(player, AdminFlags.Moderator)) // Override if admin
  254. || _deadLoocEnabled
  255. || (!HasComp<GhostComponent>(source) && !_mobStateSystem.IsDead(source))) // Check that player is not dead
  256. {
  257. }
  258. else
  259. sendType = InGameOOCChatType.Dead;
  260. // If crit player LOOC is disabled, don't send the message at all.
  261. if (!_critLoocEnabled && _mobStateSystem.IsCritical(source))
  262. return;
  263. switch (sendType)
  264. {
  265. case InGameOOCChatType.Dead:
  266. SendDeadChat(source, player, message, hideChat);
  267. break;
  268. case InGameOOCChatType.Looc:
  269. SendLOOC(source, player, message, hideChat);
  270. break;
  271. }
  272. }
  273. #region Announcements
  274. /// <summary>
  275. /// Dispatches an announcement to all.
  276. /// </summary>
  277. /// <param name="message">The contents of the message</param>
  278. /// <param name="sender">The sender (Communications Console in Communications Console Announcement)</param>
  279. /// <param name="playSound">Play the announcement sound</param>
  280. /// <param name="colorOverride">Optional color for the announcement message</param>
  281. public void DispatchGlobalAnnouncement(
  282. string message,
  283. string? sender = null,
  284. bool playSound = true,
  285. SoundSpecifier? announcementSound = null,
  286. Color? colorOverride = null
  287. )
  288. {
  289. sender ??= Loc.GetString("chat-manager-sender-announcement");
  290. var wrappedMessage = Loc.GetString("chat-manager-sender-announcement-wrap-message", ("sender", sender), ("message", FormattedMessage.EscapeText(message)));
  291. _chatManager.ChatMessageToAll(ChatChannel.Radio, message, wrappedMessage, default, false, true, colorOverride);
  292. if (playSound)
  293. {
  294. _audio.PlayGlobal(announcementSound == null ? DefaultAnnouncementSound : _audio.ResolveSound(announcementSound), Filter.Broadcast(), true, AudioParams.Default.WithVolume(-2f));
  295. }
  296. _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Global announcement from {sender}: {message}");
  297. }
  298. /// <summary>
  299. /// Dispatches an announcement to players selected by filter.
  300. /// </summary>
  301. /// <param name="filter">Filter to select players who will recieve the announcement</param>
  302. /// <param name="message">The contents of the message</param>
  303. /// <param name="source">The entity making the announcement (used to determine the station)</param>
  304. /// <param name="sender">The sender (Communications Console in Communications Console Announcement)</param>
  305. /// <param name="playDefaultSound">Play the announcement sound</param>
  306. /// <param name="announcementSound">Sound to play</param>
  307. /// <param name="colorOverride">Optional color for the announcement message</param>
  308. public void DispatchFilteredAnnouncement(
  309. Filter filter,
  310. string message,
  311. EntityUid? source = null,
  312. string? sender = null,
  313. bool playSound = true,
  314. SoundSpecifier? announcementSound = null,
  315. Color? colorOverride = null)
  316. {
  317. sender ??= Loc.GetString("chat-manager-sender-announcement");
  318. var wrappedMessage = Loc.GetString("chat-manager-sender-announcement-wrap-message", ("sender", sender), ("message", FormattedMessage.EscapeText(message)));
  319. _chatManager.ChatMessageToManyFiltered(filter, ChatChannel.Radio, message, wrappedMessage, source ?? default, false, true, colorOverride);
  320. if (playSound)
  321. {
  322. _audio.PlayGlobal(announcementSound?.ToString() ?? DefaultAnnouncementSound, filter, true, AudioParams.Default.WithVolume(-2f));
  323. }
  324. _adminLogger.Add(LogType.Chat, LogImpact.Low, $"World Announcement from {sender}: {message}");
  325. }
  326. /// <summary>
  327. /// Dispatches an announcement on a specific station
  328. /// </summary>
  329. /// <param name="source">The entity making the announcement (used to determine the station)</param>
  330. /// <param name="message">The contents of the message</param>
  331. /// <param name="sender">The sender (Communications Console in Communications Console Announcement)</param>
  332. /// <param name="playDefaultSound">Play the announcement sound</param>
  333. /// <param name="colorOverride">Optional color for the announcement message</param>
  334. public void DispatchStationAnnouncement(
  335. EntityUid source,
  336. string message,
  337. string? sender = null,
  338. bool playDefaultSound = true,
  339. SoundSpecifier? announcementSound = null,
  340. Color? colorOverride = null)
  341. {
  342. sender ??= Loc.GetString("chat-manager-sender-announcement");
  343. var wrappedMessage = Loc.GetString("chat-manager-sender-announcement-wrap-message", ("sender", sender), ("message", FormattedMessage.EscapeText(message)));
  344. var station = _stationSystem.GetOwningStation(source);
  345. if (station == null)
  346. {
  347. // you can't make a station announcement without a station
  348. return;
  349. }
  350. if (!EntityManager.TryGetComponent<StationDataComponent>(station, out var stationDataComp)) return;
  351. var filter = _stationSystem.GetInStation(stationDataComp);
  352. _chatManager.ChatMessageToManyFiltered(filter, ChatChannel.Radio, message, wrappedMessage, source, false, true, colorOverride);
  353. if (playDefaultSound)
  354. {
  355. _audio.PlayGlobal(announcementSound?.ToString() ?? DefaultAnnouncementSound, filter, true, AudioParams.Default.WithVolume(-2f));
  356. }
  357. _adminLogger.Add(LogType.Chat, LogImpact.Low, $"World Announcement on {station} from {sender}: {message}");
  358. }
  359. #endregion
  360. #region Private API
  361. private void SendEntitySpeak(
  362. EntityUid source,
  363. string originalMessage,
  364. ChatTransmitRange range,
  365. string? nameOverride,
  366. bool hideLog = false,
  367. bool ignoreActionBlocker = false
  368. )
  369. {
  370. if (!_actionBlocker.CanSpeak(source) && !ignoreActionBlocker)
  371. return;
  372. var message = TransformSpeech(source, originalMessage);
  373. if (message.Length == 0)
  374. return;
  375. var speech = GetSpeechVerb(source, message);
  376. // get the entity's apparent name (if no override provided).
  377. string name;
  378. if (nameOverride != null)
  379. {
  380. name = nameOverride;
  381. }
  382. else
  383. {
  384. var nameEv = new TransformSpeakerNameEvent(source, Name(source));
  385. RaiseLocalEvent(source, nameEv);
  386. name = nameEv.VoiceName;
  387. // Check for a speech verb override
  388. if (nameEv.SpeechVerb != null && _prototypeManager.TryIndex(nameEv.SpeechVerb, out var proto))
  389. speech = proto;
  390. }
  391. name = FormattedMessage.EscapeText(name);
  392. var wrappedMessage = Loc.GetString(speech.Bold ? "chat-manager-entity-say-bold-wrap-message" : "chat-manager-entity-say-wrap-message",
  393. ("entityName", name),
  394. ("verb", Loc.GetString(_random.Pick(speech.SpeechVerbStrings))),
  395. ("fontType", speech.FontId),
  396. ("fontSize", speech.FontSize),
  397. ("message", FormattedMessage.EscapeText(message)));
  398. SendInVoiceRange(ChatChannel.Local, message, wrappedMessage, source, range);
  399. var ev = new EntitySpokeEvent(source, message, null, null);
  400. RaiseLocalEvent(source, ev, true);
  401. // To avoid logging any messages sent by entities that are not players, like vendors, cloning, etc.
  402. // Also doesn't log if hideLog is true.
  403. if (!HasComp<ActorComponent>(source) || hideLog)
  404. return;
  405. if (originalMessage == message)
  406. {
  407. if (name != Name(source))
  408. _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Say from {ToPrettyString(source):user} as {name}: {originalMessage}.");
  409. else
  410. _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Say from {ToPrettyString(source):user}: {originalMessage}.");
  411. }
  412. else
  413. {
  414. if (name != Name(source))
  415. _adminLogger.Add(LogType.Chat, LogImpact.Low,
  416. $"Say from {ToPrettyString(source):user} as {name}, original: {originalMessage}, transformed: {message}.");
  417. else
  418. _adminLogger.Add(LogType.Chat, LogImpact.Low,
  419. $"Say from {ToPrettyString(source):user}, original: {originalMessage}, transformed: {message}.");
  420. }
  421. }
  422. private void SendEntityWhisper(
  423. EntityUid source,
  424. string originalMessage,
  425. ChatTransmitRange range,
  426. RadioChannelPrototype? channel,
  427. string? nameOverride,
  428. bool hideLog = false,
  429. bool ignoreActionBlocker = false
  430. )
  431. {
  432. if (!_actionBlocker.CanSpeak(source) && !ignoreActionBlocker)
  433. return;
  434. var message = TransformSpeech(source, FormattedMessage.RemoveMarkupOrThrow(originalMessage));
  435. if (message.Length == 0)
  436. return;
  437. var obfuscatedMessage = ObfuscateMessageReadability(message, 0.2f);
  438. // get the entity's name by visual identity (if no override provided).
  439. string nameIdentity = FormattedMessage.EscapeText(nameOverride ?? Identity.Name(source, EntityManager));
  440. // get the entity's name by voice (if no override provided).
  441. string name;
  442. if (nameOverride != null)
  443. {
  444. name = nameOverride;
  445. }
  446. else
  447. {
  448. var nameEv = new TransformSpeakerNameEvent(source, Name(source));
  449. RaiseLocalEvent(source, nameEv);
  450. name = nameEv.VoiceName;
  451. }
  452. name = FormattedMessage.EscapeText(name);
  453. var wrappedMessage = Loc.GetString("chat-manager-entity-whisper-wrap-message",
  454. ("entityName", name), ("message", FormattedMessage.EscapeText(message)));
  455. var wrappedobfuscatedMessage = Loc.GetString("chat-manager-entity-whisper-wrap-message",
  456. ("entityName", nameIdentity), ("message", FormattedMessage.EscapeText(obfuscatedMessage)));
  457. var wrappedUnknownMessage = Loc.GetString("chat-manager-entity-whisper-unknown-wrap-message",
  458. ("message", FormattedMessage.EscapeText(obfuscatedMessage)));
  459. foreach (var (session, data) in GetRecipients(source, WhisperMuffledRange))
  460. {
  461. EntityUid listener;
  462. if (session.AttachedEntity is not { Valid: true } playerEntity)
  463. continue;
  464. listener = session.AttachedEntity.Value;
  465. if (MessageRangeCheck(session, data, range) != MessageRangeCheckResult.Full)
  466. 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.
  467. if (data.Range <= WhisperClearRange)
  468. _chatManager.ChatMessageToOne(ChatChannel.Whisper, message, wrappedMessage, source, false, session.Channel);
  469. //If listener is too far, they only hear fragments of the message
  470. else if (_examineSystem.InRangeUnOccluded(source, listener, WhisperMuffledRange))
  471. _chatManager.ChatMessageToOne(ChatChannel.Whisper, obfuscatedMessage, wrappedobfuscatedMessage, source, false, session.Channel);
  472. //If listener is too far and has no line of sight, they can't identify the whisperer's identity
  473. else
  474. _chatManager.ChatMessageToOne(ChatChannel.Whisper, obfuscatedMessage, wrappedUnknownMessage, source, false, session.Channel);
  475. }
  476. _replay.RecordServerMessage(new ChatMessage(ChatChannel.Whisper, message, wrappedMessage, GetNetEntity(source), null, MessageRangeHideChatForReplay(range)));
  477. var ev = new EntitySpokeEvent(source, message, channel, obfuscatedMessage);
  478. RaiseLocalEvent(source, ev, true);
  479. if (!hideLog)
  480. if (originalMessage == message)
  481. {
  482. if (name != Name(source))
  483. _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Whisper from {ToPrettyString(source):user} as {name}: {originalMessage}.");
  484. else
  485. _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Whisper from {ToPrettyString(source):user}: {originalMessage}.");
  486. }
  487. else
  488. {
  489. if (name != Name(source))
  490. _adminLogger.Add(LogType.Chat, LogImpact.Low,
  491. $"Whisper from {ToPrettyString(source):user} as {name}, original: {originalMessage}, transformed: {message}.");
  492. else
  493. _adminLogger.Add(LogType.Chat, LogImpact.Low,
  494. $"Whisper from {ToPrettyString(source):user}, original: {originalMessage}, transformed: {message}.");
  495. }
  496. }
  497. private void SendEntityEmote(
  498. EntityUid source,
  499. string action,
  500. ChatTransmitRange range,
  501. string? nameOverride,
  502. bool hideLog = false,
  503. bool checkEmote = true,
  504. bool ignoreActionBlocker = false,
  505. NetUserId? author = null
  506. )
  507. {
  508. if (!_actionBlocker.CanEmote(source) && !ignoreActionBlocker)
  509. return;
  510. // get the entity's apparent name (if no override provided).
  511. var ent = Identity.Entity(source, EntityManager);
  512. string name = FormattedMessage.EscapeText(nameOverride ?? Name(ent));
  513. // Emotes use Identity.Name, since it doesn't actually involve your voice at all.
  514. var wrappedMessage = Loc.GetString("chat-manager-entity-me-wrap-message",
  515. ("entityName", name),
  516. ("entity", ent),
  517. ("message", FormattedMessage.RemoveMarkupOrThrow(action)));
  518. if (checkEmote)
  519. TryEmoteChatInput(source, action);
  520. SendInVoiceRange(ChatChannel.Emotes, action, wrappedMessage, source, range, author);
  521. if (!hideLog)
  522. if (name != Name(source))
  523. _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Emote from {ToPrettyString(source):user} as {name}: {action}");
  524. else
  525. _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Emote from {ToPrettyString(source):user}: {action}");
  526. }
  527. // ReSharper disable once InconsistentNaming
  528. private void SendLOOC(EntityUid source, ICommonSession player, string message, bool hideChat)
  529. {
  530. var name = FormattedMessage.EscapeText(Identity.Name(source, EntityManager));
  531. if (_adminManager.IsAdmin(player))
  532. {
  533. if (!_adminLoocEnabled) return;
  534. }
  535. else if (!_loocEnabled) return;
  536. // If crit player LOOC is disabled, don't send the message at all.
  537. if (!_critLoocEnabled && _mobStateSystem.IsCritical(source))
  538. return;
  539. var wrappedMessage = Loc.GetString("chat-manager-entity-looc-wrap-message",
  540. ("entityName", name),
  541. ("message", FormattedMessage.EscapeText(message)));
  542. SendInVoiceRange(ChatChannel.LOOC, message, wrappedMessage, source, hideChat ? ChatTransmitRange.HideChat : ChatTransmitRange.Normal, player.UserId);
  543. _adminLogger.Add(LogType.Chat, LogImpact.Low, $"LOOC from {player:Player}: {message}");
  544. }
  545. private void SendDeadChat(EntityUid source, ICommonSession player, string message, bool hideChat)
  546. {
  547. var clients = GetDeadChatClients();
  548. var playerName = Name(source);
  549. string wrappedMessage;
  550. if (_adminManager.IsAdmin(player))
  551. {
  552. wrappedMessage = Loc.GetString("chat-manager-send-admin-dead-chat-wrap-message",
  553. ("adminChannelName", Loc.GetString("chat-manager-admin-channel-name")),
  554. ("userName", player.Channel.UserName),
  555. ("message", FormattedMessage.EscapeText(message)));
  556. _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Admin dead chat from {player:Player}: {message}");
  557. }
  558. else
  559. {
  560. wrappedMessage = Loc.GetString("chat-manager-send-dead-chat-wrap-message",
  561. ("deadChannelName", Loc.GetString("chat-manager-dead-channel-name")),
  562. ("playerName", (playerName)),
  563. ("message", FormattedMessage.EscapeText(message)));
  564. _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Dead chat from {player:Player}: {message}");
  565. }
  566. _chatManager.ChatMessageToMany(ChatChannel.Dead, message, wrappedMessage, source, hideChat, true, clients.ToList(), author: player.UserId);
  567. }
  568. #endregion
  569. #region Utility
  570. private enum MessageRangeCheckResult
  571. {
  572. Disallowed,
  573. HideChat,
  574. Full
  575. }
  576. /// <summary>
  577. /// If hideChat should be set as far as replays are concerned.
  578. /// </summary>
  579. private bool MessageRangeHideChatForReplay(ChatTransmitRange range)
  580. {
  581. return range == ChatTransmitRange.HideChat;
  582. }
  583. /// <summary>
  584. /// Checks if a target as returned from GetRecipients should receive the message.
  585. /// Keep in mind data.Range is -1 for out of range observers.
  586. /// </summary>
  587. private MessageRangeCheckResult MessageRangeCheck(ICommonSession session, ICChatRecipientData data, ChatTransmitRange range)
  588. {
  589. var initialResult = MessageRangeCheckResult.Full;
  590. switch (range)
  591. {
  592. case ChatTransmitRange.Normal:
  593. initialResult = MessageRangeCheckResult.Full;
  594. break;
  595. case ChatTransmitRange.GhostRangeLimit:
  596. initialResult = (data.Observer && data.Range < 0 && !_adminManager.IsAdmin(session)) ? MessageRangeCheckResult.HideChat : MessageRangeCheckResult.Full;
  597. break;
  598. case ChatTransmitRange.HideChat:
  599. initialResult = MessageRangeCheckResult.HideChat;
  600. break;
  601. case ChatTransmitRange.NoGhosts:
  602. initialResult = (data.Observer && !_adminManager.IsAdmin(session)) ? MessageRangeCheckResult.Disallowed : MessageRangeCheckResult.Full;
  603. break;
  604. }
  605. var insistHideChat = data.HideChatOverride ?? false;
  606. var insistNoHideChat = !(data.HideChatOverride ?? true);
  607. if (insistHideChat && initialResult == MessageRangeCheckResult.Full)
  608. return MessageRangeCheckResult.HideChat;
  609. if (insistNoHideChat && initialResult == MessageRangeCheckResult.HideChat)
  610. return MessageRangeCheckResult.Full;
  611. return initialResult;
  612. }
  613. /// <summary>
  614. /// Sends a chat message to the given players in range of the source entity.
  615. /// </summary>
  616. private void SendInVoiceRange(ChatChannel channel, string message, string wrappedMessage, EntityUid source, ChatTransmitRange range, NetUserId? author = null)
  617. {
  618. foreach (var (session, data) in GetRecipients(source, VoiceRange))
  619. {
  620. var entRange = MessageRangeCheck(session, data, range);
  621. if (entRange == MessageRangeCheckResult.Disallowed)
  622. continue;
  623. var entHideChat = entRange == MessageRangeCheckResult.HideChat;
  624. _chatManager.ChatMessageToOne(channel, message, wrappedMessage, source, entHideChat, session.Channel, author: author);
  625. }
  626. _replay.RecordServerMessage(new ChatMessage(channel, message, wrappedMessage, GetNetEntity(source), null, MessageRangeHideChatForReplay(range)));
  627. }
  628. /// <summary>
  629. /// Returns true if the given player is 'allowed' to send the given message, false otherwise.
  630. /// </summary>
  631. private bool CanSendInGame(string message, IConsoleShell? shell = null, ICommonSession? player = null)
  632. {
  633. // Non-players don't have to worry about these restrictions.
  634. if (player == null)
  635. return true;
  636. var mindContainerComponent = player.ContentData()?.Mind;
  637. if (mindContainerComponent == null)
  638. {
  639. shell?.WriteError("You don't have a mind!");
  640. return false;
  641. }
  642. if (player.AttachedEntity is not { Valid: true } _)
  643. {
  644. shell?.WriteError("You don't have an entity!");
  645. return false;
  646. }
  647. return !_chatManager.MessageCharacterLimit(player, message);
  648. }
  649. // ReSharper disable once InconsistentNaming
  650. private string SanitizeInGameICMessage(EntityUid source, string message, out string? emoteStr, bool capitalize = true, bool punctuate = false, bool capitalizeTheWordI = true)
  651. {
  652. var newMessage = SanitizeMessageReplaceWords(message.Trim());
  653. GetRadioKeycodePrefix(source, newMessage, out newMessage, out var prefix);
  654. // Sanitize it first as it might change the word order
  655. _sanitizer.TrySanitizeEmoteShorthands(newMessage, source, out newMessage, out emoteStr);
  656. if (capitalize)
  657. newMessage = SanitizeMessageCapital(newMessage);
  658. if (capitalizeTheWordI)
  659. newMessage = SanitizeMessageCapitalizeTheWordI(newMessage, "i");
  660. if (punctuate)
  661. newMessage = SanitizeMessagePeriod(newMessage);
  662. return prefix + newMessage;
  663. }
  664. private string SanitizeInGameOOCMessage(string message)
  665. {
  666. var newMessage = message.Trim();
  667. newMessage = FormattedMessage.EscapeText(newMessage);
  668. return newMessage;
  669. }
  670. public string TransformSpeech(EntityUid sender, string message)
  671. {
  672. var ev = new TransformSpeechEvent(sender, message);
  673. RaiseLocalEvent(ev);
  674. return ev.Message;
  675. }
  676. public bool CheckIgnoreSpeechBlocker(EntityUid sender, bool ignoreBlocker)
  677. {
  678. if (ignoreBlocker)
  679. return ignoreBlocker;
  680. var ev = new CheckIgnoreSpeechBlockerEvent(sender, ignoreBlocker);
  681. RaiseLocalEvent(sender, ev, true);
  682. return ev.IgnoreBlocker;
  683. }
  684. private IEnumerable<INetChannel> GetDeadChatClients()
  685. {
  686. return Filter.Empty()
  687. .AddWhereAttachedEntity(HasComp<GhostComponent>)
  688. .Recipients
  689. .Union(_adminManager.ActiveAdmins)
  690. .Select(p => p.Channel);
  691. }
  692. private string SanitizeMessagePeriod(string message)
  693. {
  694. if (string.IsNullOrEmpty(message))
  695. return message;
  696. // Adds a period if the last character is a letter.
  697. if (char.IsLetter(message[^1]))
  698. message += ".";
  699. return message;
  700. }
  701. [ValidatePrototypeId<ReplacementAccentPrototype>]
  702. public const string ChatSanitize_Accent = "chatsanitize";
  703. public string SanitizeMessageReplaceWords(string message)
  704. {
  705. if (string.IsNullOrEmpty(message)) return message;
  706. var msg = message;
  707. msg = _wordreplacement.ApplyReplacements(msg, ChatSanitize_Accent);
  708. return msg;
  709. }
  710. /// <summary>
  711. /// Returns list of players and ranges for all players withing some range. Also returns observers with a range of -1.
  712. /// </summary>
  713. private Dictionary<ICommonSession, ICChatRecipientData> GetRecipients(EntityUid source, float voiceGetRange)
  714. {
  715. // TODO proper speech occlusion
  716. var recipients = new Dictionary<ICommonSession, ICChatRecipientData>();
  717. var ghostHearing = GetEntityQuery<GhostHearingComponent>();
  718. var xforms = GetEntityQuery<TransformComponent>();
  719. var transformSource = xforms.GetComponent(source);
  720. var sourceMapId = transformSource.MapID;
  721. var sourceCoords = transformSource.Coordinates;
  722. foreach (var player in _playerManager.Sessions)
  723. {
  724. if (player.AttachedEntity is not { Valid: true } playerEntity)
  725. continue;
  726. var transformEntity = xforms.GetComponent(playerEntity);
  727. if (transformEntity.MapID != sourceMapId)
  728. continue;
  729. var observer = ghostHearing.HasComponent(playerEntity);
  730. // even if they are a ghost hearer, in some situations we still need the range
  731. if (sourceCoords.TryDistance(EntityManager, transformEntity.Coordinates, out var distance) && distance < voiceGetRange)
  732. {
  733. recipients.Add(player, new ICChatRecipientData(distance, observer));
  734. continue;
  735. }
  736. if (observer)
  737. recipients.Add(player, new ICChatRecipientData(-1, true));
  738. }
  739. RaiseLocalEvent(new ExpandICChatRecipientsEvent(source, voiceGetRange, recipients));
  740. return recipients;
  741. }
  742. public readonly record struct ICChatRecipientData(float Range, bool Observer, bool? HideChatOverride = null)
  743. {
  744. }
  745. private string ObfuscateMessageReadability(string message, float chance)
  746. {
  747. var modifiedMessage = new StringBuilder(message);
  748. for (var i = 0; i < message.Length; i++)
  749. {
  750. if (char.IsWhiteSpace((modifiedMessage[i])))
  751. {
  752. continue;
  753. }
  754. if (_random.Prob(1 - chance))
  755. {
  756. modifiedMessage[i] = '~';
  757. }
  758. }
  759. return modifiedMessage.ToString();
  760. }
  761. public string BuildGibberishString(IReadOnlyList<char> charOptions, int length)
  762. {
  763. var sb = new StringBuilder();
  764. for (var i = 0; i < length; i++)
  765. {
  766. sb.Append(_random.Pick(charOptions));
  767. }
  768. return sb.ToString();
  769. }
  770. #endregion
  771. }
  772. /// <summary>
  773. /// This event is raised before chat messages are sent out to clients. This enables some systems to send the chat
  774. /// messages to otherwise out-of view entities (e.g. for multiple viewports from cameras).
  775. /// </summary>
  776. public record ExpandICChatRecipientsEvent(EntityUid Source, float VoiceRange, Dictionary<ICommonSession, ChatSystem.ICChatRecipientData> Recipients)
  777. {
  778. }
  779. /// <summary>
  780. /// Raised broadcast in order to transform speech.transmit
  781. /// </summary>
  782. public sealed class TransformSpeechEvent : EntityEventArgs
  783. {
  784. public EntityUid Sender;
  785. public string Message;
  786. public TransformSpeechEvent(EntityUid sender, string message)
  787. {
  788. Sender = sender;
  789. Message = message;
  790. }
  791. }
  792. public sealed class CheckIgnoreSpeechBlockerEvent : EntityEventArgs
  793. {
  794. public EntityUid Sender;
  795. public bool IgnoreBlocker;
  796. public CheckIgnoreSpeechBlockerEvent(EntityUid sender, bool ignoreBlocker)
  797. {
  798. Sender = sender;
  799. IgnoreBlocker = ignoreBlocker;
  800. }
  801. }
  802. /// <summary>
  803. /// Raised on an entity when it speaks, either through 'say' or 'whisper'.
  804. /// </summary>
  805. public sealed class EntitySpokeEvent : EntityEventArgs
  806. {
  807. public readonly EntityUid Source;
  808. public readonly string Message;
  809. public readonly string? ObfuscatedMessage; // not null if this was a whisper
  810. /// <summary>
  811. /// If the entity was trying to speak into a radio, this was the channel they were trying to access. If a radio
  812. /// message gets sent on this channel, this should be set to null to prevent duplicate messages.
  813. /// </summary>
  814. public RadioChannelPrototype? Channel;
  815. public EntitySpokeEvent(EntityUid source, string message, RadioChannelPrototype? channel, string? obfuscatedMessage)
  816. {
  817. Source = source;
  818. Message = message;
  819. Channel = channel;
  820. ObfuscatedMessage = obfuscatedMessage;
  821. }
  822. }
  823. /// <summary>
  824. /// InGame IC chat is for chat that is specifically ingame (not lobby) but is also in character, i.e. speaking.
  825. /// </summary>
  826. // ReSharper disable once InconsistentNaming
  827. public enum InGameICChatType : byte
  828. {
  829. Speak,
  830. Emote,
  831. Whisper
  832. }
  833. /// <summary>
  834. /// InGame OOC chat is for chat that is specifically ingame (not lobby) but is OOC, like deadchat or LOOC.
  835. /// </summary>
  836. public enum InGameOOCChatType : byte
  837. {
  838. Looc,
  839. Dead
  840. }
  841. /// <summary>
  842. /// Controls transmission of chat.
  843. /// </summary>
  844. public enum ChatTransmitRange : byte
  845. {
  846. /// Acts normal, ghosts can hear across the map, etc.
  847. Normal,
  848. /// Normal but ghosts are still range-limited.
  849. GhostRangeLimit,
  850. /// Hidden from the chat window.
  851. HideChat,
  852. /// Ghosts can't hear or see it at all. Regular players can if in-range.
  853. NoGhosts
  854. }