Whisper 0.1.0: private messages between players

/pm (/w) with names, #userid, SteamIDs, @me, @aim, @random, @randomt and
@randomct, /r to reply, and /block, /blocklist, /unblock. Lines use
CS2-Tags' rank prefixes and colours: >[[M] Kalus -> me] message. Gagged
players can't send; blocks stop everyone but staff. Messages are kept in
CS2-SimpleAdmin's database while History is on (css_whisper_history).
This commit is contained in:
Astra 2026-10-05 13:13:58 +01:00
commit c414e8a770
12 changed files with 1547 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
bin/
obj/
release-out/

50
Config.cs Normal file
View file

@ -0,0 +1,50 @@
using System.Text.Json.Serialization;
using CounterStrikeSharp.API.Core;
namespace Whisper;
public sealed class WhisperConfig : BasePluginConfig
{
// Keep each message (sender, recipient, both teams and alive/dead, map, time) in wh_messages.
// css_whisper_history on|off changes it and saves this file.
[JsonPropertyName("History")]
public bool History { get; set; } = true;
// How the line is drawn: Open, then the sender's rank prefix and name, Arrow, the recipient
// (Me on the recipient's own line), Close, then the message. Colour tags like {Default}.
[JsonPropertyName("Open")]
public string Open { get; set; } = "{Default}>[";
[JsonPropertyName("Arrow")]
public string Arrow { get; set; } = "{Default}→";
[JsonPropertyName("Me")]
public string Me { get; set; } = "{Default}me";
[JsonPropertyName("Close")]
public string Close { get; set; } = "{Default}]";
// CS2-Tags' file, read for each player's rank prefix and colours.
[JsonPropertyName("TagsFile")]
public string TagsFile { get; set; } = "configs/plugins/CS2-Tags/tags.json";
[JsonPropertyName("CooldownSeconds")]
public double CooldownSeconds { get; set; } = 1;
[JsonPropertyName("MaxMessageLength")]
public int MaxMessageLength { get; set; } = 200;
[JsonPropertyName("MaxBlocks")]
public int MaxBlocks { get; set; } = 100;
// Who gets through blocks and can find admins hidden with css_hide.
[JsonPropertyName("StaffPermission")]
public string StaffPermission { get; set; } = "@css/generic";
// Writes kept on disk while the database is down.
[JsonPropertyName("SpoolMaxEntries")]
public int SpoolMaxEntries { get; set; } = 20000;
[JsonPropertyName("ConfigVersion")]
public override int Version { get; set; } = 1;
}

126
Database.cs Normal file
View file

@ -0,0 +1,126 @@
using MySqlConnector;
namespace Whisper;
// Whisper's wh_* tables in CS2-SimpleAdmin's MySQL database, over its connection string. Every
// method opens its own connection from the pool and runs off the game thread. Writes come only
// from the Writer, one at a time, in order.
public sealed class Database(string connectionString)
{
private const string MigrationPrefix = "Whisper.Migrations.";
public sealed record Block(ulong SteamId, string Name, DateTime At);
private async Task<MySqlConnection> OpenAsync(CancellationToken token = default)
{
var connection = new MySqlConnection(connectionString);
await connection.OpenAsync(token);
return connection;
}
private static MySqlCommand Command(MySqlConnection connection, string sql, params (string Name, object? Value)[] parameters)
{
var command = new MySqlCommand(sql, connection);
foreach (var (name, value) in parameters)
command.Parameters.AddWithValue(name, value ?? DBNull.Value);
return command;
}
// Applies the embedded Migrations/*.sql in name order, each once, the way SimpleAdmin's
// Migration class does with sa_migrations. Returns the versions applied.
public async Task<List<string>> MigrateAsync(CancellationToken token)
{
await using var connection = await OpenAsync(token);
await using (var create = Command(connection, """
CREATE TABLE IF NOT EXISTS `wh_migrations` (
`id` int NOT NULL AUTO_INCREMENT,
`version` varchar(128) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
"""))
await create.ExecuteNonQueryAsync(token);
string last;
await using (var query = Command(connection, "SELECT version FROM wh_migrations ORDER BY id DESC LIMIT 1"))
last = (await query.ExecuteScalarAsync(token))?.ToString() ?? "";
var assembly = typeof(Database).Assembly;
var applied = new List<string>();
foreach (var resource in assembly.GetManifestResourceNames()
.Where(n => n.StartsWith(MigrationPrefix) && n.EndsWith(".sql"))
.Order(StringComparer.Ordinal))
{
var version = resource[MigrationPrefix.Length..^".sql".Length];
if (string.CompareOrdinal(version, last) <= 0)
continue;
using var reader = new StreamReader(assembly.GetManifestResourceStream(resource)!);
await using (var migrate = Command(connection, await reader.ReadToEndAsync(token)))
await migrate.ExecuteNonQueryAsync(token);
await using (var record = Command(connection, "INSERT INTO wh_migrations (version) VALUES (@version)", ("@version", version)))
await record.ExecuteNonQueryAsync(token);
applied.Add(version);
}
return applied;
}
public async Task ApplyAsync(Op op, CancellationToken token)
{
await using var connection = await OpenAsync(token);
switch (op)
{
case MessageRecord o:
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,
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),
("@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;
case BlockAdd o:
await Exec(connection, token, """
INSERT INTO wh_blocks (blocker_steamid64, blocked_steamid64, blocked_name, created_at)
VALUES (@blocker, @blocked, @name, @at)
ON DUPLICATE KEY UPDATE blocked_name = IF(VALUES(blocked_name) = '', blocked_name, VALUES(blocked_name))
""", ("@blocker", o.Blocker), ("@blocked", o.Blocked), ("@name", Clip(o.BlockedName, 128)), ("@at", o.At));
break;
case BlockRemove o:
await Exec(connection, token, "DELETE FROM wh_blocks WHERE blocker_steamid64 = @blocker AND blocked_steamid64 = @blocked",
("@blocker", o.Blocker), ("@blocked", o.Blocked));
break;
default:
throw new ArgumentException($"Unknown op {op.GetType().Name}");
}
}
// One player's blocks, oldest first: the order /blocklist numbers them in.
public async Task<List<Block>> BlocksAsync(ulong blocker)
{
await using var connection = await OpenAsync();
await using var query = Command(connection, """
SELECT blocked_steamid64, blocked_name, created_at FROM wh_blocks
WHERE blocker_steamid64 = @blocker ORDER BY created_at, blocked_steamid64
""", ("@blocker", blocker));
await using var reader = await query.ExecuteReaderAsync();
var blocks = new List<Block>();
while (await reader.ReadAsync())
blocks.Add(new Block(reader.GetUInt64(0), reader.GetString(1), DateTime.SpecifyKind(reader.GetDateTime(2), DateTimeKind.Utc)));
return blocks;
}
private static async Task Exec(MySqlConnection connection, CancellationToken token, string sql, params (string Name, object? Value)[] parameters)
{
await using var command = Command(connection, sql, parameters);
await command.ExecuteNonQueryAsync(token);
}
private static string? Clip(string? value, int length) =>
value == null || value.Length <= length ? value : value[..length];
}

