Compare commits

...

3 commits
v0.1.0 ... main

Author SHA1 Message Date
63e07d12c8 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.
2026-10-07 09:36:08 +01:00
f500d2a9db README: typo 2026-10-05 14:57:24 +01:00
06823c2d47 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.
2026-10-05 14:57:19 +01:00
5 changed files with 151 additions and 31 deletions

View file

@ -73,11 +73,12 @@ public sealed class Database(string connectionString)
var r = o.Recipient; var r = o.Recipient;
await Exec(connection, token, """ await Exec(connection, token, """
INSERT IGNORE INTO wh_messages 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) 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) 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)), """, ("@key", o.Key), ("@at", o.At), ("@server", o.ServerId), ("@map", Clip(o.Map, 64)), ("@channel", Clip(o.Channel, 16)),
("@sid", o.Sender.SteamId), ("@sname", Clip(o.Sender.Name, 128)), ("@steam", o.Sender.Team), ("@salive", o.Sender.Alive), ("@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), ("@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)); ("@target", Clip(o.Target, 128)), ("@message", Clip(o.Message, 512)), ("@result", o.Result));
break; break;

View file

@ -1,30 +1,38 @@
-- Whisper's tables, in CS2-SimpleAdmin's database. Applied once at load, tracked in wh_migrations. -- Whisper's tables, in CS2-SimpleAdmin's database. Applied once at load, tracked in wh_migrations.
-- All times are UTC. -- All times are UTC.
-- One row per private message attempt, sent or not (while History is on in Whisper.json). -- Every chat message, private message and staff message (while History is on in Whisper.json).
-- result: sent, blocked, gagged, cooldown, no_target, ambiguous, group_target, too_long. -- channel: all, team (normal chat), pm (/pm and /r), staff (@ team chat from staff), to_staff
-- The recipient columns are NULL when no single player was found. team: 0 none, 1 spectator, -- (@ team chat from a player), or the SimpleAdmin command used: css_say, css_asay, css_psay,
-- 2 T, 3 CT. alive: 1 when on T/CT with a living pawn. target is what the sender typed for it. -- 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` ( CREATE TABLE IF NOT EXISTS `wh_messages` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT, `id` bigint unsigned NOT NULL AUTO_INCREMENT,
`message_key` char(32) NOT NULL, `message_key` char(32) NOT NULL,
`sent_at` datetime NOT NULL, `sent_at` datetime NOT NULL,
`server_id` int NULL, `server_id` int NULL,
`map` varchar(64) NOT 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_name` varchar(128) NOT NULL,
`sender_team` tinyint NOT NULL, `sender_team` tinyint NULL,
`sender_alive` tinyint NOT NULL, `sender_alive` tinyint NULL,
`recipient_steamid64` bigint unsigned NULL, `recipient_steamid64` bigint unsigned NULL,
`recipient_name` varchar(128) NULL, `recipient_name` varchar(128) NULL,
`recipient_team` tinyint NULL, `recipient_team` tinyint NULL,
`recipient_alive` tinyint NULL, `recipient_alive` tinyint NULL,
`target` varchar(128) NOT NULL, `target` varchar(128) NULL,
`message` varchar(512) NOT NULL, `message` varchar(512) NOT NULL,
`result` varchar(16) NOT NULL, `result` varchar(16) NOT NULL,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `uq_message_key` (`message_key`), UNIQUE KEY `uq_message_key` (`message_key`),
KEY `ix_sent_at` (`sent_at`), KEY `ix_sent_at` (`sent_at`),
KEY `ix_channel` (`channel`, `sent_at`),
KEY `ix_sender` (`sender_steamid64`, `sent_at`), KEY `ix_sender` (`sender_steamid64`, `sent_at`),
KEY `ix_recipient` (`recipient_steamid64`, `sent_at`) KEY `ix_recipient` (`recipient_steamid64`, `sent_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

21
Ops.cs
View file

@ -11,20 +11,35 @@ namespace Whisper;
[JsonDerivedType(typeof(BlockRemove), "block_remove")] [JsonDerivedType(typeof(BlockRemove), "block_remove")]
public abstract record Op; 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( public sealed record MessageRecord(
string Key, DateTime At, int? ServerId, string Map, PlayerState Sender, PlayerState? Recipient, string Key, DateTime At, int? ServerId, string Map, string Channel, PlayerState Sender, PlayerState? Recipient,
string Target, string Message, string Result) : Op; string? Target, string Message, string Result) : Op;
public sealed record BlockAdd(ulong Blocker, ulong Blocked, string BlockedName, DateTime At) : Op; public sealed record BlockAdd(ulong Blocker, ulong Blocked, string BlockedName, DateTime At) : Op;
public sealed record BlockRemove(ulong Blocker, ulong Blocked) : 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). // What the message log calls each outcome (wh_messages.result).
public static class Result public static class Result
{ {
public const string Sent = "sent"; public const string Sent = "sent";
public const string Command = "command";
public const string Blocked = "blocked"; public const string Blocked = "blocked";
public const string Gagged = "gagged"; public const string Gagged = "gagged";
public const string Cooldown = "cooldown"; public const string Cooldown = "cooldown";

View file

@ -69,19 +69,36 @@ tables created on first load and tracked in `wh_migrations`. All times are UTC.
### History ### History
With `History` on, each `/pm` and `/r`, sent or not, is a row in `wh_messages`: when (`sent_at`), With `History` on, every message is a row in `wh_messages`, sent or not:
`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`: | `channel` | What |
`sent`, `blocked`, `gagged`, `cooldown`, `too_long`, `no_target`, `ambiguous` or `group_target`. The |---|---|
recipient columns are `NULL` when no single player was found. | `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`.
`css_whisper_history off` stops it at once and saves the setting to `Whisper.json`; rows already kept `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 stay. Writes go in order on a background task and spool to `plugins/Whisper/spool.jsonl` while the
database is down. database is down.
```sql ```sql
SELECT sent_at, map, sender_name, sender_steamid64, sender_team, sender_alive, SELECT sent_at, map, channel, sender_name, sender_steamid64, sender_team, sender_alive,
recipient_name, recipient_steamid64, recipient_team, recipient_alive, message, result recipient_name, message, result
FROM wh_messages ORDER BY sent_at DESC LIMIT 50; FROM wh_messages ORDER BY sent_at DESC LIMIT 50;
``` ```

View file

@ -15,8 +15,15 @@ using Microsoft.Extensions.Logging;
namespace Whisper; namespace Whisper;
// Private messages between players: /pm <target> <message>, /r to reply, and /block, /blocklist, // Private messages between players: /pm <target> <message>, /r to reply, and /block, /blocklist,
// /unblock. Lines use CS2-Tags' rank prefixes and colours from tags.json. Each message is kept in // /unblock. Lines use CS2-Tags' rank prefixes and colours from tags.json. While History is on, every
// CS2-SimpleAdmin's database (wh_messages) while History is on, and blocks in wh_blocks. // 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 // 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 // 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<WhisperConfig> public class Whisper : BasePlugin, IPluginConfig<WhisperConfig>
{ {
public override string ModuleName => "Whisper"; public override string ModuleName => "Whisper";
public override string ModuleVersion => "0.1.0"; public override string ModuleVersion => "0.2.1";
public override string ModuleAuthor => "astra"; public override string ModuleAuthor => "astra";
public override string ModuleDescription => "Private messages between players"; public override string ModuleDescription => "Private messages between players";
@ -37,6 +44,10 @@ public class Whisper : BasePlugin, IPluginConfig<WhisperConfig>
private static readonly HashSet<string> ChatCommands = new(StringComparer.OrdinalIgnoreCase) private static readonly HashSet<string> ChatCommands = new(StringComparer.OrdinalIgnoreCase)
{ "pm", "w", "r", "block", "blocklist", "unblock" }; { "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<string> 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. // Targets that mean more than one player. A PM goes to one.
private static readonly HashSet<string> GroupTargets = new(StringComparer.OrdinalIgnoreCase) private static readonly HashSet<string> GroupTargets = new(StringComparer.OrdinalIgnoreCase)
{ "@all", "@t", "@ct", "@spec", "@alive", "@dead", "@bot", "@bots", "@human", "@humans", "@!me" }; { "@all", "@t", "@ct", "@spec", "@alive", "@dead", "@bot", "@bots", "@human", "@humans", "@!me" };
@ -69,6 +80,8 @@ public class Whisper : BasePlugin, IPluginConfig<WhisperConfig>
_tags = new Tags(Path.Combine(Server.GameDirectory, "csgo", "addons", "counterstrikesharp", Config.TagsFile), _tags = new Tags(Path.Combine(Server.GameDirectory, "csgo", "addons", "counterstrikesharp", Config.TagsFile),
message => Logger.LogWarning("{Message}", message)); 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", OnSay, HookMode.Pre);
AddCommandListener("say_team", OnSay, HookMode.Pre); AddCommandListener("say_team", OnSay, HookMode.Pre);
RegisterEventHandler<EventPlayerConnectFull>(OnPlayerConnectFull); RegisterEventHandler<EventPlayerConnectFull>(OnPlayerConnectFull);
@ -103,6 +116,9 @@ public class Whisper : BasePlugin, IPluginConfig<WhisperConfig>
_writer.Start(); _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()) foreach (var player in Humans())
LoadBlocks(player.SteamID); LoadBlocks(player.SteamID);
} }
@ -200,13 +216,73 @@ public class Whisper : BasePlugin, IPluginConfig<WhisperConfig>
// --- Chat ----------------------------------------------------------------------- // --- 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) private HookResult OnSay(CCSPlayerController? player, CommandInfo info)
{ {
if (player is not { IsValid: true, IsBot: false }) if (player is not { IsValid: true, IsBot: false })
return HookResult.Continue; return HookResult.Continue;
var text = info.GetArg(1); var text = info.GetArg(1);
var trigger = CoreConfig.PublicChatTrigger.Concat(CoreConfig.SilentChatTrigger) var trigger = ChatTrigger(text);
.FirstOrDefault(t => t.Length > 0 && text.StartsWith(t));
if (trigger == null) if (trigger == null)
return HookResult.Continue; return HookResult.Continue;
@ -602,7 +678,10 @@ public class Whisper : BasePlugin, IPluginConfig<WhisperConfig>
// --- History -------------------------------------------------------------------- // --- 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) if (!Config.History || _writer == null)
return; return;
@ -615,7 +694,7 @@ public class Whisper : BasePlugin, IPluginConfig<WhisperConfig>
{ {
} }
_writer.Enqueue(new MessageRecord(Guid.NewGuid().ToString("N"), DateTime.UtcNow, serverId, Server.MapName ?? "", _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")] [ConsoleCommand("css_whisper_history", "Turns keeping message history on or off")]