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")]