commit c414e8a77083e13ae5e649bd152e385ee441a79b Author: Astra Date: Mon Oct 5 13:13:58 2026 +0100 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). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a3935e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +bin/ +obj/ +release-out/ diff --git a/Config.cs b/Config.cs new file mode 100644 index 0000000..e0ae1bf --- /dev/null +++ b/Config.cs @@ -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; +} diff --git a/Database.cs b/Database.cs new file mode 100644 index 0000000..f03d2be --- /dev/null +++ b/Database.cs @@ -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 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> 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(); + 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> 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(); + 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]; +} diff --git a/Migrations/001_CreateTables.sql b/Migrations/001_CreateTables.sql new file mode 100644 index 0000000..999992b --- /dev/null +++ b/Migrations/001_CreateTables.sql @@ -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; diff --git a/Ops.cs b/Ops.cs new file mode 100644 index 0000000..252f4e3 --- /dev/null +++ b/Ops.cs @@ -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"; +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..3146a39 --- /dev/null +++ b/README.md @@ -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 `, `/w` | `css_pm`, `css_w` | Send a private message | +| `/r ` | `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 ` | `css_unblock` | Remove that block | +| | `css_whisper_history ` | `@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` (Forgejo token from `FORGEJO_TOKEN` or `../../.forgejo-token`) builds +from the committed source, tags it, and publishes `Whisper-.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`). diff --git a/Tags.cs b/Tags.cs new file mode 100644 index 0000000..51070ed --- /dev/null +++ b/Tags.cs @@ -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 warn) +{ + public sealed record Tag(string Prefix, string NickColor, string MessageColor); + + private List> _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>(); + 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); + } +} diff --git a/Whisper.cs b/Whisper.cs new file mode 100644 index 0000000..8b3af45 --- /dev/null +++ b/Whisper.cs @@ -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 , /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 +{ + 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 SimpleAdminCapability = new("simpleadmin:api"); + + // Chat words handled here, after a chat trigger ("!pm", "/pm"). + private static readonly HashSet 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 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?> _blocks = []; + // SteamID64 -> who last sent them a PM, for /r. + private readonly Dictionary _lastFrom = []; + // SteamID64 -> when they may send again (UTC). + private readonly Dictionary _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(OnPlayerConnectFull); + RegisterListener(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 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: " ", 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: "", 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: "", 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 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 reply) + { + var rest = args; + var target = NextToken(ref rest); + var message = rest.Trim(); + if (target.Length == 0 || message.Length == 0) + { + reply(Notice("Usage: /pm ")); + 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 reply) + { + var message = args.Trim(); + if (message.Length == 0) + { + reply(Notice("Usage: /r ")); + 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 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("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 reply, out List 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 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 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 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 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 , 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 "); + 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); + } +} diff --git a/Whisper.csproj b/Whisper.csproj new file mode 100644 index 0000000..ce05eea --- /dev/null +++ b/Whisper.csproj @@ -0,0 +1,28 @@ + + + net10.0 + enable + enable + Whisper + Whisper + + + + + none + runtime + compile; build; native; contentfiles; analyzers; buildtransitive + + + + + + lib/CS2-SimpleAdminApi.dll + false + + + + + + + diff --git a/Writer.cs b/Writer.cs new file mode 100644 index 0000000..1e85f1a --- /dev/null +++ b/Writer.cs @@ -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 _queue = Channel.CreateUnbounded(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(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); + } +} diff --git a/lib/CS2-SimpleAdminApi.dll b/lib/CS2-SimpleAdminApi.dll new file mode 100644 index 0000000..3b594c7 Binary files /dev/null and b/lib/CS2-SimpleAdminApi.dll differ diff --git a/release.sh b/release.sh new file mode 100755 index 0000000..ace877c --- /dev/null +++ b/release.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# Build Whisper, package it as one tarball and publish it as a Forgejo release. +# +# ./release.sh 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-.tar.gz, laid out like the server's game/csgo/ - extract it there: +# tar -xzf Whisper-.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 (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"