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:
commit
e8e0d1d921
12 changed files with 1547 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
bin/
|
||||
obj/
|
||||
release-out/
|
||||
50
Config.cs
Normal file
50
Config.cs
Normal 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
126
Database.cs
Normal 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];
|
||||
}
|
||||
40
Migrations/001_CreateTables.sql
Normal file
40
Migrations/001_CreateTables.sql
Normal 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
35
Ops.cs
Normal 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
101
README.md
Normal 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
97
Tags.cs
Normal 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
656
Whisper.cs
Normal 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
28
Whisper.csproj
Normal 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
287
Writer.cs
Normal 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
BIN
lib/CS2-SimpleAdminApi.dll
Normal file
Binary file not shown.
124
release.sh
Executable file
124
release.sh
Executable 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue