diff --git a/Database.cs b/Database.cs index 519ef47..f03d2be 100644 --- a/Database.cs +++ b/Database.cs @@ -73,12 +73,11 @@ 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, channel, sender_steamid64, sender_name, sender_team, sender_alive, + (message_key, sent_at, server_id, map, sender_steamid64, sender_name, sender_team, sender_alive, recipient_steamid64, recipient_name, recipient_team, recipient_alive, target, message, result) - 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), + 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), ("@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 8af6289..999992b 100644 --- a/Migrations/001_CreateTables.sql +++ b/Migrations/001_CreateTables.sql @@ -1,38 +1,30 @@ -- Whisper's tables, in CS2-SimpleAdmin's database. Applied once at load, tracked in wh_migrations. -- All times are UTC. --- 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). +-- 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. 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, - `channel` varchar(16) NOT NULL, - `sender_steamid64` bigint unsigned NULL, + `sender_steamid64` bigint unsigned NOT NULL, `sender_name` varchar(128) NOT NULL, - `sender_team` tinyint NULL, - `sender_alive` tinyint NULL, + `sender_team` tinyint NOT NULL, + `sender_alive` tinyint NOT NULL, `recipient_steamid64` bigint unsigned NULL, `recipient_name` varchar(128) NULL, `recipient_team` tinyint NULL, `recipient_alive` tinyint NULL, - `target` varchar(128) NULL, + `target` varchar(128) NOT 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 01c2438..252f4e3 100644 --- a/Ops.cs +++ b/Ops.cs @@ -11,35 +11,20 @@ namespace Whisper; [JsonDerivedType(typeof(BlockRemove), "block_remove")] public abstract record Op; -// 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 PlayerState(ulong SteamId, string Name, int Team, bool Alive); public sealed record MessageRecord( - string Key, DateTime At, int? ServerId, string Map, string Channel, PlayerState Sender, PlayerState? Recipient, - string? Target, string Message, string Result) : Op; + string Key, DateTime At, int? ServerId, string Map, 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 5a05b6b..3146a39 100644 --- a/README.md +++ b/README.md @@ -69,36 +69,19 @@ tables created on first load and tracked in `wh_migrations`. All times are UTC. ### History -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`, 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`. +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. `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, channel, sender_name, sender_steamid64, sender_team, sender_alive, - recipient_name, message, result +SELECT sent_at, map, sender_name, sender_steamid64, sender_team, sender_alive, + recipient_name, recipient_steamid64, recipient_team, recipient_alive, message, result FROM wh_messages ORDER BY sent_at DESC LIMIT 50; ``` diff --git a/Whisper.cs b/Whisper.cs index 6a72d31..8b3af45 100644 --- a/Whisper.cs +++ b/Whisper.cs @@ -15,15 +15,8 @@ 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. 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. +// /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. // // 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 @@ -34,7 +27,7 @@ namespace Whisper; public class Whisper : BasePlugin, IPluginConfig { public override string ModuleName => "Whisper"; - public override string ModuleVersion => "0.2.1"; + public override string ModuleVersion => "0.1.0"; public override string ModuleAuthor => "astra"; public override string ModuleDescription => "Private messages between players"; @@ -44,10 +37,6 @@ 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" }; @@ -80,8 +69,6 @@ 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); @@ -116,11 +103,8 @@ public class Whisper : BasePlugin, IPluginConfig _writer.Start(); } - // 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); + foreach (var player in Humans()) + LoadBlocks(player.SteamID); } public override void Unload(bool hotReload) @@ -216,73 +200,13 @@ 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 = ChatTrigger(text); + var trigger = CoreConfig.PublicChatTrigger.Concat(CoreConfig.SilentChatTrigger) + .FirstOrDefault(t => t.Length > 0 && text.StartsWith(t)); if (trigger == null) return HookResult.Continue; @@ -678,10 +602,7 @@ public class Whisper : BasePlugin, IPluginConfig // --- History -------------------------------------------------------------------- - 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) + private void Log(CCSPlayerController sender, CCSPlayerController? recipient, string target, string message, string result) { if (!Config.History || _writer == null) return; @@ -694,7 +615,7 @@ public class Whisper : BasePlugin, IPluginConfig { } _writer.Enqueue(new MessageRecord(Guid.NewGuid().ToString("N"), DateTime.UtcNow, serverId, Server.MapName ?? "", - channel, sender, recipient == null ? null : StateOf(recipient), target, message, result)); + StateOf(sender), recipient == null ? null : StateOf(recipient), target, message, result)); } [ConsoleCommand("css_whisper_history", "Turns keeping message history on or off")]