Whisper/Whisper.cs
Astra c414e8a770 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).
2026-10-05 13:13:58 +01:00

656 lines
26 KiB
C#

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