SharedChatSystem.cs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. using System.Collections.Frozen;
  2. using System.Text.RegularExpressions;
  3. using Content.Shared.Popups;
  4. using Content.Shared.Radio;
  5. using Content.Shared.Speech;
  6. using Robust.Shared.Prototypes;
  7. using Robust.Shared.Utility;
  8. namespace Content.Shared.Chat;
  9. public abstract class SharedChatSystem : EntitySystem
  10. {
  11. public const char RadioCommonPrefix = ';';
  12. public const char RadioChannelPrefix = ':';
  13. public const char RadioChannelAltPrefix = '.';
  14. public const char LocalPrefix = '>';
  15. public const char ConsolePrefix = '/';
  16. public const char DeadPrefix = '\\';
  17. public const char LOOCPrefix = '(';
  18. public const char OOCPrefix = '[';
  19. public const char EmotesPrefix = '@';
  20. public const char EmotesAltPrefix = '*';
  21. public const char AdminPrefix = ']';
  22. public const char WhisperPrefix = ',';
  23. public const char DefaultChannelKey = 'h';
  24. [ValidatePrototypeId<RadioChannelPrototype>]
  25. public const string CommonChannel = "Common";
  26. public static string DefaultChannelPrefix = $"{RadioChannelPrefix}{DefaultChannelKey}";
  27. [ValidatePrototypeId<SpeechVerbPrototype>]
  28. public const string DefaultSpeechVerb = "Default";
  29. [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
  30. [Dependency] private readonly SharedPopupSystem _popup = default!;
  31. /// <summary>
  32. /// Cache of the keycodes for faster lookup.
  33. /// </summary>
  34. private FrozenDictionary<char, RadioChannelPrototype> _keyCodes = default!;
  35. public override void Initialize()
  36. {
  37. base.Initialize();
  38. DebugTools.Assert(_prototypeManager.HasIndex<RadioChannelPrototype>(CommonChannel));
  39. SubscribeLocalEvent<PrototypesReloadedEventArgs>(OnPrototypeReload);
  40. CacheRadios();
  41. }
  42. protected virtual void OnPrototypeReload(PrototypesReloadedEventArgs obj)
  43. {
  44. if (obj.WasModified<RadioChannelPrototype>())
  45. CacheRadios();
  46. }
  47. private void CacheRadios()
  48. {
  49. _keyCodes = _prototypeManager.EnumeratePrototypes<RadioChannelPrototype>()
  50. .ToFrozenDictionary(x => x.KeyCode);
  51. }
  52. /// <summary>
  53. /// Attempts to find an applicable <see cref="SpeechVerbPrototype"/> for a speaking entity's message.
  54. /// If one is not found, returns <see cref="DefaultSpeechVerb"/>.
  55. /// </summary>
  56. public SpeechVerbPrototype GetSpeechVerb(EntityUid source, string message, SpeechComponent? speech = null)
  57. {
  58. if (!Resolve(source, ref speech, false))
  59. return _prototypeManager.Index<SpeechVerbPrototype>(DefaultSpeechVerb);
  60. // check for a suffix-applicable speech verb
  61. SpeechVerbPrototype? current = null;
  62. foreach (var (str, id) in speech.SuffixSpeechVerbs)
  63. {
  64. var proto = _prototypeManager.Index<SpeechVerbPrototype>(id);
  65. if (message.EndsWith(Loc.GetString(str)) && proto.Priority >= (current?.Priority ?? 0))
  66. {
  67. current = proto;
  68. }
  69. }
  70. // if no applicable suffix verb return the normal one used by the entity
  71. return current ?? _prototypeManager.Index<SpeechVerbPrototype>(speech.SpeechVerb);
  72. }
  73. /// <summary>
  74. /// Splits the input message into a radio prefix part and the rest to preserve it during sanitization.
  75. /// </summary>
  76. /// <remarks>
  77. /// This is primarily for the chat emote sanitizer, which can match against ":b" as an emote, which is a valid radio keycode.
  78. /// </remarks>
  79. public void GetRadioKeycodePrefix(EntityUid source,
  80. string input,
  81. out string output,
  82. out string prefix)
  83. {
  84. prefix = string.Empty;
  85. output = input;
  86. // If the string is less than 2, then it's probably supposed to be an emote.
  87. // No one is sending empty radio messages!
  88. if (input.Length <= 2)
  89. return;
  90. if (!(input.StartsWith(RadioChannelPrefix) || input.StartsWith(RadioChannelAltPrefix)))
  91. return;
  92. if (!_keyCodes.TryGetValue(char.ToLower(input[1]), out _))
  93. return;
  94. prefix = input[..2];
  95. output = input[2..];
  96. }
  97. /// <summary>
  98. /// Attempts to resolve radio prefixes in chat messages (e.g., remove a leading ":e" and resolve the requested
  99. /// channel. Returns true if a radio message was attempted, even if the channel is invalid.
  100. /// </summary>
  101. /// <param name="source">Source of the message</param>
  102. /// <param name="input">The message to be modified</param>
  103. /// <param name="output">The modified message</param>
  104. /// <param name="channel">The channel that was requested, if any</param>
  105. /// <param name="quiet">Whether or not to generate an informative pop-up message.</param>
  106. /// <returns></returns>
  107. public bool TryProccessRadioMessage(
  108. EntityUid source,
  109. string input,
  110. out string output,
  111. out RadioChannelPrototype? channel,
  112. bool quiet = false)
  113. {
  114. output = input.Trim();
  115. channel = null;
  116. if (input.Length == 0)
  117. return false;
  118. if (input.StartsWith(RadioCommonPrefix))
  119. {
  120. output = SanitizeMessageCapital(input[1..].TrimStart());
  121. channel = _prototypeManager.Index<RadioChannelPrototype>(CommonChannel);
  122. return true;
  123. }
  124. if (!(input.StartsWith(RadioChannelPrefix) || input.StartsWith(RadioChannelAltPrefix)))
  125. return false;
  126. if (input.Length < 2 || char.IsWhiteSpace(input[1]))
  127. {
  128. output = SanitizeMessageCapital(input[1..].TrimStart());
  129. if (!quiet)
  130. _popup.PopupEntity(Loc.GetString("chat-manager-no-radio-key"), source, source);
  131. return true;
  132. }
  133. var channelKey = input[1];
  134. channelKey = char.ToLower(channelKey);
  135. output = SanitizeMessageCapital(input[2..].TrimStart());
  136. if (channelKey == DefaultChannelKey)
  137. {
  138. var ev = new GetDefaultRadioChannelEvent();
  139. RaiseLocalEvent(source, ev);
  140. if (ev.Channel != null)
  141. _prototypeManager.TryIndex(ev.Channel, out channel);
  142. return true;
  143. }
  144. if (!_keyCodes.TryGetValue(channelKey, out channel) && !quiet)
  145. {
  146. var msg = Loc.GetString("chat-manager-no-such-channel", ("key", channelKey));
  147. _popup.PopupEntity(msg, source, source);
  148. }
  149. return true;
  150. }
  151. public string SanitizeMessageCapital(string message)
  152. {
  153. if (string.IsNullOrEmpty(message))
  154. return message;
  155. // Capitalize first letter
  156. message = OopsConcat(char.ToUpper(message[0]).ToString(), message.Remove(0, 1));
  157. return message;
  158. }
  159. private static string OopsConcat(string a, string b)
  160. {
  161. // This exists to prevent Roslyn being clever and compiling something that fails sandbox checks.
  162. return a + b;
  163. }
  164. public string SanitizeMessageCapitalizeTheWordI(string message, string theWordI = "i")
  165. {
  166. if (string.IsNullOrEmpty(message))
  167. return message;
  168. for
  169. (
  170. var index = message.IndexOf(theWordI);
  171. index != -1;
  172. index = message.IndexOf(theWordI, index + 1)
  173. )
  174. {
  175. // Stops the code If It's tryIng to capItalIze the letter I In the mIddle of words
  176. // Repeating the code twice is the simplest option
  177. if (index + 1 < message.Length && char.IsLetter(message[index + 1]))
  178. continue;
  179. if (index - 1 >= 0 && char.IsLetter(message[index - 1]))
  180. continue;
  181. var beforeTarget = message.Substring(0, index);
  182. var target = message.Substring(index, theWordI.Length);
  183. var afterTarget = message.Substring(index + theWordI.Length);
  184. message = beforeTarget + target.ToUpper() + afterTarget;
  185. }
  186. return message;
  187. }
  188. public static string SanitizeAnnouncement(string message, int maxLength = 0, int maxNewlines = 2)
  189. {
  190. var trimmed = message.Trim();
  191. if (maxLength > 0 && trimmed.Length > maxLength)
  192. {
  193. trimmed = $"{message[..maxLength]}...";
  194. }
  195. // No more than max newlines, other replaced to spaces
  196. if (maxNewlines > 0)
  197. {
  198. var chars = trimmed.ToCharArray();
  199. var newlines = 0;
  200. for (var i = 0; i < chars.Length; i++)
  201. {
  202. if (chars[i] != '\n')
  203. continue;
  204. if (newlines >= maxNewlines)
  205. chars[i] = ' ';
  206. newlines++;
  207. }
  208. return new string(chars);
  209. }
  210. return trimmed;
  211. }
  212. public static string InjectTagInsideTag(ChatMessage message, string outerTag, string innerTag, string? tagParameter)
  213. {
  214. var rawmsg = message.WrappedMessage;
  215. var tagStart = rawmsg.IndexOf($"[{outerTag}]");
  216. var tagEnd = rawmsg.IndexOf($"[/{outerTag}]");
  217. if (tagStart < 0 || tagEnd < 0) //If the outer tag is not found, the injection is not performed
  218. return rawmsg;
  219. tagStart += outerTag.Length + 2;
  220. string innerTagProcessed = tagParameter != null ? $"[{innerTag}={tagParameter}]" : $"[{innerTag}]";
  221. rawmsg = rawmsg.Insert(tagEnd, $"[/{innerTag}]");
  222. rawmsg = rawmsg.Insert(tagStart, innerTagProcessed);
  223. return rawmsg;
  224. }
  225. /// <summary>
  226. /// Injects a tag around all found instances of a specific string in a ChatMessage.
  227. /// Excludes strings inside other tags and brackets.
  228. /// </summary>
  229. public static string InjectTagAroundString(ChatMessage message, string targetString, string tag, string? tagParameter)
  230. {
  231. var rawmsg = message.WrappedMessage;
  232. rawmsg = Regex.Replace(rawmsg, "(?i)(" + targetString + ")(?-i)(?![^[]*])", $"[{tag}={tagParameter}]$1[/{tag}]");
  233. return rawmsg;
  234. }
  235. public static string GetStringInsideTag(ChatMessage message, string tag)
  236. {
  237. var rawmsg = message.WrappedMessage;
  238. var tagStart = rawmsg.IndexOf($"[{tag}]");
  239. var tagEnd = rawmsg.IndexOf($"[/{tag}]");
  240. if (tagStart < 0 || tagEnd < 0)
  241. return "";
  242. tagStart += tag.Length + 2;
  243. return rawmsg.Substring(tagStart, tagEnd - tagStart);
  244. }
  245. }