1
0

ChatSystem.cs 40 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001
  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. /// <summary>
  281. /// Sends a global announcement message to all players, optionally specifying the sender name and message colour.
  282. /// </summary>
  283. /// <param name="message">The announcement text to broadcast.</param>
  284. /// <param name="sender">Optional name to display as the sender of the announcement. Defaults to a generic announcement sender if not provided.</param>
  285. /// <param name="playSound">Unused parameter; sound is not played.</param>
  286. /// <param name="announcementSound">Unused parameter; announcement sound is not played.</param>
  287. /// <param name="colorOverride">Optional colour for the announcement message.</param>
  288. public void DispatchGlobalAnnouncement(
  289. string message,
  290. string? sender = null,
  291. bool playSound = true,
  292. SoundSpecifier? announcementSound = null,
  293. Color? colorOverride = null
  294. )
  295. {
  296. sender ??= Loc.GetString("chat-manager-sender-announcement");
  297. var wrappedMessage = Loc.GetString("chat-manager-sender-announcement-wrap-message", ("sender", sender), ("message", FormattedMessage.EscapeText(message)));
  298. _chatManager.ChatMessageToAll(ChatChannel.Radio, message, wrappedMessage, default, false, true, colorOverride);
  299. _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Global announcement from {sender}: {message}");
  300. }
  301. /// <summary>
  302. /// Dispatches an announcement to players selected by filter.
  303. /// </summary>
  304. /// <param name="filter">Filter to select players who will recieve the announcement</param>
  305. /// <param name="message">The contents of the message</param>
  306. /// <param name="source">The entity making the announcement (used to determine the station)</param>
  307. /// <param name="sender">The sender (Communications Console in Communications Console Announcement)</param>
  308. /// <param name="playDefaultSound">Play the announcement sound</param>
  309. /// <param name="announcementSound">Sound to play</param>
  310. /// <param name="colorOverride">Optional color for the announcement message</param>
  311. public void DispatchFilteredAnnouncement(
  312. Filter filter,
  313. string message,
  314. EntityUid? source = null,
  315. string? sender = null,
  316. bool playSound = true,
  317. SoundSpecifier? announcementSound = null,
  318. Color? colorOverride = null)
  319. {
  320. sender ??= Loc.GetString("chat-manager-sender-announcement");
  321. var wrappedMessage = Loc.GetString("chat-manager-sender-announcement-wrap-message", ("sender", sender), ("message", FormattedMessage.EscapeText(message)));
  322. _chatManager.ChatMessageToManyFiltered(filter, ChatChannel.Radio, message, wrappedMessage, source ?? default, false, true, colorOverride);
  323. if (playSound)
  324. {
  325. _audio.PlayGlobal(announcementSound?.ToString() ?? DefaultAnnouncementSound, filter, true, AudioParams.Default.WithVolume(-2f));
  326. }
  327. _adminLogger.Add(LogType.Chat, LogImpact.Low, $"World Announcement from {sender}: {message}");
  328. }
  329. /// <summary>
  330. /// Dispatches an announcement on a specific station
  331. /// </summary>
  332. /// <param name="source">The entity making the announcement (used to determine the station)</param>
  333. /// <param name="message">The contents of the message</param>
  334. /// <param name="sender">The sender (Communications Console in Communications Console Announcement)</param>
  335. /// <param name="playDefaultSound">Play the announcement sound</param>
  336. /// <param name="colorOverride">Optional color for the announcement message</param>
  337. public void DispatchStationAnnouncement(
  338. EntityUid source,
  339. string message,
  340. string? sender = null,
  341. bool playDefaultSound = true,
  342. SoundSpecifier? announcementSound = null,
  343. Color? colorOverride = null)
  344. {
  345. sender ??= Loc.GetString("chat-manager-sender-announcement");
  346. var wrappedMessage = Loc.GetString("chat-manager-sender-announcement-wrap-message", ("sender", sender), ("message", FormattedMessage.EscapeText(message)));
  347. var station = _stationSystem.GetOwningStation(source);
  348. if (station == null)
  349. {
  350. // you can't make a station announcement without a station
  351. return;
  352. }
  353. if (!EntityManager.TryGetComponent<StationDataComponent>(station, out var stationDataComp)) return;
  354. var filter = _stationSystem.GetInStation(stationDataComp);
  355. _chatManager.ChatMessageToManyFiltered(filter, ChatChannel.Radio, message, wrappedMessage, source, false, true, colorOverride);
  356. if (playDefaultSound)
  357. {
  358. _audio.PlayGlobal(announcementSound?.ToString() ?? DefaultAnnouncementSound, filter, true, AudioParams.Default.WithVolume(-2f));
  359. }
  360. _adminLogger.Add(LogType.Chat, LogImpact.Low, $"World Announcement on {station} from {sender}: {message}");
  361. }
  362. #endregion
  363. #region Private API
  364. private void SendEntitySpeak(
  365. EntityUid source,
  366. string originalMessage,
  367. ChatTransmitRange range,
  368. string? nameOverride,
  369. bool hideLog = false,
  370. bool ignoreActionBlocker = false
  371. )
  372. {
  373. if (!_actionBlocker.CanSpeak(source) && !ignoreActionBlocker)
  374. return;
  375. var message = TransformSpeech(source, originalMessage);
  376. if (message.Length == 0)
  377. return;
  378. var speech = GetSpeechVerb(source, message);
  379. // get the entity's apparent name (if no override provided).
  380. string name;
  381. if (nameOverride != null)
  382. {
  383. name = nameOverride;
  384. }
  385. else
  386. {
  387. var nameEv = new TransformSpeakerNameEvent(source, Name(source));
  388. RaiseLocalEvent(source, nameEv);
  389. name = nameEv.VoiceName;
  390. // Check for a speech verb override
  391. if (nameEv.SpeechVerb != null && _prototypeManager.TryIndex(nameEv.SpeechVerb, out var proto))
  392. speech = proto;
  393. }
  394. name = FormattedMessage.EscapeText(name);
  395. var wrappedMessage = Loc.GetString(speech.Bold ? "chat-manager-entity-say-bold-wrap-message" : "chat-manager-entity-say-wrap-message",
  396. ("entityName", name),
  397. ("verb", Loc.GetString(_random.Pick(speech.SpeechVerbStrings))),
  398. ("fontType", speech.FontId),
  399. ("fontSize", speech.FontSize),
  400. ("message", FormattedMessage.EscapeText(message)));
  401. SendInVoiceRange(ChatChannel.Local, message, wrappedMessage, source, range);
  402. var ev = new EntitySpokeEvent(source, message, null, null);
  403. RaiseLocalEvent(source, ev, true);
  404. // To avoid logging any messages sent by entities that are not players, like vendors, cloning, etc.
  405. // Also doesn't log if hideLog is true.
  406. if (!HasComp<ActorComponent>(source) || hideLog)
  407. return;
  408. if (originalMessage == message)
  409. {
  410. if (name != Name(source))
  411. _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Say from {ToPrettyString(source):user} as {name}: {originalMessage}.");
  412. else
  413. _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Say from {ToPrettyString(source):user}: {originalMessage}.");
  414. }
  415. else
  416. {
  417. if (name != Name(source))
  418. _adminLogger.Add(LogType.Chat, LogImpact.Low,
  419. $"Say from {ToPrettyString(source):user} as {name}, original: {originalMessage}, transformed: {message}.");
  420. else
  421. _adminLogger.Add(LogType.Chat, LogImpact.Low,
  422. $"Say from {ToPrettyString(source):user}, original: {originalMessage}, transformed: {message}.");
  423. }
  424. }
  425. private void SendEntityWhisper(
  426. EntityUid source,
  427. string originalMessage,
  428. ChatTransmitRange range,
  429. RadioChannelPrototype? channel,
  430. string? nameOverride,
  431. bool hideLog = false,
  432. bool ignoreActionBlocker = false
  433. )
  434. {
  435. if (!_actionBlocker.CanSpeak(source) && !ignoreActionBlocker)
  436. return;
  437. var message = TransformSpeech(source, FormattedMessage.RemoveMarkupOrThrow(originalMessage));
  438. if (message.Length == 0)
  439. return;
  440. var obfuscatedMessage = ObfuscateMessageReadability(message, 0.2f);
  441. // get the entity's name by visual identity (if no override provided).
  442. string nameIdentity = FormattedMessage.EscapeText(nameOverride ?? Identity.Name(source, EntityManager));
  443. // get the entity's name by voice (if no override provided).
  444. string name;
  445. if (nameOverride != null)
  446. {
  447. name = nameOverride;
  448. }
  449. else
  450. {
  451. var nameEv = new TransformSpeakerNameEvent(source, Name(source));
  452. RaiseLocalEvent(source, nameEv);
  453. name = nameEv.VoiceName;
  454. }
  455. name = FormattedMessage.EscapeText(name);
  456. var wrappedMessage = Loc.GetString("chat-manager-entity-whisper-wrap-message",
  457. ("entityName", name), ("message", FormattedMessage.EscapeText(message)));
  458. var wrappedobfuscatedMessage = Loc.GetString("chat-manager-entity-whisper-wrap-message",
  459. ("entityName", nameIdentity), ("message", FormattedMessage.EscapeText(obfuscatedMessage)));
  460. var wrappedUnknownMessage = Loc.GetString("chat-manager-entity-whisper-unknown-wrap-message",
  461. ("message", FormattedMessage.EscapeText(obfuscatedMessage)));
  462. foreach (var (session, data) in GetRecipients(source, WhisperMuffledRange))
  463. {
  464. EntityUid listener;
  465. if (session.AttachedEntity is not { Valid: true } playerEntity)
  466. continue;
  467. listener = session.AttachedEntity.Value;
  468. if (MessageRangeCheck(session, data, range) != MessageRangeCheckResult.Full)
  469. 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.
  470. if (data.Range <= WhisperClearRange)
  471. _chatManager.ChatMessageToOne(ChatChannel.Whisper, message, wrappedMessage, source, false, session.Channel);
  472. //If listener is too far, they only hear fragments of the message
  473. else if (_examineSystem.InRangeUnOccluded(source, listener, WhisperMuffledRange))
  474. _chatManager.ChatMessageToOne(ChatChannel.Whisper, obfuscatedMessage, wrappedobfuscatedMessage, source, false, session.Channel);
  475. //If listener is too far and has no line of sight, they can't identify the whisperer's identity
  476. else
  477. _chatManager.ChatMessageToOne(ChatChannel.Whisper, obfuscatedMessage, wrappedUnknownMessage, source, false, session.Channel);
  478. }
  479. _replay.RecordServerMessage(new ChatMessage(ChatChannel.Whisper, message, wrappedMessage, GetNetEntity(source), null, MessageRangeHideChatForReplay(range)));
  480. var ev = new EntitySpokeEvent(source, message, channel, obfuscatedMessage);
  481. RaiseLocalEvent(source, ev, true);
  482. if (!hideLog)
  483. if (originalMessage == message)
  484. {
  485. if (name != Name(source))
  486. _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Whisper from {ToPrettyString(source):user} as {name}: {originalMessage}.");
  487. else
  488. _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Whisper from {ToPrettyString(source):user}: {originalMessage}.");
  489. }
  490. else
  491. {
  492. if (name != Name(source))
  493. _adminLogger.Add(LogType.Chat, LogImpact.Low,
  494. $"Whisper from {ToPrettyString(source):user} as {name}, original: {originalMessage}, transformed: {message}.");
  495. else
  496. _adminLogger.Add(LogType.Chat, LogImpact.Low,
  497. $"Whisper from {ToPrettyString(source):user}, original: {originalMessage}, transformed: {message}.");
  498. }
  499. }
  500. private void SendEntityEmote(
  501. EntityUid source,
  502. string action,
  503. ChatTransmitRange range,
  504. string? nameOverride,
  505. bool hideLog = false,
  506. bool checkEmote = true,
  507. bool ignoreActionBlocker = false,
  508. NetUserId? author = null
  509. )
  510. {
  511. if (!_actionBlocker.CanEmote(source) && !ignoreActionBlocker)
  512. return;
  513. // get the entity's apparent name (if no override provided).
  514. var ent = Identity.Entity(source, EntityManager);
  515. string name = FormattedMessage.EscapeText(nameOverride ?? Name(ent));
  516. // Emotes use Identity.Name, since it doesn't actually involve your voice at all.
  517. var wrappedMessage = Loc.GetString("chat-manager-entity-me-wrap-message",
  518. ("entityName", name),
  519. ("entity", ent),
  520. ("message", FormattedMessage.RemoveMarkupOrThrow(action)));
  521. if (checkEmote)
  522. TryEmoteChatInput(source, action);
  523. SendInVoiceRange(ChatChannel.Emotes, action, wrappedMessage, source, range, author);
  524. if (!hideLog)
  525. if (name != Name(source))
  526. _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Emote from {ToPrettyString(source):user} as {name}: {action}");
  527. else
  528. _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Emote from {ToPrettyString(source):user}: {action}");
  529. }
  530. // ReSharper disable once InconsistentNaming
  531. private void SendLOOC(EntityUid source, ICommonSession player, string message, bool hideChat)
  532. {
  533. var name = FormattedMessage.EscapeText(Identity.Name(source, EntityManager));
  534. if (_adminManager.IsAdmin(player))
  535. {
  536. if (!_adminLoocEnabled) return;
  537. }
  538. else if (!_loocEnabled) return;
  539. // If crit player LOOC is disabled, don't send the message at all.
  540. if (!_critLoocEnabled && _mobStateSystem.IsCritical(source))
  541. return;
  542. var wrappedMessage = Loc.GetString("chat-manager-entity-looc-wrap-message",
  543. ("entityName", name),
  544. ("message", FormattedMessage.EscapeText(message)));
  545. SendInVoiceRange(ChatChannel.LOOC, message, wrappedMessage, source, hideChat ? ChatTransmitRange.HideChat : ChatTransmitRange.Normal, player.UserId);
  546. _adminLogger.Add(LogType.Chat, LogImpact.Low, $"LOOC from {player:Player}: {message}");
  547. }
  548. private void SendDeadChat(EntityUid source, ICommonSession player, string message, bool hideChat)
  549. {
  550. var clients = GetDeadChatClients();
  551. var playerName = Name(source);
  552. string wrappedMessage;
  553. if (_adminManager.IsAdmin(player))
  554. {
  555. wrappedMessage = Loc.GetString("chat-manager-send-admin-dead-chat-wrap-message",
  556. ("adminChannelName", Loc.GetString("chat-manager-admin-channel-name")),
  557. ("userName", player.Channel.UserName),
  558. ("message", FormattedMessage.EscapeText(message)));
  559. _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Admin dead chat from {player:Player}: {message}");
  560. }
  561. else
  562. {
  563. wrappedMessage = Loc.GetString("chat-manager-send-dead-chat-wrap-message",
  564. ("deadChannelName", Loc.GetString("chat-manager-dead-channel-name")),
  565. ("playerName", (playerName)),
  566. ("message", FormattedMessage.EscapeText(message)));
  567. _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Dead chat from {player:Player}: {message}");
  568. }
  569. _chatManager.ChatMessageToMany(ChatChannel.Dead, message, wrappedMessage, source, hideChat, true, clients.ToList(), author: player.UserId);
  570. }
  571. #endregion
  572. #region Utility
  573. private enum MessageRangeCheckResult
  574. {
  575. Disallowed,
  576. HideChat,
  577. Full
  578. }
  579. /// <summary>
  580. /// If hideChat should be set as far as replays are concerned.
  581. /// </summary>
  582. private bool MessageRangeHideChatForReplay(ChatTransmitRange range)
  583. {
  584. return range == ChatTransmitRange.HideChat;
  585. }
  586. /// <summary>
  587. /// Checks if a target as returned from GetRecipients should receive the message.
  588. /// Keep in mind data.Range is -1 for out of range observers.
  589. /// </summary>
  590. private MessageRangeCheckResult MessageRangeCheck(ICommonSession session, ICChatRecipientData data, ChatTransmitRange range)
  591. {
  592. var initialResult = MessageRangeCheckResult.Full;
  593. switch (range)
  594. {
  595. case ChatTransmitRange.Normal:
  596. initialResult = MessageRangeCheckResult.Full;
  597. break;
  598. case ChatTransmitRange.GhostRangeLimit:
  599. initialResult = (data.Observer && data.Range < 0 && !_adminManager.IsAdmin(session)) ? MessageRangeCheckResult.HideChat : MessageRangeCheckResult.Full;
  600. break;
  601. case ChatTransmitRange.HideChat:
  602. initialResult = MessageRangeCheckResult.HideChat;
  603. break;
  604. case ChatTransmitRange.NoGhosts:
  605. initialResult = (data.Observer && !_adminManager.IsAdmin(session)) ? MessageRangeCheckResult.Disallowed : MessageRangeCheckResult.Full;
  606. break;
  607. }
  608. var insistHideChat = data.HideChatOverride ?? false;
  609. var insistNoHideChat = !(data.HideChatOverride ?? true);
  610. if (insistHideChat && initialResult == MessageRangeCheckResult.Full)
  611. return MessageRangeCheckResult.HideChat;
  612. if (insistNoHideChat && initialResult == MessageRangeCheckResult.HideChat)
  613. return MessageRangeCheckResult.Full;
  614. return initialResult;
  615. }
  616. /// <summary>
  617. /// Sends a chat message to the given players in range of the source entity.
  618. /// </summary>
  619. private void SendInVoiceRange(ChatChannel channel, string message, string wrappedMessage, EntityUid source, ChatTransmitRange range, NetUserId? author = null)
  620. {
  621. foreach (var (session, data) in GetRecipients(source, VoiceRange))
  622. {
  623. var entRange = MessageRangeCheck(session, data, range);
  624. if (entRange == MessageRangeCheckResult.Disallowed)
  625. continue;
  626. var entHideChat = entRange == MessageRangeCheckResult.HideChat;
  627. _chatManager.ChatMessageToOne(channel, message, wrappedMessage, source, entHideChat, session.Channel, author: author);
  628. }
  629. _replay.RecordServerMessage(new ChatMessage(channel, message, wrappedMessage, GetNetEntity(source), null, MessageRangeHideChatForReplay(range)));
  630. }
  631. /// <summary>
  632. /// Returns true if the given player is 'allowed' to send the given message, false otherwise.
  633. /// </summary>
  634. private bool CanSendInGame(string message, IConsoleShell? shell = null, ICommonSession? player = null)
  635. {
  636. // Non-players don't have to worry about these restrictions.
  637. if (player == null)
  638. return true;
  639. var mindContainerComponent = player.ContentData()?.Mind;
  640. if (mindContainerComponent == null)
  641. {
  642. shell?.WriteError("You don't have a mind!");
  643. return false;
  644. }
  645. if (player.AttachedEntity is not { Valid: true } _)
  646. {
  647. shell?.WriteError("You don't have an entity!");
  648. return false;
  649. }
  650. return !_chatManager.MessageCharacterLimit(player, message);
  651. }
  652. // ReSharper disable once InconsistentNaming
  653. private string SanitizeInGameICMessage(EntityUid source, string message, out string? emoteStr, bool capitalize = true, bool punctuate = false, bool capitalizeTheWordI = true)
  654. {
  655. var newMessage = SanitizeMessageReplaceWords(message.Trim());
  656. GetRadioKeycodePrefix(source, newMessage, out newMessage, out var prefix);
  657. // Sanitize it first as it might change the word order
  658. _sanitizer.TrySanitizeEmoteShorthands(newMessage, source, out newMessage, out emoteStr);
  659. if (capitalize)
  660. newMessage = SanitizeMessageCapital(newMessage);
  661. if (capitalizeTheWordI)
  662. newMessage = SanitizeMessageCapitalizeTheWordI(newMessage, "i");
  663. if (punctuate)
  664. newMessage = SanitizeMessagePeriod(newMessage);
  665. return prefix + newMessage;
  666. }
  667. private string SanitizeInGameOOCMessage(string message)
  668. {
  669. var newMessage = message.Trim();
  670. newMessage = FormattedMessage.EscapeText(newMessage);
  671. return newMessage;
  672. }
  673. public string TransformSpeech(EntityUid sender, string message)
  674. {
  675. var ev = new TransformSpeechEvent(sender, message);
  676. RaiseLocalEvent(ev);
  677. return ev.Message;
  678. }
  679. public bool CheckIgnoreSpeechBlocker(EntityUid sender, bool ignoreBlocker)
  680. {
  681. if (ignoreBlocker)
  682. return ignoreBlocker;
  683. var ev = new CheckIgnoreSpeechBlockerEvent(sender, ignoreBlocker);
  684. RaiseLocalEvent(sender, ev, true);
  685. return ev.IgnoreBlocker;
  686. }
  687. private IEnumerable<INetChannel> GetDeadChatClients()
  688. {
  689. return Filter.Empty()
  690. .AddWhereAttachedEntity(HasComp<GhostComponent>)
  691. .Recipients
  692. .Union(_adminManager.ActiveAdmins)
  693. .Select(p => p.Channel);
  694. }
  695. private string SanitizeMessagePeriod(string message)
  696. {
  697. if (string.IsNullOrEmpty(message))
  698. return message;
  699. // Adds a period if the last character is a letter.
  700. if (char.IsLetter(message[^1]))
  701. message += ".";
  702. return message;
  703. }
  704. [ValidatePrototypeId<ReplacementAccentPrototype>]
  705. public const string ChatSanitize_Accent = "chatsanitize";
  706. public string SanitizeMessageReplaceWords(string message)
  707. {
  708. if (string.IsNullOrEmpty(message)) return message;
  709. var msg = message;
  710. msg = _wordreplacement.ApplyReplacements(msg, ChatSanitize_Accent);
  711. return msg;
  712. }
  713. /// <summary>
  714. /// Returns list of players and ranges for all players withing some range. Also returns observers with a range of -1.
  715. /// </summary>
  716. private Dictionary<ICommonSession, ICChatRecipientData> GetRecipients(EntityUid source, float voiceGetRange)
  717. {
  718. // TODO proper speech occlusion
  719. var recipients = new Dictionary<ICommonSession, ICChatRecipientData>();
  720. var ghostHearing = GetEntityQuery<GhostHearingComponent>();
  721. var xforms = GetEntityQuery<TransformComponent>();
  722. var transformSource = xforms.GetComponent(source);
  723. var sourceMapId = transformSource.MapID;
  724. var sourceCoords = transformSource.Coordinates;
  725. foreach (var player in _playerManager.Sessions)
  726. {
  727. if (player.AttachedEntity is not { Valid: true } playerEntity)
  728. continue;
  729. var transformEntity = xforms.GetComponent(playerEntity);
  730. if (transformEntity.MapID != sourceMapId)
  731. continue;
  732. var observer = ghostHearing.HasComponent(playerEntity);
  733. // even if they are a ghost hearer, in some situations we still need the range
  734. if (sourceCoords.TryDistance(EntityManager, transformEntity.Coordinates, out var distance) && distance < voiceGetRange)
  735. {
  736. recipients.Add(player, new ICChatRecipientData(distance, observer));
  737. continue;
  738. }
  739. if (observer)
  740. recipients.Add(player, new ICChatRecipientData(-1, true));
  741. }
  742. RaiseLocalEvent(new ExpandICChatRecipientsEvent(source, voiceGetRange, recipients));
  743. return recipients;
  744. }
  745. public readonly record struct ICChatRecipientData(float Range, bool Observer, bool? HideChatOverride = null)
  746. {
  747. }
  748. private string ObfuscateMessageReadability(string message, float chance)
  749. {
  750. var modifiedMessage = new StringBuilder(message);
  751. for (var i = 0; i < message.Length; i++)
  752. {
  753. if (char.IsWhiteSpace((modifiedMessage[i])))
  754. {
  755. continue;
  756. }
  757. if (_random.Prob(1 - chance))
  758. {
  759. modifiedMessage[i] = '~';
  760. }
  761. }
  762. return modifiedMessage.ToString();
  763. }
  764. public string BuildGibberishString(IReadOnlyList<char> charOptions, int length)
  765. {
  766. var sb = new StringBuilder();
  767. for (var i = 0; i < length; i++)
  768. {
  769. sb.Append(_random.Pick(charOptions));
  770. }
  771. return sb.ToString();
  772. }
  773. #endregion
  774. }
  775. /// <summary>
  776. /// This event is raised before chat messages are sent out to clients. This enables some systems to send the chat
  777. /// messages to otherwise out-of view entities (e.g. for multiple viewports from cameras).
  778. /// </summary>
  779. public record ExpandICChatRecipientsEvent(EntityUid Source, float VoiceRange, Dictionary<ICommonSession, ChatSystem.ICChatRecipientData> Recipients)
  780. {
  781. }
  782. /// <summary>
  783. /// Raised broadcast in order to transform speech.transmit
  784. /// </summary>
  785. public sealed class TransformSpeechEvent : EntityEventArgs
  786. {
  787. public EntityUid Sender;
  788. public string Message;
  789. public TransformSpeechEvent(EntityUid sender, string message)
  790. {
  791. Sender = sender;
  792. Message = message;
  793. }
  794. }
  795. public sealed class CheckIgnoreSpeechBlockerEvent : EntityEventArgs
  796. {
  797. public EntityUid Sender;
  798. public bool IgnoreBlocker;
  799. public CheckIgnoreSpeechBlockerEvent(EntityUid sender, bool ignoreBlocker)
  800. {
  801. Sender = sender;
  802. IgnoreBlocker = ignoreBlocker;
  803. }
  804. }
  805. /// <summary>
  806. /// Raised on an entity when it speaks, either through 'say' or 'whisper'.
  807. /// </summary>
  808. public sealed class EntitySpokeEvent : EntityEventArgs
  809. {
  810. public readonly EntityUid Source;
  811. public readonly string Message;
  812. public readonly string? ObfuscatedMessage; // not null if this was a whisper
  813. /// <summary>
  814. /// If the entity was trying to speak into a radio, this was the channel they were trying to access. If a radio
  815. /// message gets sent on this channel, this should be set to null to prevent duplicate messages.
  816. /// </summary>
  817. public RadioChannelPrototype? Channel;
  818. public EntitySpokeEvent(EntityUid source, string message, RadioChannelPrototype? channel, string? obfuscatedMessage)
  819. {
  820. Source = source;
  821. Message = message;
  822. Channel = channel;
  823. ObfuscatedMessage = obfuscatedMessage;
  824. }
  825. }
  826. /// <summary>
  827. /// InGame IC chat is for chat that is specifically ingame (not lobby) but is also in character, i.e. speaking.
  828. /// </summary>
  829. // ReSharper disable once InconsistentNaming
  830. public enum InGameICChatType : byte
  831. {
  832. Speak,
  833. Emote,
  834. Whisper
  835. }
  836. /// <summary>
  837. /// InGame OOC chat is for chat that is specifically ingame (not lobby) but is OOC, like deadchat or LOOC.
  838. /// </summary>
  839. public enum InGameOOCChatType : byte
  840. {
  841. Looc,
  842. Dead
  843. }
  844. /// <summary>
  845. /// Controls transmission of chat.
  846. /// </summary>
  847. public enum ChatTransmitRange : byte
  848. {
  849. /// Acts normal, ghosts can hear across the map, etc.
  850. Normal,
  851. /// Normal but ghosts are still range-limited.
  852. GhostRangeLimit,
  853. /// Hidden from the chat window.
  854. HideChat,
  855. /// Ghosts can't hear or see it at all. Regular players can if in-range.
  856. NoGhosts
  857. }