From 06823c2d47504ecb1b002b77379a6e43a861e3cf Mon Sep 17 00:00:00 2001 From: Astra Date: Mon, 5 Oct 2026 14:57:19 +0100 Subject: [PATCH 1/3] 0.2.0: keep all chat, @ staff chat and SimpleAdmin's say commands too wh_messages now holds every message with a channel (all, team, pm, staff, to_staff, css_say/asay/psay/csay/hsay/cssay) and NULL sender columns for the console. Seen by a catch-all listener added in Load, which CSS runs before CS2-SimpleAdmin's (added in OnAllPluginsLoaded), so gagged lines and @ chat are kept before SimpleAdmin stops or rewrites them. Replaces 001_CreateTables in place: drop wh_messages and wh_migrations first. --- Database.cs | 9 ++-- Migrations/001_CreateTables.sql | 24 ++++++--- Ops.cs | 21 ++++++-- README.md | 31 +++++++++--- Whisper.cs | 90 ++++++++++++++++++++++++++++++--- 5 files changed, 146 insertions(+), 29 deletions(-) diff --git a/Database.cs b/Database.cs index f03d2be..519ef47 100644 --- a/Database.cs +++ b/Database.cs @@ -73,11 +73,12 @@ public sealed class Database(string connectionString) var r = o.Recipient; await Exec(connection, token, """ INSERT IGNORE INTO wh_messages - (message_key, sent_at, server_id, map, sender_steamid64, sender_name, sender_team, sender_alive, + (message_key, sent_at, server_id, map, channel, sender_steamid64, sender_name, sender_team, sender_alive, recipient_steamid64, recipient_name, recipient_team, recipient_alive, target, message, result) - VALUES (@key, @at, @server, @map, @sid, @sname, @steam, @salive, @rid, @rname, @rteam, @ralive, @target, @message, @result) - """, ("@key", o.Key), ("@at", o.At), ("@server", o.ServerId), ("@map", Clip(o.Map, 64)), - ("@sid", o.Sender.SteamId), ("@sname", Clip(o.Sender.Name, 128)), ("@steam", o.Sender.Team), ("@salive", o.Sender.Alive), + VALUES (@key, @at, @server, @map, @channel, @sid, @sname, @steam, @salive, @rid, @rname, @rteam, @ralive, @target, @message, @result) + """, ("@key", o.Key), ("@at", o.At), ("@server", o.ServerId), ("@map", Clip(o.Map, 64)), ("@channel", Clip(o.Channel, 16)), + ("@sid", o.Sender.SteamId == 0 ? null : o.Sender.SteamId), ("@sname", Clip(o.Sender.Name, 128)), + ("@steam", o.Sender.Team), ("@salive", o.Sender.Alive), ("@rid", r?.SteamId), ("@rname", Clip(r?.Name, 128)), ("@rteam", r?.Team), ("@ralive", r?.Alive), ("@target", Clip(o.Target, 128)), ("@message", Clip(o.Message, 512)), ("@result", o.Result)); break; diff --git a/Migrations/001_CreateTables.sql b/Migrations/001_CreateTables.sql index 999992b..8af6289 100644 --- a/Migrations/001_CreateTables.sql +++ b/Migrations/001_CreateTables.sql @@ -1,30 +1,38 @@ -- Whisper's tables, in CS2-SimpleAdmin's database. Applied once at load, tracked in wh_migrations. -- All times are UTC. --- One row per private message attempt, sent or not (while History is on in Whisper.json). --- result: sent, blocked, gagged, cooldown, no_target, ambiguous, group_target, too_long. --- The recipient columns are NULL when no single player was found. team: 0 none, 1 spectator, --- 2 T, 3 CT. alive: 1 when on T/CT with a living pawn. target is what the sender typed for it. +-- Every chat message, private message and staff message (while History is on in Whisper.json). +-- channel: all, team (normal chat), pm (/pm and /r), staff (@ team chat from staff), to_staff +-- (@ team chat from a player), or the SimpleAdmin command used: css_say, css_asay, css_psay, +-- css_csay, css_hsay, css_cssay. +-- result: sent; gagged (blocked by a gag or silence); command (a ! or / chat command); and for pm, +-- blocked, cooldown, too_long, no_target, ambiguous or group_target. Staff commands are logged +-- as typed, so one the sender wasn't allowed to use is still "sent". +-- The sender columns are NULL for the server console (RCON, the web panel). The recipient columns +-- are for pm only, NULL when no single player was found. team: 0 none, 1 spectator, 2 T, 3 CT. +-- alive: 1 when on T/CT with a living pawn. target is what the sender typed for it (pm, css_psay). CREATE TABLE IF NOT EXISTS `wh_messages` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `message_key` char(32) NOT NULL, `sent_at` datetime NOT NULL, `server_id` int NULL, `map` varchar(64) NOT NULL, - `sender_steamid64` bigint unsigned NOT NULL, + `channel` varchar(16) NOT NULL, + `sender_steamid64` bigint unsigned NULL, `sender_name` varchar(128) NOT NULL, - `sender_team` tinyint NOT NULL, - `sender_alive` tinyint NOT NULL, + `sender_team` tinyint NULL, + `sender_alive` tinyint NULL, `recipient_steamid64` bigint unsigned NULL, `recipient_name` varchar(128) NULL, `recipient_team` tinyint NULL, `recipient_alive` tinyint NULL, - `target` varchar(128) NOT NULL, + `target` varchar(128) NULL, `message` varchar(512) NOT NULL, `result` varchar(16) NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uq_message_key` (`message_key`), KEY `ix_sent_at` (`sent_at`), + KEY `ix_channel` (`channel`, `sent_at`), KEY `ix_sender` (`sender_steamid64`, `sent_at`), KEY `ix_recipient` (`recipient_steamid64`, `sent_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; diff --git a/Ops.cs b/Ops.cs index 252f4e3..01c2438 100644 --- a/Ops.cs +++ b/Ops.cs @@ -11,20 +11,35 @@ namespace Whisper; [JsonDerivedType(typeof(BlockRemove), "block_remove")] public abstract record Op; -public sealed record PlayerState(ulong SteamId, string Name, int Team, bool Alive); +// A player as they were when the message was sent. Console: SteamId 0, no team. +public sealed record PlayerState(ulong SteamId, string Name, int? Team, bool? Alive) +{ + public static readonly PlayerState Console = new(0, "Console", null, null); +} public sealed record MessageRecord( - string Key, DateTime At, int? ServerId, string Map, PlayerState Sender, PlayerState? Recipient, - string Target, string Message, string Result) : Op; + string Key, DateTime At, int? ServerId, string Map, string Channel, PlayerState Sender, PlayerState? Recipient, + string? Target, string Message, string Result) : Op; public sealed record BlockAdd(ulong Blocker, ulong Blocked, string BlockedName, DateTime At) : Op; public sealed record BlockRemove(ulong Blocker, ulong Blocked) : Op; +// Where a message went (wh_messages.channel). SimpleAdmin's commands are logged by their own name. +public static class ChatChannel +{ + public const string All = "all"; + public const string Team = "team"; + public const string Pm = "pm"; + public const string Staff = "staff"; + public const string ToStaff = "to_staff"; +} + // What the message log calls each outcome (wh_messages.result). public static class Result { public const string Sent = "sent"; + public const string Command = "command"; public const string Blocked = "blocked"; public const string Gagged = "gagged"; public const string Cooldown = "cooldown"; diff --git a/README.md b/README.md index 3146a39..f6acebd 100644 --- a/README.md +++ b/README.md @@ -69,19 +69,36 @@ tables created on first load and tracked in `wh_migrations`. All times are UTC. ### History -With `History` on, each `/pm` and `/r`, sent or not, is a row in `wh_messages`: when (`sent_at`), -`server_id` (SimpleAdmin's), `map`, the sender's and recipient's SteamID64, name, team (0 none, -1 spectator, 2 T, 3 CT) and whether alive, the `target` as typed, the `message`, and the `result`: -`sent`, `blocked`, `gagged`, `cooldown`, `too_long`, `no_target`, `ambiguous` or `group_target`. The -recipient columns are `NULL` when no single player was found. +With `History` on, every message is a row in `wh_messages`, sent or not: + +| `channel` | What | +|---|---| +| `all`, `team` | Normal chat, including `!`/`/` commands (`result` `command`) other than the ones below | +| `pm` | `/pm`, `/w` and `/r` | +| `staff`, `to_staff` | `@` team chat, from staff (`@css/chat`) or from a player to staff | +| `css_say`, `css_asay`, `css_psay`, `css_csay`, `css_hsay`, `css_cssay` | SimpleAdmin's say commands, typed in console, sent over RCON (the web panel too), or as `!asay` and the like in chat. `@` in all chat from staff is `css_say`, as SimpleAdmin runs it | + +Each row has when (`sent_at`), `server_id` (SimpleAdmin's), `map`, the sender's SteamID64, name, +team (0 none, 1 spectator, 2 T, 3 CT) and whether alive (all `NULL` and "Console" for the server +console), the `message`, and the `result`: `sent`, `gagged` (stopped by a gag or silence; `@` team +chat gets through one), `command`, or for `pm` `blocked`, `cooldown`, `too_long`, `no_target`, +`ambiguous` or `group_target`. PMs also have the recipient's SteamID64, name, team and whether alive, +and `target` as typed (`css_psay` has `target` too). Staff commands are logged as typed, so one the +sender wasn't allowed to use still says `sent`. + +Chat is seen by a catch-all command listener added in `Load`. CSS runs catch-alls before any +per-command listener (CS2-Tags' `say`, say), in the order they were added, and stops at the first +that returns `Stop`. CS2-SimpleAdmin adds its catch-all (which stops gagged players and handles `@`) +in `OnAllPluginsLoaded`, after every plugin's `Load`, so Whisper always sees a line before it's +stopped or rewritten. Keep that in mind if SimpleAdmin ever moves its listener into `Load`. `css_whisper_history off` stops it at once and saves the setting to `Whisper.json`; rows already kept stay. Writes go in order on a background task and spool to `plugins/Whisper/spool.jsonl` while the database is down. ```sql -SELECT sent_at, map, sender_name, sender_steamid64, sender_team, sender_alive, - recipient_name, recipient_steamid64, recipient_team, recipient_alive, message, result +SELECT sent_at, map, channel, sender_name, sender_steamid64, sender_team, sender_alive, + recipient_name, message, result FROM wh_messages ORDER BY sent_at DESC LIMIT 50; ``` diff --git a/Whisper.cs b/Whisper.cs index 8b3af45..adb9440 100644 --- a/Whisper.cs +++ b/Whisper.cs @@ -15,8 +15,15 @@ using Microsoft.Extensions.Logging; namespace Whisper; // Private messages between players: /pm , /r to reply, and /block, /blocklist, -// /unblock. Lines use CS2-Tags' rank prefixes and colours from tags.json. Each message is kept in -// CS2-SimpleAdmin's database (wh_messages) while History is on, and blocks in wh_blocks. +// /unblock. Lines use CS2-Tags' rank prefixes and colours from tags.json. While History is on, every +// message is kept in CS2-SimpleAdmin's database (wh_messages): private ones, all and team chat, @ +// staff chat and SimpleAdmin's say commands. Blocks are in wh_blocks. +// +// Chat is seen by a catch-all command listener added in Load. CSS runs catch-alls before any +// per-command listener, in the order they were added, and stops at the first that returns Stop. +// CS2-SimpleAdmin adds its own (which stops gagged players' chat and handles @) in +// OnAllPluginsLoaded, after every plugin's Load, so this one always sees a line first. It only +// looks, never blocks. // // Chat commands are caught in a say listener rather than left to CSS's chat triggers: for "!pm" the // game prints the whole line, message and all, to everyone before CSS runs the command. Stopping @@ -27,7 +34,7 @@ namespace Whisper; public class Whisper : BasePlugin, IPluginConfig { public override string ModuleName => "Whisper"; - public override string ModuleVersion => "0.1.0"; + public override string ModuleVersion => "0.2.0"; public override string ModuleAuthor => "astra"; public override string ModuleDescription => "Private messages between players"; @@ -37,6 +44,10 @@ public class Whisper : BasePlugin, IPluginConfig private static readonly HashSet ChatCommands = new(StringComparer.OrdinalIgnoreCase) { "pm", "w", "r", "block", "blocklist", "unblock" }; + // SimpleAdmin's commands that send a message, logged under their own name as the channel. + private static readonly HashSet StaffCommands = new(StringComparer.OrdinalIgnoreCase) + { "css_say", "css_asay", "css_psay", "css_csay", "css_hsay", "css_cssay" }; + // Targets that mean more than one player. A PM goes to one. private static readonly HashSet GroupTargets = new(StringComparer.OrdinalIgnoreCase) { "@all", "@t", "@ct", "@spec", "@alive", "@dead", "@bot", "@bots", "@human", "@humans", "@!me" }; @@ -69,6 +80,8 @@ public class Whisper : BasePlugin, IPluginConfig _tags = new Tags(Path.Combine(Server.GameDirectory, "csgo", "addons", "counterstrikesharp", Config.TagsFile), message => Logger.LogWarning("{Message}", message)); + // In Load, not OnAllPluginsLoaded: see the top of this file. + AddCommandListener(null, OnAnyCommand, HookMode.Pre); AddCommandListener("say", OnSay, HookMode.Pre); AddCommandListener("say_team", OnSay, HookMode.Pre); RegisterEventHandler(OnPlayerConnectFull); @@ -200,13 +213,73 @@ public class Whisper : BasePlugin, IPluginConfig // --- Chat ----------------------------------------------------------------------- + // Logs chat and staff messages as they're sent. Never blocks anything. + private HookResult OnAnyCommand(CCSPlayerController? player, CommandInfo info) + { + if (!Config.History || _writer == null) + return HookResult.Continue; + var command = info.GetArg(0).ToLowerInvariant(); + + if (StaffCommands.Contains(command)) + { + // Typed in console, RCON (the web panel too), or "!asay" in chat, which CSS runs as css_asay. + var sender = player is { IsValid: true, IsBot: false } ? StateOf(player) : player == null ? PlayerState.Console : null; + if (sender == null) + return HookResult.Continue; + var rest = info.ArgString; + var target = command == "css_psay" ? NextToken(ref rest) : null; + var message = rest.Trim(); + if (message.Length > 0) + Log(command, sender, null, target, message, Result.Sent); + return HookResult.Continue; + } + + if (command is not ("say" or "say_team") || player is not { IsValid: true, IsBot: false }) + return HookResult.Continue; + var text = info.GetArg(1); + if (text.Length == 0) + return HookResult.Continue; + var team = command == "say_team"; + var channel = team ? ChatChannel.Team : ChatChannel.All; + + var trigger = ChatTrigger(text); + if (trigger != null) + { + var rest = text[trigger.Length..]; + var word = NextToken(ref rest).ToLowerInvariant(); + // Logged on their own: PMs where they're sent, "!asay" and the like as the command. + if (!ChatCommands.Contains(word) && !StaffCommands.Contains("css_" + word)) + Log(channel, StateOf(player), null, null, text, Result.Command); + return HookResult.Continue; + } + + if (text.StartsWith('@')) + { + var chatStaff = AdminManager.PlayerHasPermissions(player, "@css/chat"); + // SimpleAdmin runs this as css_say, which is logged then. + if (!team && chatStaff) + return HookResult.Continue; + // @ team chat goes to staff, and gets through a gag. + if (team) + { + Log(chatStaff ? ChatChannel.Staff : ChatChannel.ToStaff, StateOf(player), null, null, text[1..].Trim(), Result.Sent); + return HookResult.Continue; + } + } + + Log(channel, StateOf(player), null, null, text, IsGagged(player, out _) ? Result.Gagged : Result.Sent); + return HookResult.Continue; + } + + private static string? ChatTrigger(string text) => + CoreConfig.PublicChatTrigger.Concat(CoreConfig.SilentChatTrigger).FirstOrDefault(t => t.Length > 0 && text.StartsWith(t)); + private HookResult OnSay(CCSPlayerController? player, CommandInfo info) { if (player is not { IsValid: true, IsBot: false }) return HookResult.Continue; var text = info.GetArg(1); - var trigger = CoreConfig.PublicChatTrigger.Concat(CoreConfig.SilentChatTrigger) - .FirstOrDefault(t => t.Length > 0 && text.StartsWith(t)); + var trigger = ChatTrigger(text); if (trigger == null) return HookResult.Continue; @@ -602,7 +675,10 @@ public class Whisper : BasePlugin, IPluginConfig // --- History -------------------------------------------------------------------- - private void Log(CCSPlayerController sender, CCSPlayerController? recipient, string target, string message, string result) + private void Log(CCSPlayerController sender, CCSPlayerController? recipient, string target, string message, string result) => + Log(ChatChannel.Pm, StateOf(sender), recipient, target, message, result); + + private void Log(string channel, PlayerState sender, CCSPlayerController? recipient, string? target, string message, string result) { if (!Config.History || _writer == null) return; @@ -615,7 +691,7 @@ public class Whisper : BasePlugin, IPluginConfig { } _writer.Enqueue(new MessageRecord(Guid.NewGuid().ToString("N"), DateTime.UtcNow, serverId, Server.MapName ?? "", - StateOf(sender), recipient == null ? null : StateOf(recipient), target, message, result)); + channel, sender, recipient == null ? null : StateOf(recipient), target, message, result)); } [ConsoleCommand("css_whisper_history", "Turns keeping message history on or off")] From f500d2a9dbd42c1a654f655ab0ba56e823a29e1b Mon Sep 17 00:00:00 2001 From: Astra Date: Mon, 5 Oct 2026 14:57:24 +0100 Subject: [PATCH 2/3] README: typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f6acebd..5a05b6b 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ and `target` as typed (`css_psay` has `target` too). Staff commands are logged a sender wasn't allowed to use still says `sent`. Chat is seen by a catch-all command listener added in `Load`. CSS runs catch-alls before any -per-command listener (CS2-Tags' `say`, say), in the order they were added, and stops at the first +per-command listener (CS2-Tags' `say`, for one), in the order they were added, and stops at the first that returns `Stop`. CS2-SimpleAdmin adds its catch-all (which stops gagged players and handles `@`) in `OnAllPluginsLoaded`, after every plugin's `Load`, so Whisper always sees a line before it's stopped or rewritten. Keep that in mind if SimpleAdmin ever moves its listener into `Load`. From 63e07d12c8df3d182ab15e1f1baab758dc5acbef Mon Sep 17 00:00:00 2001 From: Astra Date: Wed, 7 Oct 2026 09:36:08 +0100 Subject: [PATCH 3/3] 0.2.1: don't read the player list in OnAllPluginsLoaded at server start It isn't readable before the server has set up (Global Variables not initialized yet). Online players' blocks are only read there on a hot reload; at start everyone's load as they join. --- Whisper.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Whisper.cs b/Whisper.cs index adb9440..6a72d31 100644 --- a/Whisper.cs +++ b/Whisper.cs @@ -34,7 +34,7 @@ namespace Whisper; public class Whisper : BasePlugin, IPluginConfig { public override string ModuleName => "Whisper"; - public override string ModuleVersion => "0.2.0"; + public override string ModuleVersion => "0.2.1"; public override string ModuleAuthor => "astra"; public override string ModuleDescription => "Private messages between players"; @@ -116,8 +116,11 @@ public class Whisper : BasePlugin, IPluginConfig _writer.Start(); } - foreach (var player in Humans()) - LoadBlocks(player.SteamID); + // Only on a hot reload: at server start nobody's connected yet, and the player list can't be + // read before the server has set up (it throws). Everyone's blocks load as they join. + if (hotReload) + foreach (var player in Humans()) + LoadBlocks(player.SteamID); } public override void Unload(bool hotReload)