View file

@ -0,0 +1,40 @@
-- 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.
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,
`sender_name` varchar(128) NOT 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) 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_sender` (`sender_steamid64`, `sent_at`),
KEY `ix_recipient` (`recipient_steamid64`, `sent_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- Who each player has blocked from messaging them (/block). blocked_name is their name when blocked,
-- empty when blocked by SteamID64 while offline.
CREATE TABLE IF NOT EXISTS `wh_blocks` (
`blocker_steamid64` bigint unsigned NOT NULL,
`blocked_steamid64` bigint unsigned NOT NULL,
`blocked_name` varchar(128) NOT NULL DEFAULT '',
`created_at` datetime NOT NULL,
PRIMARY KEY (`blocker_steamid64`, `blocked_steamid64`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

35
Ops.cs Normal file
View file

@ -0,0 +1,35 @@
using System.Text.Json.Serialization;
namespace Whisper;
// Every database write, as data, so the writer can run them in order off the game thread and keep
// them in the spool file while the database is down. Each is safe to run twice: messages are keyed,
// block adds are INSERT IGNOREs and removes are deletes.
[JsonPolymorphic(TypeDiscriminatorPropertyName = "op")]
[JsonDerivedType(typeof(MessageRecord), "message")]
[JsonDerivedType(typeof(BlockAdd), "block_add")]
[JsonDerivedType(typeof(BlockRemove), "block_remove")]
public abstract record Op;
public sealed record PlayerState(ulong SteamId, string Name, int Team, bool Alive);
public sealed record MessageRecord(
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;
// What the message log calls each outcome (wh_messages.result).
public static class Result
{
public const string Sent = "sent";
public const string Blocked = "blocked";
public const string Gagged = "gagged";
public const string Cooldown = "cooldown";
public const string NoTarget = "no_target";
public const string Ambiguous = "ambiguous";
public const string GroupTarget = "group_target";
public const string TooLong = "too_long";
}

101
README.md Normal file
View file

@ -0,0 +1,101 @@
# Whisper
Private messages between players, for CounterStrikeSharp 1.0.376 (net10.0), alongside
CS2-SimpleAdmin and CS2-Tags.
## Commands
| Chat | Console | What |
|---|---|---|
| `/pm <target> <message>`, `/w` | `css_pm`, `css_w` | Send a private message |
| `/r <message>` | `css_r` | Reply to whoever last messaged you |
| `/block <#userid\|steamid64>` | `css_block` | Stop a player messaging you. A SteamID64 works while they're offline |
| `/blocklist` | `css_blocklist` | Your blocks, numbered from 1 |
| `/unblock <number>` | `css_unblock` | Remove that block |
| | `css_whisper_history <on\|off>` | `@css/root` and the server console: see [History](#history) |
`!` works the same as `/`. The plugin takes these commands out of chat itself, so `!pm` never shows
the message to everyone the way a `!` command normally would.
**Targets** are one player:
- part of a name (an exact match wins when several names contain it), or `"a name with spaces"`;
- `#userid`, as `css_players` and `status` show it, a SteamID64 (with or without `#`) or a `STEAM_` ID;
- `@me`, `@aim` (who you're looking at), `@randomt`, `@randomct`, or `@random` (anyone on any team,
alive, dead or spectating).
Group targets (`@all`, `@t`, `@ct`, `@spec`, `@alive`, `@dead`, `@bots`, `@human`, `@!me`) are refused.
Bots and SourceTV can't be messaged. Admins hidden with SimpleAdmin's `css_hide` can't be found, so a
message doesn't give them away; staff (`StaffPermission`) still can. Random picks never land on you or
a hidden admin.
## Messages
```
>[[M] Kalus → me] rotate b what Bravo sees
>[[M] Kalus → Bravo] rotate b what Kalus sees
```
The sender's rank prefix, name colour (`nick_color`, the team colour when empty) and message colour
(`message_color`) come from CS2-Tags' `tags.json`, picked the way CS2-Tags picks them: SteamID64, then
the first `#group`/`@permission` key in file order, then `everyone`. A player with no prefix shows as
`>[Kalus → me]`. The recipient's name is in their own `nick_color`. The file is reread when it changes.
No dead or team labels.
- Dead players can message anyone.
- Gagged or silenced players (CS2-SimpleAdmin) can't send.
- A player who has blocked the sender isn't messaged, and the sender is told so. Staff get through.
- One message per `CooldownSeconds`, at most `MaxMessageLength` characters.
## Configuration
`configs/plugins/Whisper/Whisper.json`, written on first load:
| Setting | Default | Meaning |
|---|---|---|
| `History` | `true` | Keep each message in `wh_messages`, see below |
| `Open`, `Arrow`, `Me`, `Close` | `{Default}>[`, `{Default}→`, `{Default}me`, `{Default}]` | The parts of the line around the names. Colour tags as in `tags.json` |
| `TagsFile` | `configs/plugins/CS2-Tags/tags.json` | Relative to `addons/counterstrikesharp/` |
| `CooldownSeconds` | `1` | |
| `MaxMessageLength` | `200` | |
| `MaxBlocks` | `100` | Per player |
| `StaffPermission` | `@css/generic` | Gets through blocks and can find hidden admins |
| `SpoolMaxEntries` | `20000` | Writes kept on disk while the database is down |
## Storage
Everything is in CS2-SimpleAdmin's MySQL database, over its connection string (from its API), in
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.
`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
FROM wh_messages ORDER BY sent_at DESC LIMIT 50;
```
### Blocks
`wh_blocks`: blocker, blocked, the blocked player's name when blocked (empty for an offline
SteamID64), and when. Kept whatever `History` says. A player's list is read when they join.
## Building and releasing
`../../build.sh` from this folder builds into `compiled/Whisper/`. `lib/CS2-SimpleAdminApi.dll` is a
compile-only copy of the one SimpleAdmin installs in `shared/`.
`./release.sh v<ModuleVersion>` (Forgejo token from `FORGEJO_TOKEN` or `../../.forgejo-token`) builds
from the committed source, tags it, and publishes `Whisper-<tag>.tar.gz` (laid out like `game/csgo/`)
on git.zio.sh. The server installs it from there (`WHISPER_VERSION` in the server repo's
`update_plugins.sh`).

97
Tags.cs Normal file
View file

@ -0,0 +1,97 @@
using System.Reflection;
using System.Text.Json;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Admin;
using CounterStrikeSharp.API.Modules.Utils;
namespace Whisper;
// Reads CS2-Tags' tags.json so private messages show the same rank prefix and colours as chat, and
// picks a player's tag the way CS2-Tags does: their SteamID64 first, then the first "#group" or
// "@permission" key they match in file order, then "everyone". Reread when the file changes.
public sealed class Tags(string path, Action<string> warn)
{
public sealed record Tag(string Prefix, string NickColor, string MessageColor);
private List<KeyValuePair<string, Tag>> _tags = [];
private DateTime _loadedWrite;
public Tag? Find(CCSPlayerController player)
{
Refresh();
if (player.IsBot || player.IsHLTV || player.AuthorizedSteamID == null)
return null;
var steamid = player.AuthorizedSteamID.SteamId64.ToString();
foreach (var (key, tag) in _tags)
if (key == steamid)
return tag;
foreach (var (key, tag) in _tags)
{
if (key.StartsWith('#') && AdminManager.PlayerInGroup(player, key)) return tag;
if (key.StartsWith('@') && AdminManager.PlayerHasPermissions(player, key)) return tag;
}
foreach (var (key, tag) in _tags)
if (key == "everyone")
return tag;
return null;
}
private void Refresh()
{
DateTime written;
try
{
written = File.GetLastWriteTimeUtc(path);
}
catch (Exception)
{
return;
}
// GetLastWriteTimeUtc gives 1601 for a missing file.
if (written == _loadedWrite || written.Year < 1700)
{
if (written.Year < 1700 && _tags.Count > 0)
_tags = [];
_loadedWrite = written;
return;
}
_loadedWrite = written;
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(path),
new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true });
var tags = new List<KeyValuePair<string, Tag>>();
if (doc.RootElement.TryGetProperty("tags", out var section))
foreach (var entry in section.EnumerateObject())
tags.Add(new(entry.Name, new Tag(Str(entry.Value, "prefix"), Str(entry.Value, "nick_color"), Str(entry.Value, "message_color"))));
_tags = tags;
}
catch (Exception ex)
{
// Keep the tags from before, as CS2-Tags does with a broken file.
warn($"Couldn't read {path}: {ex.Message}; keeping the previous tags");
}
}
private static string Str(JsonElement e, string name) =>
e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? "" : "";
public static string TeamColor(int team) => team switch
{
(int)CsTeam.Terrorist => ChatColors.Gold.ToString(),
(int)CsTeam.CounterTerrorist => ChatColors.Blue.ToString(),
_ => "",
};
// Colour tags in config strings only, never in names or messages: {Red}, {Default}, {TEAMCOLOR}.
public static string Colors(string text, int team = 0)
{
if (!text.Contains('{'))
return text;
foreach (var field in typeof(ChatColors).GetFields(BindingFlags.Public | BindingFlags.Static))
if (field.FieldType == typeof(char))
text = text.Replace($"{{{field.Name}}}", field.GetValue(null)!.ToString(), StringComparison.OrdinalIgnoreCase);
return text.Replace("{TEAMCOLOR}", TeamColor(team), StringComparison.OrdinalIgnoreCase);
}
}

