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

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];
}