From 8e2dac5db8e52ea29664b82ae33563a4fcce344f Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 21:45:49 -0400 Subject: [PATCH] bug: trust the organizer tag only from a flag, strip chat formatting, gag spectators first - web_chat renders the organizer tag only when the api's last token is 1, never from a line that starts with [organizer]; SwiftlyS2 splits on U+200B, so earlier tokens can be smuggled out of the quoted line - strip control bytes and SwiftlyS2 [colour]/[newline] tags from relayed web text and from the spectator all-chat rebroadcast (name, clan, message) - all chat checks the gag before the spectator rebroadcast, via MatchUtility.AllChatRoute; GagPlayer uses the same IsGagged check --- .../src/FiveStack.Commands/WebMessage.cs | 19 +- .../src/FiveStack.Events/GagPlayer.cs | 15 +- .../src/FiveStack.Events/PlayerChat.cs | 53 +++--- .../src/FiveStack.Utilities/MatchUtility.cs | 41 ++++- .../counterstrikesharp/test/ChatRelayTests.cs | 174 ++++++++++++++++++ .../src/FiveStack.Commands/WebMessage.cs | 16 +- .../swiftly/src/FiveStack.Events/GagPlayer.cs | 15 +- .../src/FiveStack.Events/PlayerChat.cs | 54 +++--- .../src/FiveStack.Utilities/MatchUtility.cs | 41 ++++- apps/swiftly/test/ChatRelayTests.cs | 174 ++++++++++++++++++ .../dotnet/FiveStack.Enums/eAllChatRoute.cs | 12 ++ .../dotnet/FiveStack.Utilities/ChatUtility.cs | 62 +++++++ 12 files changed, 554 insertions(+), 122 deletions(-) create mode 100644 apps/counterstrikesharp/test/ChatRelayTests.cs create mode 100644 apps/swiftly/test/ChatRelayTests.cs create mode 100644 shared/dotnet/FiveStack.Enums/eAllChatRoute.cs create mode 100644 shared/dotnet/FiveStack.Utilities/ChatUtility.cs diff --git a/apps/counterstrikesharp/src/FiveStack.Commands/WebMessage.cs b/apps/counterstrikesharp/src/FiveStack.Commands/WebMessage.cs index 29c7a402..7d465ab4 100644 --- a/apps/counterstrikesharp/src/FiveStack.Commands/WebMessage.cs +++ b/apps/counterstrikesharp/src/FiveStack.Commands/WebMessage.cs @@ -2,6 +2,7 @@ using CounterStrikeSharp.API.Core.Attributes.Registration; using CounterStrikeSharp.API.Modules.Commands; using CounterStrikeSharp.API.Modules.Utils; +using FiveStack.Utilities; namespace FiveStack; @@ -11,24 +12,20 @@ public partial class FiveStackPlugin [CommandHelper(whoCanExecute: CommandUsage.SERVER_ONLY)] public void OnWebMessage(CCSPlayerController? player, CommandInfo? command) { - if (command == null) + if (command == null || command.ArgCount < 2) { return; } - string message = command.ArgByIndex(1); + (string text, bool organizer) = ChatUtility.ParseWebChat( + Enumerable.Range(1, command.ArgCount - 1).Select(command.ArgByIndex).ToList() + ); - if (message == null) + if (organizer) { - return; - } - - if (message.StartsWith("[organizer]")) - { - message = - $" {ChatColors.Red}[organizer]{ChatColors.White} {message.Replace("[organizer]", "")}"; + text = $" {ChatColors.Red}{ChatUtility.OrganizerTag}{ChatColors.White} {text}"; } - _gameServer.Message(HudDestination.Chat, message); + _gameServer.Message(HudDestination.Chat, text); } } diff --git a/apps/counterstrikesharp/src/FiveStack.Events/GagPlayer.cs b/apps/counterstrikesharp/src/FiveStack.Events/GagPlayer.cs index 6086bc02..7c6c5b6f 100644 --- a/apps/counterstrikesharp/src/FiveStack.Events/GagPlayer.cs +++ b/apps/counterstrikesharp/src/FiveStack.Events/GagPlayer.cs @@ -28,19 +28,10 @@ public HookResult GagPlayer(CCSPlayerController? player, CommandInfo info) return HookResult.Continue; } - MatchMember? member = MatchUtility.GetMemberFromLineup( - matchData, - player.SteamID.ToString(), - player.PlayerName - ); - - if (member != null) + if (MatchUtility.IsGagged(matchData, player.SteamID.ToString(), player.PlayerName)) { - if (member.is_gagged) - { - player.PrintToChat(Localizer["gag.you_are_gagged"]); - return HookResult.Stop; - } + player.PrintToChat(Localizer["gag.you_are_gagged"]); + return HookResult.Stop; } return HookResult.Continue; diff --git a/apps/counterstrikesharp/src/FiveStack.Events/PlayerChat.cs b/apps/counterstrikesharp/src/FiveStack.Events/PlayerChat.cs index 54832196..22bd3e4d 100644 --- a/apps/counterstrikesharp/src/FiveStack.Events/PlayerChat.cs +++ b/apps/counterstrikesharp/src/FiveStack.Events/PlayerChat.cs @@ -2,6 +2,7 @@ using CounterStrikeSharp.API.Modules.Commands; using CounterStrikeSharp.API.Modules.Utils; using FiveStack.Entities; +using FiveStack.Enums; using FiveStack.Utilities; namespace FiveStack; @@ -41,50 +42,42 @@ bool teamOnly return HookResult.Continue; } - if (player.Team == CsTeam.Spectator) + eAllChatRoute route = MatchUtility.AllChatRoute( + _matchService.GetCurrentMatch()?.GetMatchData(), + player.SteamID.ToString(), + player.PlayerName, + player.Team == CsTeam.Spectator + ); + + if (route == eAllChatRoute.Block) + { + return HookResult.Stop; + } + + if (route == eAllChatRoute.Spectator) { PublishChatEvent(player, message); - string clan = string.IsNullOrEmpty(player.Clan) ? "" : $"[{player.Clan}]"; + string clan = string.IsNullOrEmpty(player.Clan) + ? "" + : ChatUtility.StripFormatting($"[{player.Clan}]"); + + string name = ChatUtility.StripFormatting(player.PlayerName); + string text = ChatUtility.StripFormatting(message); _gameServer.Message( HudDestination.Chat, - $" {ChatColors.Red}{clan}{ChatColors.White} {player.PlayerName}: {message}" + $" {ChatColors.Red}{clan}{ChatColors.White} {name}: {text}" ); return HookResult.Stop; } - MatchManager? match = _matchService.GetCurrentMatch(); - - if (match == null) - { - return HookResult.Continue; - } - - MatchData? matchData = match.GetMatchData(); - - if (matchData == null) + if (route == eAllChatRoute.Publish) { - return HookResult.Continue; - } - - MatchMember? member = MatchUtility.GetMemberFromLineup( - matchData, - player.SteamID.ToString(), - player.PlayerName - ); - - if (member != null) - { - if (member.is_gagged) - { - return HookResult.Stop; - } + PublishChatEvent(player, message); } - PublishChatEvent(player, message); - return HookResult.Continue; } diff --git a/apps/counterstrikesharp/src/FiveStack.Utilities/MatchUtility.cs b/apps/counterstrikesharp/src/FiveStack.Utilities/MatchUtility.cs index 9b0afb57..1d95126f 100644 --- a/apps/counterstrikesharp/src/FiveStack.Utilities/MatchUtility.cs +++ b/apps/counterstrikesharp/src/FiveStack.Utilities/MatchUtility.cs @@ -39,6 +39,31 @@ string playerName }); } + public static eAllChatRoute AllChatRoute( + MatchData? matchData, + string steamId, + string playerName, + bool spectator + ) + { + if (matchData != null && IsGagged(matchData, steamId, playerName)) + { + return eAllChatRoute.Block; + } + + if (spectator) + { + return eAllChatRoute.Spectator; + } + + if (matchData == null) + { + return eAllChatRoute.NoMatch; + } + + return eAllChatRoute.Publish; + } + public static string? GetTeamChatRelayLineupId( MatchData matchData, string steamId, @@ -50,13 +75,7 @@ string playerName return null; } - bool gagged = - GetMemberFromLineup(matchData, steamId, playerName)?.is_gagged == true - || matchData - .lineup_1.lineup_players.Concat(matchData.lineup_2.lineup_players) - .Any(member => member.is_gagged && member.steam_id == steamId); - - if (gagged) + if (IsGagged(matchData, steamId, playerName)) { return null; } @@ -64,6 +83,14 @@ string playerName return GetTeamChatLineupId(matchData, steamId, playerName); } + public static bool IsGagged(MatchData matchData, string steamId, string playerName) + { + return GetMemberFromLineup(matchData, steamId, playerName)?.is_gagged == true + || matchData + .lineup_1.lineup_players.Concat(matchData.lineup_2.lineup_players) + .Any(member => member.is_gagged && member.steam_id == steamId); + } + // A lineup_1 placeholder whose name prefixes a lineup_2 player would // otherwise claim them, so exact steam ids are matched before names. public static string? GetTeamChatLineupId( diff --git a/apps/counterstrikesharp/test/ChatRelayTests.cs b/apps/counterstrikesharp/test/ChatRelayTests.cs new file mode 100644 index 00000000..5dde386f --- /dev/null +++ b/apps/counterstrikesharp/test/ChatRelayTests.cs @@ -0,0 +1,174 @@ +using FiveStack.Entities; +using FiveStack.Enums; +using FiveStack.Utilities; +using Xunit; + +public class ChatRelayTests +{ + private const string Gagged = "76561198000000001"; + private const string Free = "76561198000000002"; + + private static MatchData BuildMatch() + { + return new MatchData + { + lineup_1 = new MatchLineUp + { + lineup_players = new List + { + new MatchMember { steam_id = Gagged, name = "Sub", is_gagged = true }, + }, + }, + lineup_2 = new MatchLineUp + { + lineup_players = new List + { + new MatchMember { steam_id = Free, name = "Free" }, + }, + }, + }; + } + + private static string[] Args(string line, params string[] rest) + { + return [line, .. rest]; + } + + [Fact] + public void ParseWebChat_NameStartingWithOrganizerTagIsNotAnOrganizer() + { + string[][] cases = [Args("[organizer] Mallory: gg"), Args("[organizer] Mallory: gg", "0")]; + + foreach (string[] args in cases) + { + (string text, bool organizer) = ChatUtility.ParseWebChat(args); + + Assert.False(organizer); + Assert.Equal("[organizer] Mallory: gg", text); + } + } + + [Fact] + public void ParseWebChat_TokensSmuggledOutOfTheLineAreNotTheFlag() + { + Assert.False(ChatUtility.ParseWebChat(Args("Mallory: gg", "1", "", "0")).Organizer); + Assert.False(ChatUtility.ParseWebChat(Args("Mallory: gg", "1 0")).Organizer); + } + + [Theory] + [InlineData("Alice: gg")] + [InlineData("[organizer] Alice: gg")] + public void ParseWebChat_OrganizerComesFromTheFlag(string line) + { + (string text, bool organizer) = ChatUtility.ParseWebChat(Args(line, "1")); + + Assert.True(organizer); + Assert.Equal("Alice: gg", text); + } + + [Theory] + [InlineData("Bob: [red]gg", "Bob: gg")] + [InlineData("Bob: [RED]g[Lime]g", "Bob: gg")] + [InlineData("[teamcolor]Bob: gg", "Bob: gg")] + [InlineData("Bob: gg[newline]Console: match cancelled", "Bob: ggConsole: match cancelled")] + [InlineData("Bob: [re[red]d]gg", "Bob: gg")] + [InlineData("Bob: [r\u0001ed]gg", "Bob: gg")] + [InlineData("Bob: \u0007g\u0002g\u0010", "Bob: gg")] + [InlineData("Bob: [AWP] {red} [organizer] gg", "Bob: [AWP] {red} [organizer] gg")] + public void ParseWebChat_StripsChatFormatting(string line, string expected) + { + Assert.Equal(expected, ChatUtility.ParseWebChat(Args(line)).Text); + } + + [Theory] + [InlineData("default")] + [InlineData("/")] + [InlineData("white")] + [InlineData("darkred")] + [InlineData("lightpurple")] + [InlineData("green")] + [InlineData("olive")] + [InlineData("lime")] + [InlineData("red")] + [InlineData("gray")] + [InlineData("grey")] + [InlineData("lightyellow")] + [InlineData("yellow")] + [InlineData("silver")] + [InlineData("bluegrey")] + [InlineData("lightblue")] + [InlineData("blue")] + [InlineData("darkblue")] + [InlineData("purple")] + [InlineData("magenta")] + [InlineData("lightred")] + [InlineData("gold")] + [InlineData("orange")] + [InlineData("teamcolor")] + [InlineData("newline")] + public void StripFormatting_RemovesEverySwiftlyTag(string tag) + { + string upper = tag.ToUpperInvariant(); + + Assert.Equal("Bob: gg", ChatUtility.StripFormatting($"Bob: [{tag}]g[{upper}]g")); + } + + [Fact] + public void AllChatRoute_GaggedSpectatorIsBlocked() + { + Assert.Equal( + eAllChatRoute.Block, + MatchUtility.AllChatRoute(BuildMatch(), Gagged, "Sub", spectator: true) + ); + } + + [Fact] + public void AllChatRoute_GaggedPlayerIsBlocked() + { + Assert.Equal( + eAllChatRoute.Block, + MatchUtility.AllChatRoute(BuildMatch(), Gagged, "Sub", spectator: false) + ); + } + + [Fact] + public void AllChatRoute_PlaceholderPrefixDoesNotHideAGag() + { + MatchData match = BuildMatch(); + match.lineup_1.lineup_players.Insert( + 0, + new MatchMember { steam_id = null, placeholder_name = "Subway" } + ); + + Assert.Equal( + eAllChatRoute.Block, + MatchUtility.AllChatRoute(match, Gagged, "Sub", spectator: false) + ); + } + + [Fact] + public void AllChatRoute_SpectatorIsRelayed() + { + Assert.Equal( + eAllChatRoute.Spectator, + MatchUtility.AllChatRoute(BuildMatch(), Free, "Free", spectator: true) + ); + Assert.Equal( + eAllChatRoute.Spectator, + MatchUtility.AllChatRoute(null, Free, "Free", spectator: true) + ); + } + + [Fact] + public void AllChatRoute_PlayerIsPublished() + { + Assert.Equal( + eAllChatRoute.Publish, + MatchUtility.AllChatRoute(BuildMatch(), Free, "Free", spectator: false) + ); + Assert.Equal( + eAllChatRoute.NoMatch, + MatchUtility.AllChatRoute(null, Free, "Free", spectator: false) + ); + } +} diff --git a/apps/swiftly/src/FiveStack.Commands/WebMessage.cs b/apps/swiftly/src/FiveStack.Commands/WebMessage.cs index d64694d0..a0c77d19 100644 --- a/apps/swiftly/src/FiveStack.Commands/WebMessage.cs +++ b/apps/swiftly/src/FiveStack.Commands/WebMessage.cs @@ -1,3 +1,4 @@ +using FiveStack.Utilities; using SwiftlyS2.Shared.Commands; using SwiftlyS2.Shared.Players; @@ -8,23 +9,18 @@ public partial class FiveStackPlugin [Command("web_chat", registerRaw: false, permission: "")] public void OnWebMessage(ICommandContext context) { - if (context.IsSentByPlayer) + if (context.IsSentByPlayer || context.Args.Length == 0) { return; } - string? message = context.Args.Length > 0 ? context.Args[0] : null; + (string text, bool organizer) = ChatUtility.ParseWebChat(context.Args); - if (message == null) + if (organizer) { - return; - } - - if (message.StartsWith("[organizer]")) - { - message = $" [red][organizer][white] {message.Replace("[organizer]", "")}"; + text = $" [red]{ChatUtility.OrganizerTag}[white] {text}"; } - _gameServer.Message(MessageType.Chat, message); + _gameServer.Message(MessageType.Chat, text); } } diff --git a/apps/swiftly/src/FiveStack.Events/GagPlayer.cs b/apps/swiftly/src/FiveStack.Events/GagPlayer.cs index 07e8e62e..87a309ab 100644 --- a/apps/swiftly/src/FiveStack.Events/GagPlayer.cs +++ b/apps/swiftly/src/FiveStack.Events/GagPlayer.cs @@ -28,19 +28,10 @@ public HookResult GagPlayer(IPlayer? player, string message, bool teamOnly) return HookResult.Continue; } - MatchMember? member = MatchUtility.GetMemberFromLineup( - matchData, - player.SteamID.ToString(), - player.Name - ); - - if (member != null) + if (MatchUtility.IsGagged(matchData, player.SteamID.ToString(), player.Name)) { - if (member.is_gagged) - { - _gameServer.Message(MessageType.Chat, _localizer["gag.you_are_gagged"], player); - return HookResult.Stop; - } + _gameServer.Message(MessageType.Chat, _localizer["gag.you_are_gagged"], player); + return HookResult.Stop; } return HookResult.Continue; diff --git a/apps/swiftly/src/FiveStack.Events/PlayerChat.cs b/apps/swiftly/src/FiveStack.Events/PlayerChat.cs index 44a674ca..77f4154c 100644 --- a/apps/swiftly/src/FiveStack.Events/PlayerChat.cs +++ b/apps/swiftly/src/FiveStack.Events/PlayerChat.cs @@ -1,4 +1,5 @@ using FiveStack.Entities; +using FiveStack.Enums; using FiveStack.Utilities; using SwiftlyS2.Shared.Misc; using SwiftlyS2.Shared.Players; @@ -22,52 +23,39 @@ public HookResult OnPlayerChat(IPlayer? player, string message, bool teamOnly) return HookResult.Continue; } - if (player.Controller.Team == Team.Spectator) + eAllChatRoute route = MatchUtility.AllChatRoute( + _matchService.GetCurrentMatch()?.GetMatchData(), + player.SteamID.ToString(), + player.Name, + player.Controller.Team == Team.Spectator + ); + + if (route == eAllChatRoute.Block) + { + return HookResult.Stop; + } + + if (route == eAllChatRoute.Spectator) { PublishChatEvent(player, message); string clan = string.IsNullOrEmpty(player.Controller.Clan) ? "" - : $"[{player.Controller.Clan}]"; - - _gameServer.Message( - MessageType.Chat, - $" [red]{clan}[white] {player.Name}: {message}" - ); - - return HookResult.Stop; - } - - MatchManager? match = _matchService.GetCurrentMatch(); + : ChatUtility.StripFormatting($"[{player.Controller.Clan}]"); - if (match == null) - { - return HookResult.Continue; - } + string name = ChatUtility.StripFormatting(player.Name); + string text = ChatUtility.StripFormatting(message); - MatchData? matchData = match.GetMatchData(); + _gameServer.Message(MessageType.Chat, $" [red]{clan}[white] {name}: {text}"); - if (matchData == null) - { - return HookResult.Continue; + return HookResult.Stop; } - MatchMember? member = MatchUtility.GetMemberFromLineup( - matchData, - player.SteamID.ToString(), - player.Name - ); - - if (member != null) + if (route == eAllChatRoute.Publish) { - if (member.is_gagged) - { - return HookResult.Stop; - } + PublishChatEvent(player, message); } - PublishChatEvent(player, message); - return HookResult.Continue; } diff --git a/apps/swiftly/src/FiveStack.Utilities/MatchUtility.cs b/apps/swiftly/src/FiveStack.Utilities/MatchUtility.cs index 08777b4d..33df3874 100644 --- a/apps/swiftly/src/FiveStack.Utilities/MatchUtility.cs +++ b/apps/swiftly/src/FiveStack.Utilities/MatchUtility.cs @@ -48,6 +48,31 @@ string playerName }); } + public static eAllChatRoute AllChatRoute( + MatchData? matchData, + string steamId, + string playerName, + bool spectator + ) + { + if (matchData != null && IsGagged(matchData, steamId, playerName)) + { + return eAllChatRoute.Block; + } + + if (spectator) + { + return eAllChatRoute.Spectator; + } + + if (matchData == null) + { + return eAllChatRoute.NoMatch; + } + + return eAllChatRoute.Publish; + } + public static string? GetTeamChatRelayLineupId( MatchData matchData, string steamId, @@ -59,13 +84,7 @@ string playerName return null; } - bool gagged = - GetMemberFromLineup(matchData, steamId, playerName)?.is_gagged == true - || matchData - .lineup_1.lineup_players.Concat(matchData.lineup_2.lineup_players) - .Any(member => member.is_gagged && member.steam_id == steamId); - - if (gagged) + if (IsGagged(matchData, steamId, playerName)) { return null; } @@ -73,6 +92,14 @@ string playerName return GetTeamChatLineupId(matchData, steamId, playerName); } + public static bool IsGagged(MatchData matchData, string steamId, string playerName) + { + return GetMemberFromLineup(matchData, steamId, playerName)?.is_gagged == true + || matchData + .lineup_1.lineup_players.Concat(matchData.lineup_2.lineup_players) + .Any(member => member.is_gagged && member.steam_id == steamId); + } + // A lineup_1 placeholder whose name prefixes a lineup_2 player would // otherwise claim them, so exact steam ids are matched before names. public static string? GetTeamChatLineupId( diff --git a/apps/swiftly/test/ChatRelayTests.cs b/apps/swiftly/test/ChatRelayTests.cs new file mode 100644 index 00000000..5dde386f --- /dev/null +++ b/apps/swiftly/test/ChatRelayTests.cs @@ -0,0 +1,174 @@ +using FiveStack.Entities; +using FiveStack.Enums; +using FiveStack.Utilities; +using Xunit; + +public class ChatRelayTests +{ + private const string Gagged = "76561198000000001"; + private const string Free = "76561198000000002"; + + private static MatchData BuildMatch() + { + return new MatchData + { + lineup_1 = new MatchLineUp + { + lineup_players = new List + { + new MatchMember { steam_id = Gagged, name = "Sub", is_gagged = true }, + }, + }, + lineup_2 = new MatchLineUp + { + lineup_players = new List + { + new MatchMember { steam_id = Free, name = "Free" }, + }, + }, + }; + } + + private static string[] Args(string line, params string[] rest) + { + return [line, .. rest]; + } + + [Fact] + public void ParseWebChat_NameStartingWithOrganizerTagIsNotAnOrganizer() + { + string[][] cases = [Args("[organizer] Mallory: gg"), Args("[organizer] Mallory: gg", "0")]; + + foreach (string[] args in cases) + { + (string text, bool organizer) = ChatUtility.ParseWebChat(args); + + Assert.False(organizer); + Assert.Equal("[organizer] Mallory: gg", text); + } + } + + [Fact] + public void ParseWebChat_TokensSmuggledOutOfTheLineAreNotTheFlag() + { + Assert.False(ChatUtility.ParseWebChat(Args("Mallory: gg", "1", "", "0")).Organizer); + Assert.False(ChatUtility.ParseWebChat(Args("Mallory: gg", "1 0")).Organizer); + } + + [Theory] + [InlineData("Alice: gg")] + [InlineData("[organizer] Alice: gg")] + public void ParseWebChat_OrganizerComesFromTheFlag(string line) + { + (string text, bool organizer) = ChatUtility.ParseWebChat(Args(line, "1")); + + Assert.True(organizer); + Assert.Equal("Alice: gg", text); + } + + [Theory] + [InlineData("Bob: [red]gg", "Bob: gg")] + [InlineData("Bob: [RED]g[Lime]g", "Bob: gg")] + [InlineData("[teamcolor]Bob: gg", "Bob: gg")] + [InlineData("Bob: gg[newline]Console: match cancelled", "Bob: ggConsole: match cancelled")] + [InlineData("Bob: [re[red]d]gg", "Bob: gg")] + [InlineData("Bob: [r\u0001ed]gg", "Bob: gg")] + [InlineData("Bob: \u0007g\u0002g\u0010", "Bob: gg")] + [InlineData("Bob: [AWP] {red} [organizer] gg", "Bob: [AWP] {red} [organizer] gg")] + public void ParseWebChat_StripsChatFormatting(string line, string expected) + { + Assert.Equal(expected, ChatUtility.ParseWebChat(Args(line)).Text); + } + + [Theory] + [InlineData("default")] + [InlineData("/")] + [InlineData("white")] + [InlineData("darkred")] + [InlineData("lightpurple")] + [InlineData("green")] + [InlineData("olive")] + [InlineData("lime")] + [InlineData("red")] + [InlineData("gray")] + [InlineData("grey")] + [InlineData("lightyellow")] + [InlineData("yellow")] + [InlineData("silver")] + [InlineData("bluegrey")] + [InlineData("lightblue")] + [InlineData("blue")] + [InlineData("darkblue")] + [InlineData("purple")] + [InlineData("magenta")] + [InlineData("lightred")] + [InlineData("gold")] + [InlineData("orange")] + [InlineData("teamcolor")] + [InlineData("newline")] + public void StripFormatting_RemovesEverySwiftlyTag(string tag) + { + string upper = tag.ToUpperInvariant(); + + Assert.Equal("Bob: gg", ChatUtility.StripFormatting($"Bob: [{tag}]g[{upper}]g")); + } + + [Fact] + public void AllChatRoute_GaggedSpectatorIsBlocked() + { + Assert.Equal( + eAllChatRoute.Block, + MatchUtility.AllChatRoute(BuildMatch(), Gagged, "Sub", spectator: true) + ); + } + + [Fact] + public void AllChatRoute_GaggedPlayerIsBlocked() + { + Assert.Equal( + eAllChatRoute.Block, + MatchUtility.AllChatRoute(BuildMatch(), Gagged, "Sub", spectator: false) + ); + } + + [Fact] + public void AllChatRoute_PlaceholderPrefixDoesNotHideAGag() + { + MatchData match = BuildMatch(); + match.lineup_1.lineup_players.Insert( + 0, + new MatchMember { steam_id = null, placeholder_name = "Subway" } + ); + + Assert.Equal( + eAllChatRoute.Block, + MatchUtility.AllChatRoute(match, Gagged, "Sub", spectator: false) + ); + } + + [Fact] + public void AllChatRoute_SpectatorIsRelayed() + { + Assert.Equal( + eAllChatRoute.Spectator, + MatchUtility.AllChatRoute(BuildMatch(), Free, "Free", spectator: true) + ); + Assert.Equal( + eAllChatRoute.Spectator, + MatchUtility.AllChatRoute(null, Free, "Free", spectator: true) + ); + } + + [Fact] + public void AllChatRoute_PlayerIsPublished() + { + Assert.Equal( + eAllChatRoute.Publish, + MatchUtility.AllChatRoute(BuildMatch(), Free, "Free", spectator: false) + ); + Assert.Equal( + eAllChatRoute.NoMatch, + MatchUtility.AllChatRoute(null, Free, "Free", spectator: false) + ); + } +} diff --git a/shared/dotnet/FiveStack.Enums/eAllChatRoute.cs b/shared/dotnet/FiveStack.Enums/eAllChatRoute.cs new file mode 100644 index 00000000..4c08eaf9 --- /dev/null +++ b/shared/dotnet/FiveStack.Enums/eAllChatRoute.cs @@ -0,0 +1,12 @@ +namespace FiveStack.Enums; + +public enum eAllChatRoute +{ + Block, + + Spectator, + + Publish, + + NoMatch, +} diff --git a/shared/dotnet/FiveStack.Utilities/ChatUtility.cs b/shared/dotnet/FiveStack.Utilities/ChatUtility.cs new file mode 100644 index 00000000..289a880d --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/ChatUtility.cs @@ -0,0 +1,62 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace FiveStack.Utilities +{ + public static class ChatUtility + { + public const string OrganizerTag = "[organizer]"; + + public const string OrganizerFlag = "1"; + + // SwiftlyS2 turns these into colour bytes, case-insensitively, and splits + // a chat line on [newline]. On either runtime the engine reads bytes + // below 0x20 as colours. + private static readonly Regex FormattingTags = new( + @"\[(?:default|/|white|darkred|lightpurple|green|olive|lime|red|gray|grey|lightyellow|yellow|silver|bluegrey|lightblue|blue|darkblue|purple|magenta|lightred|gold|orange|teamcolor|newline)\]", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant + ); + + // Only the flag earns the organizer tag, never the line: the line starts + // with the sender's own name. The flag is the last token because SwiftlyS2 + // also splits on U+200B, so a chatter can push tokens out of the quoted + // line, just never past the api's own last one. The api still prefixes + // flagged lines with the tag for plugins that predate the flag. + public static (string Text, bool Organizer) ParseWebChat(IReadOnlyList args) + { + string line = args[0]; + bool organizer = args.Count > 1 && args[^1] == OrganizerFlag; + + if (organizer && line.StartsWith(OrganizerTag)) + { + line = line[OrganizerTag.Length..]; + } + + return (StripFormatting(line).Trim(), organizer); + } + + public static string StripFormatting(string text) + { + StringBuilder plain = new(text.Length); + + foreach (char character in text) + { + if (!char.IsControl(character)) + { + plain.Append(character); + } + } + + string stripped = plain.ToString(); + string previous; + + do + { + previous = stripped; + stripped = FormattingTags.Replace(stripped, ""); + } while (stripped != previous); + + return stripped; + } + } +}