656
Whisper.cs Normal file
View file

@ -0,0 +1,656 @@
using System.Text.Encodings.Web;
using System.Text.Json;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Attributes.Registration;
using CounterStrikeSharp.API.Core.Capabilities;
using CounterStrikeSharp.API.Modules.Admin;
using CounterStrikeSharp.API.Modules.Commands;
using CounterStrikeSharp.API.Modules.Entities;
using CounterStrikeSharp.API.Modules.Timers;
using CounterStrikeSharp.API.Modules.Utils;
using CS2_SimpleAdminApi;
using Microsoft.Extensions.Logging;
namespace Whisper;
// 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
// 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
// "say" there means nothing is printed and CSS never dispatches it, so it runs once.
//
// Threads: every field below is only touched on the game thread. Database reads run in Task.Run and
// hand back with Server.NextFrame; writes go through the Writer.
public class Whisper : BasePlugin, IPluginConfig<WhisperConfig>
{
public override string ModuleName => "Whisper";
public override string ModuleVersion => "0.1.0";
public override string ModuleAuthor => "astra";
public override string ModuleDescription => "Private messages between players";
private static readonly PluginCapability<ICS2_SimpleAdminApi> SimpleAdminCapability = new("simpleadmin:api");
// Chat words handled here, after a chat trigger ("!pm", "/pm").
private static readonly HashSet<string> ChatCommands = new(StringComparer.OrdinalIgnoreCase)
{ "pm", "w", "r", "block", "blocklist", "unblock" };
// Targets that mean more than one player. A PM goes to one.
private static readonly HashSet<string> GroupTargets = new(StringComparer.OrdinalIgnoreCase)
{ "@all", "@t", "@ct", "@spec", "@alive", "@dead", "@bot", "@bots", "@human", "@humans", "@!me" };
public WhisperConfig Config { get; set; } = new();
private ICS2_SimpleAdminApi? _simpleAdmin;
private Database? _db;
private Writer? _writer;
private Tags? _tags;
// SteamID64 -> their blocks, oldest first; null while still loading from the database.
private readonly Dictionary<ulong, List<Database.Block>?> _blocks = [];
// SteamID64 -> who last sent them a PM, for /r.
private readonly Dictionary<ulong, ulong> _lastFrom = [];
// SteamID64 -> when they may send again (UTC).
private readonly Dictionary<ulong, DateTime> _nextSend = [];
public void OnConfigParsed(WhisperConfig config)
{
config.CooldownSeconds = Math.Max(0, config.CooldownSeconds);
config.MaxMessageLength = Math.Clamp(config.MaxMessageLength, 1, 512);
config.MaxBlocks = Math.Max(1, config.MaxBlocks);
config.SpoolMaxEntries = Math.Max(0, config.SpoolMaxEntries);
Config = config;
}
public override void Load(bool hotReload)
{
_tags = new Tags(Path.Combine(Server.GameDirectory, "csgo", "addons", "counterstrikesharp", Config.TagsFile),
message => Logger.LogWarning("{Message}", message));
AddCommandListener("say", OnSay, HookMode.Pre);
AddCommandListener("say_team", OnSay, HookMode.Pre);
RegisterEventHandler<EventPlayerConnectFull>(OnPlayerConnectFull);
RegisterListener<Listeners.OnClientDisconnect>(OnClientDisconnect);
// Block lists that couldn't be read (the database was down) are tried again.
AddTimer(30.0f, () =>
{
foreach (var (steamId, blocks) in _blocks.ToList())
if (blocks == null)
LoadBlocks(steamId);
}, TimerFlags.REPEAT);
}
public override void OnAllPluginsLoaded(bool hotReload)
{
try
{
_simpleAdmin = SimpleAdminCapability.Get();
}
catch (KeyNotFoundException)
{
}
var connectionString = _simpleAdmin?.GetConnectionString();
if (string.IsNullOrEmpty(connectionString))
Logger.LogError("No CS2-SimpleAdmin MySQL database: messages work, but blocks last only until the map changes");
else
{
_db = new Database(connectionString);
_writer = new Writer(_db, Path.Combine(ModuleDirectory, "spool.jsonl"), Config.SpoolMaxEntries, Logger);
_writer.Start();
}
foreach (var player in Humans())
LoadBlocks(player.SteamID);
}
public override void Unload(bool hotReload)
{
_writer?.Drain(TimeSpan.FromSeconds(5));
}
// --- Players --------------------------------------------------------------------
private static List<CCSPlayerController> Humans() =>
Utilities.GetPlayers().Where(p => p is { IsValid: true, IsBot: false, IsHLTV: false } &&
p.Connected == PlayerConnectedState.Connected && p.SteamID != 0).ToList();
private bool IsStaff(CCSPlayerController player) =>
AdminManager.PlayerHasPermissions(player, Config.StaffPermission);
private bool IsHidden(CCSPlayerController player)
{
try
{
return _simpleAdmin?.IsAdminSilent(player) ?? false;
}
catch (Exception)
{
return false;
}
}
// Admins hidden with css_hide can't be found, except by staff and themselves.
private bool CanSee(CCSPlayerController sender, CCSPlayerController player) =>
player.SteamID == sender.SteamID || !IsHidden(player) || IsStaff(sender);
private static PlayerState StateOf(CCSPlayerController player)
{
var team = (int)player.TeamNum;
var alive = team >= (int)CsTeam.Terrorist && player.PlayerPawn.Value is { IsValid: true } pawn &&
pawn.LifeState == (byte)LifeState_t.LIFE_ALIVE;
return new PlayerState(player.SteamID, player.PlayerName, team, alive);
}
private HookResult OnPlayerConnectFull(EventPlayerConnectFull @event, GameEventInfo info)
{
if (@event.Userid is { IsValid: true, IsBot: false, IsHLTV: false } player && player.SteamID != 0)
LoadBlocks(player.SteamID);
return HookResult.Continue;
}
private void OnClientDisconnect(int slot)
{
var player = Utilities.GetPlayerFromSlot(slot);
if (player is not { IsValid: true } || player.SteamID == 0)
return;
var steamId = player.SteamID;
// Next frame, once they're out of the player list.
Server.NextFrame(() =>
{
if (Humans().Any(p => p.SteamID == steamId))
return;
_blocks.Remove(steamId);
_lastFrom.Remove(steamId);
_nextSend.Remove(steamId);
});
}
private void LoadBlocks(ulong steamId)
{
if (_db == null)
{
_blocks.TryAdd(steamId, []);
return;
}
if (_blocks.TryGetValue(steamId, out var known) && known != null)
return;
_blocks[steamId] = null;
var db = _db;
Task.Run(async () =>
{
try
{
var blocks = await db.BlocksAsync(steamId);
Server.NextFrame(() =>
{
if (_blocks.TryGetValue(steamId, out var current) && current == null)
_blocks[steamId] = blocks;
});
}
catch (Exception ex)
{
Logger.LogWarning("Couldn't read {SteamId}'s blocks, trying again shortly: {Error}", steamId, ex.Message);
}
});
}
// --- Chat -----------------------------------------------------------------------
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));
if (trigger == null)
return HookResult.Continue;
var rest = text[trigger.Length..];
var word = NextToken(ref rest);
if (!ChatCommands.Contains(word))
return HookResult.Continue;
Run(player, word.ToLowerInvariant(), rest.Trim(), reply => player.PrintToChat(reply));
return HookResult.Stop;
}
[ConsoleCommand("css_pm", "Sends a private message")]
[ConsoleCommand("css_w", "Sends a private message")]
[CommandHelper(usage: "<name|#userid|@randomt|...> <message>", whoCanExecute: CommandUsage.CLIENT_ONLY)]
public void OnPmCommand(CCSPlayerController? player, CommandInfo command) => RunConsole(player, command, "pm");
[ConsoleCommand("css_r", "Replies to the last private message")]
[CommandHelper(usage: "<message>", whoCanExecute: CommandUsage.CLIENT_ONLY)]
public void OnReplyCommand(CCSPlayerController? player, CommandInfo command) => RunConsole(player, command, "r");
[ConsoleCommand("css_block", "Stops a player sending you private messages")]
[CommandHelper(usage: "<#userid|steamid64>", whoCanExecute: CommandUsage.CLIENT_ONLY)]
public void OnBlockCommand(CCSPlayerController? player, CommandInfo command) => RunConsole(player, command, "block");
[ConsoleCommand("css_blocklist", "Lists who you've blocked")]
[CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)]
public void OnBlockListCommand(CCSPlayerController? player, CommandInfo command) => RunConsole(player, command, "blocklist");
[ConsoleCommand("css_unblock", "Unblocks a player, by their number in /blocklist")]
[CommandHelper(usage: "<number from /blocklist>", whoCanExecute: CommandUsage.CLIENT_ONLY)]
public void OnUnblockCommand(CCSPlayerController? player, CommandInfo command) => RunConsole(player, command, "unblock");
private void RunConsole(CCSPlayerController? player, CommandInfo command, string word)
{
if (player is not { IsValid: true, IsBot: false })
return;
Run(player, word, command.ArgString.Trim(), reply => command.ReplyToCommand(reply));
}
private void Run(CCSPlayerController player, string word, string args, Action<string> reply)
{
switch (word)
{
case "pm":
case "w":
SendCommand(player, args, reply);
break;
case "r":
ReplyCommand(player, args, reply);
break;
case "block":
Block(player, args, reply);
break;
case "blocklist":
BlockList(player, reply);
break;
case "unblock":
Unblock(player, args, reply);
break;
}
}
// The next word, or a "quoted name with spaces", taken off the front of text.
private static string NextToken(ref string text)
{
text = text.TrimStart();
if (text.Length == 0)
return "";
if (text[0] == '"')
{
var close = text.IndexOf('"', 1);
var quoted = close < 0 ? text[1..] : text[1..close];
text = close < 0 ? "" : text[(close + 1)..];
return quoted;
}
var end = text.IndexOfAny([' ', '\t']);
var token = end < 0 ? text : text[..end];
text = end < 0 ? "" : text[end..];
return token;
}
// --- Sending --------------------------------------------------------------------
private static string Notice(string text) => $" {ChatColors.Default}{text}";
private void SendCommand(CCSPlayerController sender, string args, Action<string> reply)
{
var rest = args;
var target = NextToken(ref rest);
var message = rest.Trim();
if (target.Length == 0 || message.Length == 0)
{
reply(Notice("Usage: /pm <name|#userid|@aim|@randomt|@randomct|@random|@me> <message>"));
return;
}
var (recipient, result, problem) = Resolve(sender, target);
if (recipient == null)
{
Log(sender, null, target, message, result);
reply(Notice(problem));
return;
}
Deliver(sender, recipient, target, message, reply);
}
private void ReplyCommand(CCSPlayerController sender, string args, Action<string> reply)
{
var message = args.Trim();
if (message.Length == 0)
{
reply(Notice("Usage: /r <message>"));
return;
}
if (!_lastFrom.TryGetValue(sender.SteamID, out var from))
{
reply(Notice("No one has sent you a private message yet."));
return;
}
var recipient = Humans().FirstOrDefault(p => p.SteamID == from && CanSee(sender, p));
if (recipient == null)
{
Log(sender, null, "/r", message, Result.NoTarget);
reply(Notice("They're not on the server any more."));
return;
}
Deliver(sender, recipient, "/r", message, reply);
}
private void Deliver(CCSPlayerController sender, CCSPlayerController recipient, string target, string message, Action<string> reply)
{
if (IsGagged(sender, out var permanent))
{
Log(sender, recipient, target, message, Result.Gagged);
reply(Notice(permanent
? "You can't send private messages: you're gagged permanently."
: "You can't send private messages while you're gagged."));
return;
}
var now = DateTime.UtcNow;
if (_nextSend.TryGetValue(sender.SteamID, out var next) && now < next)
{
Log(sender, recipient, target, message, Result.Cooldown);
reply(Notice("You're sending messages too quickly."));
return;
}
if (message.Length > Config.MaxMessageLength)
{
Log(sender, recipient, target, message, Result.TooLong);
reply(Notice($"That message is too long (at most {Config.MaxMessageLength} characters)."));
return;
}
var self = recipient.SteamID == sender.SteamID;
if (!self && !IsStaff(sender) && _blocks.TryGetValue(recipient.SteamID, out var blocks) &&
blocks?.Any(b => b.SteamId == sender.SteamID) == true)
{
Log(sender, recipient, target, message, Result.Blocked);
reply(Notice($"{recipient.PlayerName} isn't accepting your messages."));
return;
}
_nextSend[sender.SteamID] = now.AddSeconds(Config.CooldownSeconds);
var (from, color) = Speaker(sender);
recipient.PrintToChat($" {Tags.Colors(Config.Open)}{from} {Tags.Colors(Config.Arrow)} {Tags.Colors(Config.Me)}{Tags.Colors(Config.Close)} {color}{message}");
if (!self)
{
sender.PrintToChat($" {Tags.Colors(Config.Open)}{from} {Tags.Colors(Config.Arrow)} {Nick(recipient)}{Tags.Colors(Config.Close)} {color}{message}");
_lastFrom[recipient.SteamID] = sender.SteamID;
}
Log(sender, recipient, target, message, Result.Sent);
}
// The sender as the line shows them, "[M] Kalus" in tags.json's colours, and their message colour.
private (string From, string MessageColor) Speaker(CCSPlayerController player)
{
var tag = _tags?.Find(player);
var team = (int)player.TeamNum;
var prefix = tag is { Prefix: not "" } ? Tags.Colors(tag.Prefix, team).TrimEnd() + " " : "";
var color = tag is { MessageColor: not "" } ? Tags.Colors(tag.MessageColor, team) : ChatColors.Default.ToString();
return ($"{prefix}{Nick(player, tag)}", color);
}
// A name in its nick_color from tags.json, or the team colour when that's empty.
private string Nick(CCSPlayerController player, Tags.Tag? tag = null)
{
tag ??= _tags?.Find(player);
var team = (int)player.TeamNum;
var color = tag is { NickColor: not "" } ? Tags.Colors(tag.NickColor, team) : Tags.TeamColor(team);
return $"{ChatColors.Default}{color}{player.PlayerName}";
}
private bool IsGagged(CCSPlayerController player, out bool permanent)
{
permanent = false;
if (_simpleAdmin == null)
return false;
try
{
var status = _simpleAdmin.GetPlayerMuteStatus(player);
var blocks = new[] { PenaltyType.Gag, PenaltyType.Silence }
.SelectMany(t => status.TryGetValue(t, out var list) ? list : []).ToList();
permanent = blocks.Any(b => b.Duration == 0);
return blocks.Count > 0;
}
catch (Exception)
{
return false;
}
}
// --- Targets --------------------------------------------------------------------
// One player from a name, #userid (as css_players shows it), SteamID64 or STEAM_ ID, @me, @aim,
// @random, @randomt or @randomct. Never a bot, and never a hidden admin unless staff are asking.
private (CCSPlayerController? Player, string Result, string Problem) Resolve(CCSPlayerController sender, string target)
{
var humans = Humans().Where(p => CanSee(sender, p)).ToList();
if (GroupTargets.Contains(target))
return (null, Result.GroupTarget, "Private messages go to one player at a time.");
if (target.StartsWith('@'))
{
switch (target.ToLowerInvariant())
{
case "@me":
return (sender, Result.Sent, "");
case "@aim":
{
var rules = Utilities.FindAllEntitiesByDesignerName<CCSGameRulesProxy>("cs_gamerules").FirstOrDefault()?.GameRules;
var aimed = rules?.GetClientAimTarget(sender);
var found = aimed == null ? null : humans.FirstOrDefault(p => p.Slot == aimed.Slot);
return found != null ? (found, Result.Sent, "") : (null, Result.NoTarget, "You're not looking at a player.");
}
case "@random":
case "@randomt":
case "@randomct":
{
var team = target.ToLowerInvariant() switch
{
"@randomt" => (int)CsTeam.Terrorist,
"@randomct" => (int)CsTeam.CounterTerrorist,
_ => -1,
};
// Never a hidden admin, even for staff: a random pick shouldn't land on one.
var pool = humans.Where(p => p.SteamID != sender.SteamID && !IsHidden(p) && (team < 0 || p.TeamNum == team)).ToList();
return pool.Count == 0
? (null, Result.NoTarget, "There's no one to pick.")
: (pool[Random.Shared.Next(pool.Count)], Result.Sent, "");
}
default:
return (null, Result.NoTarget, $"{target} isn't a target. Use a name, #userid, @aim, @random, @randomt, @randomct or @me.");
}
}
if (TryId(target, out var userId, out var steamId))
{
var byId = humans.FirstOrDefault(p => steamId != 0 ? p.SteamID == steamId : p.UserId == userId);
return byId != null ? (byId, Result.Sent, "") : (null, Result.NoTarget, $"No player {target} on the server.");
}
var matches = humans.Where(p => p.PlayerName.Contains(target, StringComparison.OrdinalIgnoreCase)).ToList();
if (matches.Count > 1)
{
// A name that's exactly the pattern wins over names that only contain it.
var exact = matches.Where(p => p.PlayerName.Equals(target, StringComparison.OrdinalIgnoreCase)).ToList();
if (exact.Count == 1)
return (exact[0], Result.Sent, "");
return (null, Result.Ambiguous,
$"More than one player matches \"{target}\": {string.Join(", ", matches.Select(p => $"{p.PlayerName} (#{p.UserId})"))}. Use their #userid.");
}
return matches.Count == 1 ? (matches[0], Result.Sent, "") : (null, Result.NoTarget, $"No player matches \"{target}\".");
}
// "#5" (a userid), "#7656…"/"7656…" (a SteamID64) or "STEAM_0:1:…". steamId is 0 for a userid.
private static bool TryId(string text, out int userId, out ulong steamId)
{
userId = 0;
steamId = 0;
var bare = text.TrimStart('#');
if (bare.StartsWith("STEAM_", StringComparison.OrdinalIgnoreCase))
{
try
{
steamId = ((SteamID)bare).SteamId64;
return steamId != 0;
}
catch (Exception)
{
return false;
}
}
if (bare.Length == 17 && ulong.TryParse(bare, out steamId))
return true;
steamId = 0;
return text.StartsWith('#') && int.TryParse(bare, out userId);
}
// --- Blocks ---------------------------------------------------------------------
private bool BlocksReady(CCSPlayerController player, Action<string> reply, out List<Database.Block> blocks)
{
if (_blocks.TryGetValue(player.SteamID, out var list) && list != null)
{
blocks = list;
return true;
}
if (!_blocks.ContainsKey(player.SteamID))
LoadBlocks(player.SteamID);
blocks = [];
reply(Notice("Your block list is still loading. Try again in a moment."));
return false;
}
private void Block(CCSPlayerController player, string args, Action<string> reply)
{
var target = args.Trim();
if (!TryId(target, out var userId, out var steamId))
{
reply(Notice("Usage: /block <#userid|steamid64>. #userid is in css_players or status."));
return;
}
var online = Humans().FirstOrDefault(p => steamId != 0 ? p.SteamID == steamId : p.UserId == userId);
if (online != null && !CanSee(player, online))
online = null;
if (steamId == 0)
{
if (online == null)
{
reply(Notice($"No player {target} on the server. A SteamID64 works for players who are offline."));
return;
}
steamId = online.SteamID;
}
if (steamId == player.SteamID)
{
reply(Notice("You can't block yourself."));
return;
}
if (!BlocksReady(player, reply, out var blocks))
return;
var name = online?.PlayerName ?? "";
var shown = name == "" ? steamId.ToString() : name;
if (blocks.Any(b => b.SteamId == steamId))
{
reply(Notice($"You've already blocked {shown}."));
return;
}
if (blocks.Count >= Config.MaxBlocks)
{
reply(Notice($"Your block list is full ({Config.MaxBlocks}). /unblock someone first."));
return;
}
var now = DateTime.UtcNow;
blocks.Add(new Database.Block(steamId, name, now));
_writer?.Enqueue(new BlockAdd(player.SteamID, steamId, name, now));
reply(Notice($"Blocked {shown}. They can't send you private messages."));
}
private void BlockList(CCSPlayerController player, Action<string> reply)
{
if (!BlocksReady(player, reply, out var blocks))
return;
if (blocks.Count == 0)
{
reply(Notice("You haven't blocked anyone."));
return;
}
reply(Notice($"You've blocked {blocks.Count} {(blocks.Count == 1 ? "player" : "players")}. /unblock <number> to remove one:"));
for (var i = 0; i < blocks.Count; i++)
{
var b = blocks[i];
reply(Notice(b.Name == "" ? $"{i + 1}. {b.SteamId}" : $"{i + 1}. {b.Name} ({b.SteamId})"));
}
}
private void Unblock(CCSPlayerController player, string args, Action<string> reply)
{
if (!BlocksReady(player, reply, out var blocks))
return;
if (!int.TryParse(args.Trim(), out var number) || number < 1 || number > blocks.Count)
{
reply(Notice(blocks.Count == 0
? "You haven't blocked anyone."
: $"Usage: /unblock <number>, from 1 to {blocks.Count}. /blocklist shows the numbers."));
return;
}
var removed = blocks[number - 1];
blocks.RemoveAt(number - 1);
_writer?.Enqueue(new BlockRemove(player.SteamID, removed.SteamId));
reply(Notice($"Unblocked {(removed.Name == "" ? removed.SteamId.ToString() : removed.Name)}."));
}
// --- History --------------------------------------------------------------------
private void Log(CCSPlayerController sender, CCSPlayerController? recipient, string target, string message, string result)
{
if (!Config.History || _writer == null)
return;
int? serverId = null;
try
{
serverId = _simpleAdmin?.GetServerId();
}
catch (Exception)
{
}
_writer.Enqueue(new MessageRecord(Guid.NewGuid().ToString("N"), DateTime.UtcNow, serverId, Server.MapName ?? "",
StateOf(sender), recipient == null ? null : StateOf(recipient), target, message, result));
}
[ConsoleCommand("css_whisper_history", "Turns keeping message history on or off")]
[CommandHelper(usage: "[on|off]", whoCanExecute: CommandUsage.CLIENT_AND_SERVER)]
[RequiresPermissions("@css/root")]
public void OnHistoryCommand(CCSPlayerController? player, CommandInfo command)
{
var arg = command.GetArg(1).ToLowerInvariant();
if (arg is not ("on" or "off"))
{
command.ReplyToCommand($"[Whisper] History is {(Config.History ? "on" : "off")}. Usage: css_whisper_history <on|off>");
return;
}
Config.History = arg == "on";
try
{
SaveConfig();
command.ReplyToCommand($"[Whisper] History is now {arg}.");
}
catch (Exception ex)
{
command.ReplyToCommand($"[Whisper] History is now {arg}, but saving Whisper.json failed: {ex.Message}");
}
}
private void SaveConfig()
{
var path = Path.GetFullPath(Path.Combine(ModuleDirectory, "..", "..", "configs", "plugins", ModuleName, $"{ModuleName}.json"));
var json = JsonSerializer.Serialize(Config, new JsonSerializerOptions
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
});
var temp = path + ".tmp";
File.WriteAllText(temp, json);
File.Move(temp, path, overwrite: true);
}
}

28
Whisper.csproj Normal file
View file

@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Whisper</AssemblyName>
<RootNamespace>Whisper</RootNamespace>
</PropertyGroup>
<ItemGroup>
<!-- Compile-time only: the server already has CounterStrikeSharp.API.dll -->
<PackageReference Include="CounterStrikeSharp.API" Version="1.0.376">
<PrivateAssets>none</PrivateAssets>
<ExcludeAssets>runtime</ExcludeAssets>
<IncludeAssets>compile; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<!-- The same MySQL driver and version as CS2-SimpleAdmin; ships with the plugin -->
<PackageReference Include="MySqlConnector" Version="2.6.0" />
<!-- Compile-time only: CS2-SimpleAdmin installs it in shared/, copied from the server's copy -->
<Reference Include="CS2-SimpleAdminApi">
<HintPath>lib/CS2-SimpleAdminApi.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
<ItemGroup>
<!-- Embedded, so the tables are created without any extra files on the server -->
<EmbeddedResource Include="Migrations/*.sql" LogicalName="Whisper.Migrations.%(Filename)%(Extension)" />
</ItemGroup>
</Project>

287
Writer.cs Normal file
View file

@ -0,0 +1,287 @@
using System.Text.Json;
using System.Threading.Channels;
using Microsoft.Extensions.Logging;
using MySqlConnector;
namespace Whisper;
// Runs every database write on one background task, in the order the game thread queued them. When
// the database can't be reached, writes go to a spool file (one JSON op per line) instead, and every
// later write follows them there until the spool has been sent, so the order never changes. Migrations
// run first, retried until they work; until then everything is spooled.
public sealed class Writer
{
private static readonly TimeSpan RetryInterval = TimeSpan.FromSeconds(15);
private readonly Database _db;
private readonly string _spoolPath;
private readonly int _spoolMax;
private readonly ILogger _logger;
private readonly Channel<Op> _queue = Channel.CreateUnbounded<Op>(new UnboundedChannelOptions { SingleReader = true });
private readonly CancellationTokenSource _stop = new();
private Task? _loop;
private bool _migrated;
private bool _failing;
private bool _spoolFullWarned;
// While failing, migrations and the spool are only retried from this time on (UTC).
private DateTime _retryAt;
private volatile int _spooled;
private volatile string? _lastError;
private DateTime? _lastOkAt;
public Writer(Database db, string spoolPath, int spoolMax, ILogger logger)
{
_db = db;
_spoolPath = spoolPath;
_spoolMax = spoolMax;
_logger = logger;
}
// Kept here: a single-reader unbounded channel can't count itself (Reader.Count throws).
private int _queued;
public int Queued => Volatile.Read(ref _queued);
public int Spooled => _spooled;
public bool Migrated => Volatile.Read(ref _migrated);
public bool Failing => Volatile.Read(ref _failing);
public string? LastError => _lastError;
public DateTime? LastOkAt { get { lock (_stop) return _lastOkAt; } }
public void Start() => _loop = Task.Run(RunAsync);
// From the game thread. Never blocks.
public void Enqueue(Op op)
{
if (_queue.Writer.TryWrite(op))
Interlocked.Increment(ref _queued);
}
// On unload: sends what's queued, waiting up to `timeout`, then spools anything left.
public void Drain(TimeSpan timeout)
{
_queue.Writer.TryComplete();
if (_loop == null)
return;
if (!_loop.Wait(timeout))
{
_stop.Cancel();
if (!_loop.Wait(TimeSpan.FromSeconds(2)))
{
_logger.LogWarning("Database writer didn't stop; {Count} queued writes are lost", Queued);
return;
}
}
while (_queue.Reader.TryRead(out var op))
{
Interlocked.Decrement(ref _queued);
Spool(op);
}
}
private async Task RunAsync()
{
_spooled = CountSpool();
if (_spooled > 0)
_logger.LogInformation("{Count} writes waiting in the spool from before", _spooled);
var token = _stop.Token;
while (!token.IsCancellationRequested)
{
if (!_failing || DateTime.UtcNow >= _retryAt)
{
if (!_migrated)
await TryMigrateAsync(token);
if (_migrated && _spooled > 0)
await FlushSpoolAsync(token);
}
while (_queue.Reader.TryRead(out var op))
{
Interlocked.Decrement(ref _queued);
await HandleAsync(op, token);
}
bool more;
try
{
using var wait = CancellationTokenSource.CreateLinkedTokenSource(token);
wait.CancelAfter(RetryInterval);
more = await _queue.Reader.WaitToReadAsync(wait.Token);
}
catch (OperationCanceledException)
{
continue;
}
if (!more)
{
// Unloading: everything's been read. One last try at the spool.
if (_migrated && _spooled > 0)
await FlushSpoolAsync(token);
return;
}
}
}
private async Task TryMigrateAsync(CancellationToken token)
{
try
{
foreach (var version in await _db.MigrateAsync(token))
_logger.LogInformation("Migration \"{Version}\" applied", version);
Volatile.Write(ref _migrated, true);
Succeeded();
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Failed(ex, "Can't reach the database to set up its tables; writes are spooled until it works");
}
}
private async Task HandleAsync(Op op, CancellationToken token)
{
if (!_migrated || _spooled > 0)
{
Spool(op);
return;
}
try
{
await _db.ApplyAsync(op, token);
Succeeded();
}
catch (Exception ex) when (Transient(ex))
{
Failed(ex, "Database write failed; spooling writes until it's back");
Spool(op);
}
catch (Exception ex)
{
_lastError = ex.Message;
_logger.LogError(ex, "Dropped a {Op} write the database refused", op.GetType().Name);
}
}
// Sends the spool in order. Stops at the first write that fails for a reason that may pass, and
// keeps it and the rest.
private async Task FlushSpoolAsync(CancellationToken token)
{
string[] lines;
try
{
lines = File.ReadAllLines(_spoolPath);
}
catch (FileNotFoundException)
{
_spooled = 0;
return;
}
var sent = 0;
for (; sent < lines.Length; sent++)
{
Op? op;
try
{
op = JsonSerializer.Deserialize<Op>(lines[sent]);
}
catch (JsonException ex)
{
_logger.LogError(ex, "Skipped an unreadable spool line");
continue;
}
if (op == null)
continue;
try
{
await _db.ApplyAsync(op, token);
Succeeded();
}
catch (Exception ex) when (Transient(ex))
{
Failed(ex, "Database write failed while sending the spool; trying again later");
break;
}
catch (Exception ex)
{
_lastError = ex.Message;
_logger.LogError(ex, "Dropped a spooled {Op} write the database refused", op.GetType().Name);
}
}
if (sent >= lines.Length)
{
File.Delete(_spoolPath);
_spooled = 0;
_logger.LogInformation("Sent {Count} spooled writes", lines.Length);
return;
}
var temp = _spoolPath + ".tmp";
File.WriteAllLines(temp, lines[sent..]);
File.Move(temp, _spoolPath, overwrite: true);
_spooled = lines.Length - sent;
}
private void Spool(Op op)
{
if (_spooled >= _spoolMax)
{
if (!_spoolFullWarned)
_logger.LogError("The spool is full ({Max} writes); new writes are dropped until the database is back", _spoolMax);
_spoolFullWarned = true;
return;
}
try
{
File.AppendAllText(_spoolPath, JsonSerializer.Serialize(op) + "\n");
_spooled++;
}
catch (Exception ex)
{
_logger.LogError(ex, "Couldn't write to the spool {Path}; a {Op} write is lost", _spoolPath, op.GetType().Name);
}
}
private int CountSpool()
{
try
{
return File.ReadLines(_spoolPath).Count(l => l.Length > 0);
}
catch (FileNotFoundException)
{
return 0;
}
}
// Worth retrying: the database is unreachable, overloaded, or refusing our login (the password may
// be fixed). Anything else (bad data, a missing table) would fail again forever.
private static bool Transient(Exception ex) => ex switch
{
OperationCanceledException => true,
MySqlException m => m.IsTransient || m.ErrorCode is MySqlErrorCode.UnableToConnectToHost
or MySqlErrorCode.AccessDenied or MySqlErrorCode.DatabaseAccessDenied,
TimeoutException or IOException or System.Net.Sockets.SocketException => true,
_ => ex.InnerException != null && Transient(ex.InnerException),
};
private void Succeeded()
{
lock (_stop)
_lastOkAt = DateTime.UtcNow;
_spoolFullWarned = false;
if (Volatile.Read(ref _failing))
_logger.LogInformation("Database is reachable again");
Volatile.Write(ref _failing, false);
}
// Logs once per outage, not per write.
private void Failed(Exception ex, string message)
{
_lastError = ex.Message;
_retryAt = DateTime.UtcNow + RetryInterval;
if (!Volatile.Read(ref _failing))
_logger.LogError("{Message}: {Error}", message, ex.Message);
Volatile.Write(ref _failing, true);
}
}

BIN
lib/CS2-SimpleAdminApi.dll Normal file

Binary file not shown.

124
release.sh Executable file
View file

@ -0,0 +1,124 @@
#!/usr/bin/env bash
# Build Whisper, package it as one tarball and publish it as a Forgejo release.
#
# ./release.sh <tag> e.g. ./release.sh v0.1.0
#
# Commit first: the plugin is rebuilt here from the committed source, so the release always matches
# the tag. The tag is created on HEAD and pushed to origin if it doesn't exist yet. Re-running with
# the same tag replaces that release's attachment.
#
# Asset: Whisper-<tag>.tar.gz, laid out like the server's game/csgo/ - extract it there:
# tar -xzf Whisper-<tag>.tar.gz -C /srv/cs2/game/csgo
#
# addons/counterstrikesharp/plugins/Whisper/ Whisper.dll, .pdb, .deps.json, MySqlConnector.dll
# and its Microsoft.Extensions.* abstractions
#
# No configs/: the plugin writes configs/plugins/Whisper/Whisper.json itself when it's missing, and its
# data is in CS2-SimpleAdmin's database (wh_*), so an upgrade never touches them.
set -euo pipefail
FORGEJO_URL="https://git.zio.sh"
OWNER="cs2"
REPO="Whisper"
# Needs write:repository. Only uploading needs it; downloading from the public repo doesn't. From the
# environment, or else from .forgejo-token in the cs2/ folder above every plugin (outside their
# repos, mode 600). Don't commit a real token.
TOKEN_FILE="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/../../.forgejo-token"
if [[ -z "${FORGEJO_TOKEN:-}" && -r "$TOKEN_FILE" ]]; then
FORGEJO_TOKEN="$(tr -d '[:space:]' < "$TOKEN_FILE")"
fi
FORGEJO_TOKEN="${FORGEJO_TOKEN:-CHANGE_ME_FORGEJO_TOKEN}"
# Only these ship from the publish output. CounterStrikeSharp.API.dll is excluded by the csproj
# (ExcludeAssets=runtime); the server already provides it.
PLUGIN_FILES=(
Whisper.dll
Whisper.pdb
Whisper.deps.json
MySqlConnector.dll
Microsoft.Extensions.DependencyInjection.Abstractions.dll
Microsoft.Extensions.Logging.Abstractions.dll
)
TAG="${1:-}"
if [[ -z "$TAG" ]]; then
echo "usage: $0 <tag> (e.g. v0.1.0)" >&2
exit 1
fi
if [[ "$FORGEJO_TOKEN" == "CHANGE_ME_FORGEJO_TOKEN" ]]; then
echo "Set FORGEJO_TOKEN (export it, or put it in $TOKEN_FILE) first." >&2
exit 1
fi
for tool in podman jq curl git; do
command -v "$tool" >/dev/null || { echo "'$tool' is required." >&2; exit 1; }
done
cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")"
if [[ -n "$(git status --porcelain --untracked-files=no)" ]]; then
echo "Tracked files have uncommitted changes; commit them so the tag matches the release." >&2
exit 1
fi
# The tag should name the version the plugin reports in `css_plugins list`.
version="$(sed -n 's/.*ModuleVersion => "\(.*\)";.*/\1/p' Whisper.cs)"
if [[ "${TAG#v}" != "$version" ]]; then
echo "Tag $TAG doesn't match ModuleVersion $version in Whisper.cs." >&2
exit 1
fi
# --- Build -----------------------------------------------------------------------
OUT="release-out"
PUBLISH="$OUT/publish"
rm -rf bin obj "$OUT"
podman run --rm -v "$(pwd)":/src:Z -w /src \
mcr.microsoft.com/dotnet/sdk:10.0 dotnet publish -c Release -o "/src/$PUBLISH"
# --- Stage (layout of game/csgo/) and package ----------------------------------------
STAGE="$OUT/stage"
PLUGIN_DIR="$STAGE/addons/counterstrikesharp/plugins/Whisper"
mkdir -p "$PLUGIN_DIR"
for file in "${PLUGIN_FILES[@]}"; do
cp "$PUBLISH/$file" "$PLUGIN_DIR/"
done
TARBALL="Whisper-${TAG}.tar.gz"
tar -czf "$OUT/$TARBALL" -C "$STAGE" addons
rm -rf "$STAGE" "$PUBLISH"
echo "Packaged:"
tar -tzf "$OUT/$TARBALL"
# --- Tag ---------------------------------------------------------------------------
if ! git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
git tag -a "$TAG" -m "$TAG"
fi
git push origin "refs/tags/$TAG"
# --- Release -----------------------------------------------------------------------
API="$FORGEJO_URL/api/v1/repos/$OWNER/$REPO"
AUTH=(-H "Authorization: token $FORGEJO_TOKEN")
release_json="$(curl -fsS "${AUTH[@]}" "$API/releases/tags/$TAG" 2>/dev/null || true)"
if [[ -z "$release_json" ]]; then
body="$(git log -1 --format=%B "$TAG")"
release_json="$(jq -n --arg tag "$TAG" --arg body "$body" \
'{tag_name: $tag, name: $tag, body: $body, draft: false, prerelease: false}' |
curl -fsS "${AUTH[@]}" -H "Content-Type: application/json" -X POST --data @- "$API/releases")"
echo "Created release $TAG"
else
echo "Release $TAG already exists, replacing its attachment"
fi
release_id="$(jq -r '.id' <<<"$release_json")"
existing_id="$(jq -r --arg n "$TARBALL" '.assets[]? | select(.name == $n) | .id' <<<"$release_json")"
if [[ -n "$existing_id" ]]; then
curl -fsS "${AUTH[@]}" -X DELETE "$API/releases/$release_id/assets/$existing_id" >/dev/null
fi
curl -fsS "${AUTH[@]}" -X POST -F "attachment=@$OUT/$TARBALL" \
"$API/releases/$release_id/assets?name=$TARBALL" >/dev/null
echo "Uploaded $TARBALL"
echo
echo "Download URL:"
echo " $FORGEJO_URL/$OWNER/$REPO/releases/download/$TAG/$TARBALL"